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.reference_definitions() {
619 if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
620 continue;
621 }
622 let url = reference.url.as_str();
623 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
624 continue;
625 }
626 index.add_md057_link_target(Md057LinkTarget {
627 target: url.to_string(),
628 origin: LinkOrigin::Body,
629 });
630 }
631
632 for link in frontmatter_values::link_destinations(ctx) {
633 let line = ctx.lines[link.line - 1].content(ctx.content);
634 let url = &line[link.range];
635 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
636 continue;
637 }
638 index.add_md057_link_target(Md057LinkTarget {
639 target: url.to_string(),
640 origin: LinkOrigin::FrontMatter { field: link.field },
641 });
642 }
643 }
644
645 fn resolve_in_search_paths(
648 decoded_path: &str,
649 search_paths: &[PathBuf],
650 policy: Option<&crate::lint_context::LinkTargetPolicy>,
651 ) -> Option<PathBuf> {
652 search_paths
653 .iter()
654 .find_map(|dir| Self::resolve_target(dir, &dir.join(decoded_path), policy))
655 }
656
657 fn resolve_target(
662 anchor: &Path,
663 path: &Path,
664 policy: Option<&crate::lint_context::LinkTargetPolicy>,
665 ) -> Option<PathBuf> {
666 if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
667 return Some(supplied);
668 }
669 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
670 return None;
671 }
672 resolve_existing_target(anchor, path)
673 }
674
675 fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
676 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
677 format!("Relative link '{url}' target not in the supplied document set")
678 } else {
679 format!("Relative link '{url}' does not exist")
680 }
681 }
682
683 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
689 if !self.config.compact_paths {
690 return None;
691 }
692
693 let path_end = url
695 .find('?')
696 .unwrap_or(url.len())
697 .min(url.find('#').unwrap_or(url.len()));
698 let path_part = &url[..path_end];
699 let suffix = &url[path_end..];
700
701 let decoded_path = url_decode(path_part);
703
704 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
705 }
706
707 fn self_referential_link(
719 &self,
720 url: &str,
721 base_path: &Path,
722 search_paths: &[PathBuf],
723 source_file: Option<&Path>,
724 policy: Option<&crate::lint_context::LinkTargetPolicy>,
725 ) -> Option<SelfReferentialLink> {
726 if !self.config.self_referential_links {
727 return None;
728 }
729 let source_file = source_file?;
730
731 let path_part = Self::strip_query_and_fragment(url);
732 if path_part.is_empty() {
733 return None;
734 }
735 let suffix = &url[path_part.len()..];
736
737 let decoded_path = url_decode(path_part);
738 let resolved = std::iter::once(base_path)
742 .chain(search_paths.iter().map(PathBuf::as_path))
743 .find_map(|dir| {
744 Self::resolve_target(dir, &Self::resolve_link_path_with_base(&decoded_path, dir), policy)
745 })?;
746 if !Self::is_same_file(&resolved, source_file) {
747 return None;
748 }
749
750 match suffix.strip_prefix('#') {
754 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
755 _ => Some(SelfReferentialLink::WholeFile),
756 }
757 }
758
759 fn diagnostic_position(ctx: &LintContext, positions: &mut SourcePositions<'_>, offset: usize) -> (usize, usize) {
763 let line = ctx.line_number_at_offset(offset);
764 if let Some(info) = ctx.line_info(line)
765 && offset <= info.byte_offset + info.byte_len
766 {
767 (line, positions.column_on_line(line - 1, offset) + 1)
768 } else {
769 ctx.offset_to_line_col(offset)
770 }
771 }
772
773 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
780 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
781 let label_end = Self::label_end(def)?;
782 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
783 title.saturating_sub(ref_def.byte_offset).min(def.len())
784 });
785 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
786 let start = ref_def.byte_offset + offset;
787 Some(start..start + ref_def.url.len())
788 }
789
790 fn label_end(def: &str) -> Option<usize> {
795 let bytes = def.as_bytes();
796 let mut i = 0;
797 while i < bytes.len() {
798 match bytes[i] {
799 b'\\' => i += 2,
800 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
801 _ => i += 1,
802 }
803 }
804 None
805 }
806
807 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
811 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
812 }
813
814 fn absolute_link_message(
817 &self,
818 url: &str,
819 anchor: &LazyAnchor<'_>,
820 project_root: &Path,
821 policy: Option<&crate::lint_context::LinkTargetPolicy>,
822 ) -> Option<String> {
823 match self.config.absolute_links {
824 AbsoluteLinksOption::Ignore => None,
825 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
826 AbsoluteLinksOption::RelativeToDocs => {
827 Self::validate_absolute_link_via_docs_dir(url, &anchor.base_path, policy)
828 }
829 AbsoluteLinksOption::RelativeToRoots => {
830 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root, policy)
831 }
832 }
833 }
834
835 fn check_front_matter(
842 &self,
843 ctx: &crate::lint_context::LintContext,
844 anchor: &LazyAnchor<'_>,
845 project_root: &Path,
846 warnings: &mut Vec<LintWarning>,
847 ) {
848 if !self.config.check_frontmatter {
849 return;
850 }
851
852 let ignored: HashSet<String> = self
853 .config
854 .ignore_frontmatter_fields
855 .iter()
856 .map(|field| field.to_lowercase())
857 .collect();
858
859 for link in frontmatter_values::link_destinations(ctx) {
860 if link.field_is_in(&ignored) {
861 continue;
862 }
863
864 let line = ctx.lines[link.line - 1].content(ctx.content);
865 let url = &line[link.range.clone()];
866
867 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
870 continue;
871 }
872
873 let column = byte_to_char_count(line, link.range.start);
874 let end_column = column + url.chars().count();
875
876 if Self::is_absolute_path(url) {
877 if let Some(message) = self.absolute_link_message(url, anchor, project_root, ctx.link_target_policy()) {
878 warnings.push(LintWarning {
879 rule_name: Some(self.name().to_string()),
880 line: link.line,
881 column,
882 end_line: link.line,
883 end_column,
884 message,
885 severity: Severity::Warning,
886 fix: None,
887 });
888 }
889 continue;
890 }
891
892 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
893 continue;
894 }
895
896 warnings.push(LintWarning {
897 rule_name: Some(self.name().to_string()),
898 line: link.line,
899 column,
900 end_line: link.line,
901 end_column,
902 message: Self::missing_relative_message(url, ctx.link_target_policy()),
903 severity: Severity::Error,
904 fix: None,
905 });
906 }
907 }
908
909 fn relative_target_exists(
911 url: &str,
912 base_path: &Path,
913 search_paths: &[PathBuf],
914 policy: Option<&crate::lint_context::LinkTargetPolicy>,
915 ) -> bool {
916 Self::resolve_relative(url, base_path, search_paths, policy).is_some()
917 }
918
919 fn resolve_relative(
926 url: &str,
927 base_path: &Path,
928 search_paths: &[PathBuf],
929 policy: Option<&crate::lint_context::LinkTargetPolicy>,
930 ) -> Option<PathBuf> {
931 let decoded_path = link_path_part(url);
932 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
933
934 if let Some(target) = Self::resolve_target(base_path, &resolved_path, policy) {
936 return Some(target);
937 }
938
939 if let Some(source) =
940 html_markdown_sources(&resolved_path).find_map(|source| Self::resolve_target(base_path, &source, policy))
941 {
942 return Some(source);
943 }
944
945 Self::resolve_in_search_paths(&decoded_path, search_paths, policy)
946 }
947
948 fn document_anchor(
951 &self,
952 flavor: crate::config::MarkdownFlavor,
953 source_file: Option<&Path>,
954 explicit_base: Option<PathBuf>,
955 project_root: &Path,
956 ) -> DocumentAnchor {
957 #[cfg(test)]
958 ANCHOR_RESOLUTIONS.with(|count| count.set(count.get() + 1));
959 let self_path = source_file.map(|source_file| {
960 crate::source_paths::canonicalize_source(source_file).unwrap_or_else(|_| source_file.to_path_buf())
961 });
962 let base_path = explicit_base.unwrap_or_else(|| {
963 self_path
964 .as_deref()
965 .and_then(Path::parent)
966 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
967 });
968 let search_paths = self.compute_search_paths(flavor, source_file, &base_path, project_root);
969 DocumentAnchor {
970 self_path,
971 base_path,
972 search_paths,
973 }
974 }
975
976 fn produces_fixes(&self) -> bool {
980 self.config.compact_paths || self.config.self_referential_links
981 }
982
983 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
985 match self_link {
986 SelfReferentialLink::Fragment(fragment) => {
987 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
988 }
989 SelfReferentialLink::WholeFile => {
990 format!("Relative link '{url}' points to the file it is in")
991 }
992 }
993 }
994
995 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
1001 if resolved.file_name() != source_file.file_name() {
1003 return false;
1004 }
1005 match (
1006 resolved.canonicalize(),
1007 crate::source_paths::canonicalize_source(source_file),
1008 ) {
1009 (Ok(link), Ok(source)) => link == source,
1010 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
1011 }
1012 }
1013
1014 fn validate_absolute_link_via_docs_dir(
1019 url: &str,
1020 source_path: &Path,
1021 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1022 ) -> Option<String> {
1023 let Some(resolution) = Self::resolve_absolute_via_docs_dir(url, source_path, policy) else {
1024 return Some(format!(
1025 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
1026 ));
1027 };
1028
1029 match resolution {
1030 Resolution::Found(_) => None,
1031 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
1032 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
1033 resolved.display()
1034 )),
1035 Resolution::NotFound { resolved } => Some(format!(
1036 "Absolute link '{url}' resolves to '{}' which does not exist",
1037 resolved.display()
1038 )),
1039 }
1040 }
1041
1042 fn validate_absolute_link_via_roots(
1051 url: &str,
1052 roots: &[String],
1053 project_root: &Path,
1054 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1055 ) -> Option<String> {
1056 if Self::resolve_absolute_via_roots(url, roots, project_root, policy).is_some() {
1057 return None;
1058 }
1059
1060 let msg = if roots.is_empty() {
1061 format!("Absolute link '{url}' was not found under the project root")
1062 } else {
1063 format!("Absolute link '{url}' was not found under any configured root or the project root")
1064 };
1065 Some(msg)
1066 }
1067
1068 fn resolve_absolute_via_docs_dir(
1074 url: &str,
1075 source_path: &Path,
1076 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1077 ) -> Option<Resolution> {
1078 let docs_dir = resolve_docs_dir(source_path)?;
1079 let decoded = Self::prepare_absolute_url(url);
1080 Some(Self::resolve_under_root_with_opts(&docs_dir, &decoded, true, policy))
1081 }
1082
1083 fn resolve_absolute_via_roots(
1089 url: &str,
1090 roots: &[String],
1091 project_root: &Path,
1092 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1093 ) -> Option<PathBuf> {
1094 let decoded = Self::prepare_absolute_url(url);
1095 roots
1096 .iter()
1097 .map(|root| Self::resolve_against_project_root(root, project_root))
1098 .chain(std::iter::once(project_root.to_path_buf()))
1099 .find_map(
1100 |root| match Self::resolve_under_root_with_opts(&root, &decoded, false, policy) {
1101 Resolution::Found(target) => Some(target),
1102 _ => None,
1103 },
1104 )
1105 }
1106
1107 fn prepare_absolute_url(url: &str) -> String {
1110 let relative_url = url.trim_start_matches('/');
1111 let file_path = Self::strip_query_and_fragment(relative_url);
1112 url_decode(file_path)
1113 }
1114
1115 fn resolve_under_root_with_opts(
1148 root_path: &Path,
1149 decoded: &str,
1150 require_index_for_dirs: bool,
1151 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1152 ) -> Resolution {
1153 let resolved = root_path.join(decoded);
1154
1155 if let Some(policy) = policy {
1156 if let Some(resolution) = Self::resolve_supplied_under_root(&resolved, require_index_for_dirs, policy) {
1157 return resolution;
1158 }
1159 if !policy.allow_disk_fallback() {
1160 return Resolution::NotFound { resolved };
1161 }
1162 }
1163
1164 if resolved.is_dir() && exists_exact_case(root_path, &resolved) {
1165 if !require_index_for_dirs {
1166 return Resolution::Found(resolved);
1167 }
1168 let index = resolved.join("index.md");
1169 return if exists_exact_case(root_path, &index) {
1170 Resolution::Found(index)
1171 } else {
1172 Resolution::DirectoryWithoutIndex { resolved }
1173 };
1174 }
1175
1176 if let Some(target) = resolve_existing_target(root_path, &resolved) {
1177 return Resolution::Found(target);
1178 }
1179
1180 if let Some(source) = html_markdown_sources(&resolved).find(|source| exists_exact_case(root_path, source)) {
1183 return Resolution::Found(source);
1184 }
1185
1186 Resolution::NotFound { resolved }
1187 }
1188
1189 fn resolve_supplied_under_root(
1195 resolved: &Path,
1196 require_index_for_dirs: bool,
1197 policy: &crate::lint_context::LinkTargetPolicy,
1198 ) -> Option<Resolution> {
1199 let Some(supplied) = policy.resolve_supplied(resolved) else {
1200 return html_markdown_sources(resolved)
1201 .find(|source| policy.contains(source))
1202 .map(Resolution::Found);
1203 };
1204 if policy.contains(&supplied) || !require_index_for_dirs {
1205 return Some(Resolution::Found(supplied));
1206 }
1207 let index = supplied.join("index.md");
1208 if policy.contains(&index) {
1209 Some(Resolution::Found(index))
1210 } else if policy.allow_disk_fallback() {
1211 None
1212 } else {
1213 Some(Resolution::DirectoryWithoutIndex {
1214 resolved: resolved.to_path_buf(),
1215 })
1216 }
1217 }
1218}
1219
1220#[cfg(feature = "blake3")]
1224impl MD057ExistingRelativeLinks {
1225 pub fn cache_dependency_fingerprint(
1231 &self,
1232 source_file: &Path,
1233 flavor: crate::config::MarkdownFlavor,
1234 file_index: &FileIndex,
1235 ) -> String {
1236 let mut hasher = blake3::Hasher::new();
1237 hasher.update(b"rumdl-md057-dependencies-v1");
1238 if file_index.md057_link_targets.is_empty() {
1239 return hasher.finalize().to_hex().to_string();
1240 }
1241
1242 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
1243 let project_root = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1244 let anchor = LazyCell::new(|| self.document_anchor(flavor, Some(source_file), explicit_base, &project_root));
1248 let ignored_frontmatter_fields: HashSet<String> = self
1249 .config
1250 .ignore_frontmatter_fields
1251 .iter()
1252 .map(|field| field.to_lowercase())
1253 .collect();
1254
1255 for dependency in &file_index.md057_link_targets {
1256 if let LinkOrigin::FrontMatter { field } = &dependency.origin
1257 && (!self.config.check_frontmatter
1258 || field
1259 .as_ref()
1260 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1261 {
1262 continue;
1263 }
1264
1265 let url = dependency.target.as_str();
1266 if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1267 continue;
1268 }
1269
1270 Self::hash_bytes(&mut hasher, url.as_bytes());
1271 if Self::is_absolute_path(url) {
1272 match self.config.absolute_links {
1273 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1274 AbsoluteLinksOption::RelativeToDocs => {
1275 hasher.update(b"docs");
1276 if let Some(docs_dir) = resolve_docs_dir(source_file) {
1277 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1278 } else {
1279 hasher.update(b"no-docs-dir");
1280 }
1281 }
1282 AbsoluteLinksOption::RelativeToRoots => {
1283 hasher.update(b"roots");
1284 let decoded = Self::prepare_absolute_url(url);
1285 let mut found = false;
1286 for root in &self.config.roots {
1287 let root_path = Self::resolve_against_project_root(root, &project_root);
1288 if Self::observe_under_root(&mut hasher, &root_path, &decoded, false) {
1289 found = true;
1290 break;
1291 }
1292 }
1293 if !found {
1294 Self::observe_under_root(&mut hasher, &project_root, &decoded, false);
1295 }
1296 }
1297 }
1298 } else {
1299 hasher.update(b"relative");
1300 if self.config.self_referential_links
1301 && Self::observe_self_referential_resolution(
1302 &mut hasher,
1303 url,
1304 &anchor.base_path,
1305 &anchor.search_paths,
1306 anchor.self_path.as_deref().unwrap_or(source_file),
1307 )
1308 {
1309 continue;
1310 }
1311 Self::observe_relative_resolution(&mut hasher, url, &anchor.base_path, &anchor.search_paths);
1312 }
1313 }
1314
1315 hasher.finalize().to_hex().to_string()
1316 }
1317
1318 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1319 hasher.update(&(bytes.len() as u64).to_le_bytes());
1320 hasher.update(bytes);
1321 }
1322
1323 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1324 #[cfg(unix)]
1325 {
1326 use std::os::unix::ffi::OsStrExt;
1327 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1328 }
1329 #[cfg(windows)]
1330 {
1331 use std::os::windows::ffi::OsStrExt;
1332 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1333 Self::hash_bytes(hasher, &encoded);
1334 }
1335 #[cfg(not(any(unix, windows)))]
1336 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1337 }
1338
1339 fn observe_path(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> DependencyPathState {
1344 Self::hash_path(hasher, path);
1345 let state = if exists_exact_case(anchor, path) {
1346 match std::fs::metadata(path) {
1347 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1348 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1349 Ok(_) => DependencyPathState::Other,
1350 Err(_) => DependencyPathState::Missing,
1351 }
1352 } else {
1353 DependencyPathState::Missing
1354 };
1355 hasher.update(&[match state {
1356 DependencyPathState::Missing => 0,
1357 DependencyPathState::File => 1,
1358 DependencyPathState::Directory => 2,
1359 DependencyPathState::Other => 3,
1360 }]);
1361 state
1362 }
1363
1364 fn observe_existing_target(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> Option<PathBuf> {
1365 if Self::observe_path(hasher, anchor, path) != DependencyPathState::Missing {
1366 return Some(path.to_path_buf());
1367 }
1368 if path.extension().is_none() {
1369 for extension in MARKDOWN_EXTENSIONS {
1370 let candidate = path.with_extension(&extension[1..]);
1371 if Self::observe_path(hasher, anchor, &candidate) != DependencyPathState::Missing {
1372 return Some(candidate);
1373 }
1374 }
1375 }
1376 None
1377 }
1378
1379 fn observe_self_referential_resolution(
1380 hasher: &mut blake3::Hasher,
1381 url: &str,
1382 base_path: &Path,
1383 search_paths: &[PathBuf],
1384 source_file: &Path,
1385 ) -> bool {
1386 let decoded = url_decode(Self::strip_query_and_fragment(url));
1387 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1388 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1389 if let Some(resolved) = Self::observe_existing_target(hasher, directory, &candidate) {
1390 let canonical = resolved.canonicalize().unwrap_or(resolved);
1391 hasher.update(b"resolved-identity");
1392 Self::hash_path(hasher, &canonical);
1393 return Self::is_same_file(&canonical, source_file);
1394 }
1395 }
1396 false
1397 }
1398
1399 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1400 let decoded = url_decode(Self::strip_query_and_fragment(url));
1401 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1402 if Self::observe_existing_target(hasher, base_path, &resolved).is_some() {
1403 return;
1404 }
1405
1406 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1407 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1408 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1409 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1410 Self::observe_path(hasher, base_path, &parent.join(format!("{stem}{extension}")))
1411 != DependencyPathState::Missing
1412 })
1413 {
1414 return;
1415 }
1416
1417 for search_path in search_paths {
1418 if Self::observe_existing_target(hasher, search_path, &search_path.join(&decoded)).is_some() {
1419 return;
1420 }
1421 }
1422 }
1423
1424 fn observe_absolute_resolution(
1425 hasher: &mut blake3::Hasher,
1426 root: &Path,
1427 url: &str,
1428 require_index_for_dirs: bool,
1429 ) -> bool {
1430 let decoded = Self::prepare_absolute_url(url);
1431 Self::observe_under_root(hasher, root, &decoded, require_index_for_dirs)
1432 }
1433
1434 fn observe_under_root(
1438 hasher: &mut blake3::Hasher,
1439 root: &Path,
1440 decoded: &str,
1441 require_index_for_dirs: bool,
1442 ) -> bool {
1443 let resolved = root.join(decoded);
1444 let resolved_state = Self::observe_path(hasher, root, &resolved);
1445
1446 if resolved_state == DependencyPathState::Directory {
1447 if !require_index_for_dirs {
1448 return true;
1449 }
1450 return Self::observe_path(hasher, root, &resolved.join("index.md")) != DependencyPathState::Missing;
1451 }
1452
1453 if resolved_state != DependencyPathState::Missing {
1454 return true;
1455 }
1456 if resolved.extension().is_none()
1457 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1458 Self::observe_path(hasher, root, &resolved.with_extension(&extension[1..]))
1459 != DependencyPathState::Missing
1460 })
1461 {
1462 return true;
1463 }
1464
1465 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1466 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1467 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1468 {
1469 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1470 Self::observe_path(hasher, root, &parent.join(format!("{stem}{extension}")))
1471 != DependencyPathState::Missing
1472 });
1473 }
1474
1475 false
1476 }
1477}
1478
1479enum Resolution {
1483 Found(PathBuf),
1484 DirectoryWithoutIndex { resolved: PathBuf },
1485 NotFound { resolved: PathBuf },
1486}
1487
1488impl Rule for MD057ExistingRelativeLinks {
1489 fn name(&self) -> &'static str {
1490 "MD057"
1491 }
1492
1493 fn description(&self) -> &'static str {
1494 "Relative links should point to existing files"
1495 }
1496
1497 fn category(&self) -> RuleCategory {
1498 RuleCategory::Link
1499 }
1500
1501 fn skippable_by_category(&self) -> bool {
1502 !self.config.check_frontmatter
1505 }
1506
1507 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1508 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1509 }
1510
1511 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1512 let content = ctx.content;
1513
1514 if content.is_empty() {
1515 return Ok(Vec::new());
1516 }
1517
1518 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1522 if !has_body_links && !self.checks_front_matter_of(ctx) {
1523 return Ok(Vec::new());
1524 }
1525
1526 reset_file_existence_cache();
1528 let _directory_check = DirectoryCheck::begin();
1529
1530 let mut warnings = Vec::new();
1531
1532 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1536
1537 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1541
1542 let source_file = ctx.source_file();
1545 if explicit_base.is_none() && source_file.is_none() {
1546 return Ok(warnings);
1547 }
1548 let anchor: LazyAnchor<'_> = LazyCell::new(Box::new(|| {
1551 self.document_anchor(ctx.flavor, source_file, explicit_base, &project_root)
1552 }));
1553
1554 let mut positions = SourcePositions::new(content, &ctx.lines);
1555 let mut position = |offset| Self::diagnostic_position(ctx, &mut positions, offset);
1556
1557 for destination in ctx.body_link_destinations() {
1562 let url = destination.url;
1563
1564 if url.is_empty() {
1566 continue;
1567 }
1568
1569 if url.starts_with('`') && url.ends_with('`') {
1573 continue;
1574 }
1575
1576 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1578 continue;
1579 }
1580
1581 if Self::is_absolute_path(url) {
1583 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1584 {
1585 let (line, column) = position(destination.url_range.start);
1586 warnings.push(LintWarning {
1587 rule_name: Some(self.name().to_string()),
1588 line,
1589 column,
1590 end_line: line,
1591 end_column: position(destination.url_range.end).1,
1592 message,
1593 severity: Severity::Warning,
1594 fix: None,
1595 });
1596 }
1597 continue;
1598 }
1599
1600 let full_url = destination.full_url();
1603
1604 if let Some(self_link) = self.self_referential_link(
1609 &full_url,
1610 &anchor.base_path,
1611 &anchor.search_paths,
1612 anchor.self_path.as_deref(),
1613 ctx.link_target_policy(),
1614 ) {
1615 let (line, column) = position(destination.url_range.start);
1616 warnings.push(LintWarning {
1617 rule_name: Some(self.name().to_string()),
1618 line,
1619 column,
1620 end_line: line,
1621 end_column: position(destination.fix_range.end).1,
1622 message: Self::self_referential_message(&full_url, &self_link),
1623 severity: Severity::Warning,
1624 fix: match &self_link {
1625 SelfReferentialLink::Fragment(fragment) => {
1626 Some(Fix::new(destination.fix_range.clone(), fragment.clone()))
1627 }
1628 SelfReferentialLink::WholeFile => None,
1629 },
1630 });
1631 continue;
1632 }
1633
1634 if let Some(suggestion) = self.compact_path_suggestion(&full_url, &anchor.base_path) {
1635 let (line, column) = position(destination.url_range.start);
1636 warnings.push(LintWarning {
1637 rule_name: Some(self.name().to_string()),
1638 line,
1639 column,
1640 end_line: line,
1641 end_column: position(destination.fix_range.end).1,
1642 message: format!("Relative link '{full_url}' can be simplified to '{suggestion}'"),
1643 severity: Severity::Warning,
1644 fix: Some(Fix::new(destination.fix_range.clone(), suggestion)),
1645 });
1646 }
1647
1648 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1649 continue;
1650 }
1651
1652 let (line, column) = position(destination.url_range.start);
1654 warnings.push(LintWarning {
1655 rule_name: Some(self.name().to_string()),
1656 line,
1657 column,
1658 end_line: line,
1659 end_column: position(destination.url_range.end).1,
1660 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1661 severity: Severity::Error,
1662 fix: None,
1663 });
1664 }
1665
1666 for image in ctx.images() {
1668 if ctx
1670 .line_info(image.line)
1671 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1672 {
1673 continue;
1674 }
1675
1676 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1680 continue;
1681 }
1682
1683 if ctx.is_in_shortcode(image.byte_offset) {
1686 continue;
1687 }
1688
1689 let url = image.url.as_ref();
1690
1691 if url.is_empty() {
1693 continue;
1694 }
1695
1696 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1698 continue;
1699 }
1700
1701 if Self::is_absolute_path(url) {
1703 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1704 {
1705 warnings.push(LintWarning {
1706 rule_name: Some(self.name().to_string()),
1707 line: image.line,
1708 column: image.start_col + 1,
1709 end_line: image.line,
1710 end_column: image.start_col + 1 + url.chars().count(),
1711 message,
1712 severity: Severity::Warning,
1713 fix: None,
1714 });
1715 }
1716 continue;
1717 }
1718
1719 if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1721 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1724 let fix_byte_start = image.byte_offset + url_offset;
1725 let fix_byte_end = fix_byte_start + url.len();
1726 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1727 });
1728
1729 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1730 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1731 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1734 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1735 });
1736 warnings.push(LintWarning {
1737 rule_name: Some(self.name().to_string()),
1738 line: image.line,
1739 column: url_col,
1740 end_line: image.line,
1741 end_column: url_col + url.chars().count(),
1742 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1743 severity: Severity::Warning,
1744 fix,
1745 });
1746 }
1747
1748 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1749 continue;
1750 }
1751
1752 warnings.push(LintWarning {
1755 rule_name: Some(self.name().to_string()),
1756 line: image.line,
1757 column: image.start_col + 1,
1758 end_line: image.line,
1759 end_column: image.start_col + 1 + url.chars().count(),
1760 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1761 severity: Severity::Error,
1762 fix: None,
1763 });
1764 }
1765
1766 for ref_def in ctx.reference_definitions() {
1768 if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1769 continue;
1770 }
1771 let url = &ref_def.url;
1772
1773 if url.is_empty() {
1775 continue;
1776 }
1777
1778 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1780 continue;
1781 }
1782
1783 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1787 let (line, col) = url_range
1788 .as_ref()
1789 .map_or((ref_def.line, 1), |range| position(range.start));
1790 let end_col = col + url.chars().count();
1791
1792 if Self::is_absolute_path(url) {
1794 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1795 {
1796 warnings.push(LintWarning {
1797 rule_name: Some(self.name().to_string()),
1798 line,
1799 column: col,
1800 end_line: line,
1801 end_column: end_col,
1802 message,
1803 severity: Severity::Warning,
1804 fix: None,
1805 });
1806 }
1807 continue;
1808 }
1809
1810 if let Some(self_link) = self.self_referential_link(
1812 url,
1813 &anchor.base_path,
1814 &anchor.search_paths,
1815 anchor.self_path.as_deref(),
1816 ctx.link_target_policy(),
1817 ) {
1818 warnings.push(LintWarning {
1819 rule_name: Some(self.name().to_string()),
1820 line,
1821 column: col,
1822 end_line: line,
1823 end_column: end_col,
1824 message: Self::self_referential_message(url, &self_link),
1825 severity: Severity::Warning,
1826 fix: match (&self_link, &url_range) {
1827 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1828 Some(Fix::new(range.clone(), fragment.clone()))
1829 }
1830 _ => None,
1831 },
1832 });
1833 continue;
1834 }
1835
1836 if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1838 warnings.push(LintWarning {
1839 rule_name: Some(self.name().to_string()),
1840 line,
1841 column: col,
1842 end_line: line,
1843 end_column: end_col,
1844 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1845 severity: Severity::Warning,
1846 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1847 });
1848 }
1849
1850 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1851 continue;
1852 }
1853
1854 warnings.push(LintWarning {
1856 rule_name: Some(self.name().to_string()),
1857 line,
1858 column: col,
1859 end_line: line,
1860 end_column: end_col,
1861 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1862 severity: Severity::Error,
1863 fix: None,
1864 });
1865 }
1866
1867 self.check_front_matter(ctx, &anchor, &project_root, &mut warnings);
1868
1869 Ok(warnings)
1870 }
1871
1872 fn fix_capability(&self) -> FixCapability {
1873 if self.produces_fixes() {
1874 FixCapability::ConditionallyFixable
1875 } else {
1876 FixCapability::Unfixable
1877 }
1878 }
1879
1880 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1881 if !self.produces_fixes() {
1882 return Ok(ctx.content.to_string());
1883 }
1884
1885 let warnings = self.check(ctx)?;
1886 let warnings =
1887 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1888 let mut content = ctx.content.to_string();
1889
1890 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1892 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1893
1894 let mut last_applied_start: Option<usize> = None;
1900 for fix in fixes {
1901 if let Some(prev_start) = last_applied_start
1902 && fix.range.end > prev_start
1903 {
1904 continue;
1905 }
1906 if fix.range.end <= content.len() {
1907 content.replace_range(fix.range.clone(), &fix.replacement);
1908 last_applied_start = Some(fix.range.start);
1909 }
1910 }
1911
1912 Ok(content)
1913 }
1914
1915 fn as_any(&self) -> &dyn std::any::Any {
1916 self
1917 }
1918
1919 crate::impl_rule_config_sections!(MD057Config);
1920
1921 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1922 where
1923 Self: Sized,
1924 {
1925 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1926 Box::new(Self::from_config_struct(rule_config))
1930 }
1931
1932 fn cross_file_scope(&self) -> CrossFileScope {
1933 CrossFileScope::Workspace
1934 }
1935
1936 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1937 self.contribute_dependency_targets(ctx, index);
1938
1939 let links = extract_cross_file_links(ctx);
1942 for link in links.relative {
1943 index.add_cross_file_link(link);
1944 }
1945 for link in links.root_relative {
1948 index.add_root_relative_link(link);
1949 }
1950 }
1951
1952 fn cross_file_check(
1953 &self,
1954 _file_path: &Path,
1955 _file_index: &FileIndex,
1956 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1957 ) -> LintResult {
1958 Ok(Vec::new())
1968 }
1969}
1970
1971fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1976 let from_components: Vec<_> = from_dir.components().collect();
1977 let to_components: Vec<_> = to_path.components().collect();
1978
1979 let common_len = from_components
1981 .iter()
1982 .zip(to_components.iter())
1983 .take_while(|(a, b)| a == b)
1984 .count();
1985
1986 let mut result = PathBuf::new();
1987
1988 for _ in common_len..from_components.len() {
1990 result.push("..");
1991 }
1992
1993 for component in &to_components[common_len..] {
1995 result.push(component);
1996 }
1997
1998 result
1999}
2000
2001fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
2007 let link_path = Path::new(raw_link_path);
2008
2009 let has_traversal = link_path
2011 .components()
2012 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
2013
2014 if !has_traversal {
2015 return None;
2016 }
2017
2018 let combined = source_dir.join(link_path);
2020 let normalized_target = normalize_relative_path(&combined);
2021
2022 let normalized_source = normalize_relative_path(source_dir);
2024 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
2025
2026 if shortest != link_path {
2028 let compact = shortest.to_string_lossy().to_string();
2029 if compact.is_empty() {
2031 return None;
2032 }
2033 Some(compact.replace('\\', "/"))
2035 } else {
2036 None
2037 }
2038}
2039
2040#[cfg(test)]
2041mod tests {
2042 use super::*;
2043 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
2044 use std::fs::File;
2045 use std::io::Write;
2046 use tempfile::tempdir;
2047
2048 #[test]
2049 fn test_strip_query_and_fragment() {
2050 assert_eq!(
2052 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
2053 "file.png"
2054 );
2055 assert_eq!(
2056 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
2057 "file.png"
2058 );
2059 assert_eq!(
2060 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
2061 "file.png"
2062 );
2063
2064 assert_eq!(
2066 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
2067 "file.md"
2068 );
2069 assert_eq!(
2070 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
2071 "file.md"
2072 );
2073
2074 assert_eq!(
2076 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
2077 "file.md"
2078 );
2079
2080 assert_eq!(
2082 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
2083 "file.png"
2084 );
2085
2086 assert_eq!(
2088 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
2089 "path/to/image.png"
2090 );
2091 assert_eq!(
2092 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
2093 "path/to/image.png"
2094 );
2095
2096 assert_eq!(
2098 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
2099 "file.md"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_url_decode() {
2105 assert_eq!(url_decode("penguin%20with%20space.jpg"), "penguin with space.jpg");
2107
2108 assert_eq!(url_decode("assets/my%20file%20name.png"), "assets/my file name.png");
2110
2111 assert_eq!(url_decode("hello%20world%21.md"), "hello world!.md");
2113
2114 assert_eq!(url_decode("%2f%2e%2e"), "/..");
2116
2117 assert_eq!(url_decode("%2F%2E%2E"), "/..");
2119
2120 assert_eq!(url_decode("%2f%2E%2e"), "/..");
2122
2123 assert_eq!(url_decode("normal-file.md"), "normal-file.md");
2125
2126 assert_eq!(url_decode("file%2.txt"), "file%2.txt");
2128
2129 assert_eq!(url_decode("file%"), "file%");
2131
2132 assert_eq!(url_decode("file%GG.txt"), "file%GG.txt");
2134
2135 assert_eq!(url_decode("file+name.txt"), "file+name.txt");
2137
2138 assert_eq!(url_decode(""), "");
2140
2141 assert_eq!(url_decode("caf%C3%A9.md"), "café.md");
2143
2144 assert_eq!(url_decode("%20%20%20"), " ");
2146
2147 assert_eq!(url_decode("path%2Fto%2Ffile.md"), "path/to/file.md");
2149
2150 assert_eq!(url_decode("hello%20world/foo%20bar.md"), "hello world/foo bar.md");
2152
2153 assert_eq!(url_decode("file%5B1%5D.md"), "file[1].md");
2155
2156 assert_eq!(url_decode("100%pure.md"), "100%pure.md");
2158 }
2159
2160 #[test]
2161 fn test_url_encoded_filenames() {
2162 let temp_dir = tempdir().unwrap();
2164 let base_path = temp_dir.path();
2165
2166 let file_with_spaces = base_path.join("penguin with space.jpg");
2168 File::create(&file_with_spaces)
2169 .unwrap()
2170 .write_all(b"image data")
2171 .unwrap();
2172
2173 let subdir = base_path.join("my images");
2175 std::fs::create_dir(&subdir).unwrap();
2176 let nested_file = subdir.join("photo 1.png");
2177 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2178
2179 let content = r#"
2181# Test Document with URL-Encoded Links
2182
2183
2184
2185
2186"#;
2187
2188 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2189
2190 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2191 let result = rule.check(&ctx).unwrap();
2192
2193 assert_eq!(
2195 result.len(),
2196 1,
2197 "Should only warn about missing%20file.jpg. Got: {result:?}"
2198 );
2199 assert!(
2200 result[0].message.contains("missing%20file.jpg"),
2201 "Warning should mention the URL-encoded filename"
2202 );
2203 }
2204
2205 #[test]
2206 fn test_external_urls() {
2207 assert!(is_external_url("https://example.com"));
2209 assert!(is_external_url("http://example.com"));
2210 assert!(is_external_url("ftp://example.com"));
2211 assert!(is_external_url("www.example.com"));
2212 assert!(is_external_url("example.com"));
2213
2214 assert!(is_external_url("file:///path/to/file"));
2216 assert!(is_external_url("smb://server/share"));
2217 assert!(is_external_url("macappstores://apps.apple.com/"));
2218 assert!(is_external_url("mailto:user@example.com"));
2219 assert!(is_external_url("tel:+1234567890"));
2220 assert!(is_external_url("data:text/plain;base64,SGVsbG8="));
2221 assert!(is_external_url("javascript:void(0)"));
2222 assert!(is_external_url("ssh://git@github.com/repo"));
2223 assert!(is_external_url("git://github.com/repo.git"));
2224
2225 assert!(is_external_url("user@example.com"));
2228 assert!(is_external_url("steering@kubernetes.io"));
2229 assert!(is_external_url("john.doe+filter@company.co.uk"));
2230 assert!(is_external_url("user_name@sub.domain.com"));
2231 assert!(is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2232
2233 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"));
2244 assert!(!is_external_url("/blog/2024/release.html"));
2245 assert!(!is_external_url("/react/hooks/use-state.html"));
2246 assert!(!is_external_url("/pkg/runtime"));
2247 assert!(!is_external_url("/doc/go1compat"));
2248 assert!(!is_external_url("/index.html"));
2249 assert!(!is_external_url("/assets/logo.png"));
2250
2251 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2253 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2254 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2255 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2256 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2257
2258 assert!(is_external_url("~/assets/image.png"));
2261 assert!(is_external_url("~/components/Button.vue"));
2262 assert!(is_external_url("~assets/logo.svg")); assert!(is_external_url("@/components/Header.vue"));
2266 assert!(is_external_url("@images/photo.jpg"));
2267 assert!(is_external_url("@assets/styles.css"));
2268
2269 assert!(!is_external_url("./relative/path.md"));
2271 assert!(!is_external_url("relative/path.md"));
2272 assert!(!is_external_url("../parent/path.md"));
2273 }
2274
2275 #[test]
2276 fn test_dot_com_only_skips_bare_domains() {
2277 assert!(is_external_url("example.com"));
2279 assert!(is_external_url("sub.example.com"));
2280
2281 assert!(!is_external_url("../../vendor.com"));
2285 assert!(!is_external_url("./vendor.com"));
2286 assert!(!is_external_url("docs/vendor.com"));
2287 }
2288
2289 #[test]
2290 fn test_framework_path_aliases() {
2291 let temp_dir = tempdir().unwrap();
2293 let base_path = temp_dir.path();
2294
2295 let content = r#"
2297# Framework Path Aliases
2298
2299
2300
2301
2302
2303[Link](@/pages/about.md)
2304
2305This is a [real missing link](missing.md) that should be flagged.
2306"#;
2307
2308 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2309
2310 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311 let result = rule.check(&ctx).unwrap();
2312
2313 assert_eq!(
2315 result.len(),
2316 1,
2317 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2318 );
2319 assert!(
2320 result[0].message.contains("missing.md"),
2321 "Warning should be for missing.md"
2322 );
2323 }
2324
2325 #[test]
2326 fn test_url_decode_security_path_traversal() {
2327 let temp_dir = tempdir().unwrap();
2330 let base_path = temp_dir.path();
2331
2332 let file_in_base = base_path.join("safe.md");
2334 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2335
2336 let content = r#"
2341[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2342[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2343[Safe link](safe.md)
2344"#;
2345
2346 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2347
2348 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2349 let result = rule.check(&ctx).unwrap();
2350
2351 assert_eq!(
2354 result.len(),
2355 2,
2356 "Should have warnings for traversal attempts. Got: {result:?}"
2357 );
2358 }
2359
2360 #[test]
2361 fn test_url_encoded_utf8_filenames() {
2362 let temp_dir = tempdir().unwrap();
2364 let base_path = temp_dir.path();
2365
2366 let cafe_file = base_path.join("café.md");
2368 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2369
2370 let content = r#"
2371[Café link](caf%C3%A9.md)
2372[Missing unicode](r%C3%A9sum%C3%A9.md)
2373"#;
2374
2375 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2376
2377 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2378 let result = rule.check(&ctx).unwrap();
2379
2380 assert_eq!(
2382 result.len(),
2383 1,
2384 "Should only warn about missing résumé.md. Got: {result:?}"
2385 );
2386 assert!(
2387 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2388 "Warning should mention the URL-encoded filename"
2389 );
2390 }
2391
2392 #[test]
2393 fn test_url_encoded_emoji_filenames() {
2394 let temp_dir = tempdir().unwrap();
2397 let base_path = temp_dir.path();
2398
2399 let emoji_dir = base_path.join("👤 Personal");
2401 std::fs::create_dir(&emoji_dir).unwrap();
2402
2403 let file_path = emoji_dir.join("TV Shows.md");
2405 File::create(&file_path)
2406 .unwrap()
2407 .write_all(b"# TV Shows\n\nContent here.")
2408 .unwrap();
2409
2410 let content = r#"
2413# Test Document
2414
2415[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2416[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2417"#;
2418
2419 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2420
2421 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2422 let result = rule.check(&ctx).unwrap();
2423
2424 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2426 assert!(
2427 result[0].message.contains("Missing.md"),
2428 "Warning should be for Missing.md, got: {}",
2429 result[0].message
2430 );
2431 }
2432
2433 #[test]
2434 fn test_no_warnings_without_base_path() {
2435 let rule = MD057ExistingRelativeLinks::new();
2436 let content = "[Link](missing.md)";
2437
2438 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2439 let result = rule.check(&ctx).unwrap();
2440 assert!(result.is_empty(), "Should have no warnings without base path");
2441 }
2442
2443 #[test]
2444 fn test_existing_and_missing_links() {
2445 let temp_dir = tempdir().unwrap();
2447 let base_path = temp_dir.path();
2448
2449 let exists_path = base_path.join("exists.md");
2451 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2452
2453 assert!(exists_path.exists(), "exists.md should exist for this test");
2455
2456 let content = r#"
2458# Test Document
2459
2460[Valid Link](exists.md)
2461[Invalid Link](missing.md)
2462[External Link](https://example.com)
2463[Media Link](image.jpg)
2464 "#;
2465
2466 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2468
2469 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2471 let result = rule.check(&ctx).unwrap();
2472
2473 assert_eq!(result.len(), 2);
2475 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2476 assert!(messages.iter().any(|m| m.contains("missing.md")));
2477 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2478 }
2479
2480 #[test]
2481 fn test_angle_bracket_links() {
2482 let temp_dir = tempdir().unwrap();
2484 let base_path = temp_dir.path();
2485
2486 let exists_path = base_path.join("exists.md");
2488 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2489
2490 let content = r#"
2492# Test Document
2493
2494[Valid Link](<exists.md>)
2495[Invalid Link](<missing.md>)
2496[External Link](<https://example.com>)
2497 "#;
2498
2499 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2501
2502 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2503 let result = rule.check(&ctx).unwrap();
2504
2505 assert_eq!(result.len(), 1, "Should have exactly one warning");
2507 assert!(
2508 result[0].message.contains("missing.md"),
2509 "Warning should mention missing.md"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_angle_bracket_links_with_parens() {
2515 let temp_dir = tempdir().unwrap();
2517 let base_path = temp_dir.path();
2518
2519 let app_dir = base_path.join("app");
2521 std::fs::create_dir(&app_dir).unwrap();
2522 let upload_dir = app_dir.join("(upload)");
2523 std::fs::create_dir(&upload_dir).unwrap();
2524 let page_file = upload_dir.join("page.tsx");
2525 File::create(&page_file)
2526 .unwrap()
2527 .write_all(b"export default function Page() {}")
2528 .unwrap();
2529
2530 let content = r#"
2532# Test Document with Paths Containing Parens
2533
2534[Upload Page](<app/(upload)/page.tsx>)
2535[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2536[Missing](<app/(missing)/file.md>)
2537"#;
2538
2539 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2540
2541 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2542 let result = rule.check(&ctx).unwrap();
2543
2544 assert_eq!(
2546 result.len(),
2547 1,
2548 "Should have exactly one warning for missing file. Got: {result:?}"
2549 );
2550 assert!(
2551 result[0].message.contains("app/(missing)/file.md"),
2552 "Warning should mention app/(missing)/file.md"
2553 );
2554 }
2555
2556 #[test]
2557 fn test_balanced_parentheses_in_link_paths() {
2558 let temp_dir = tempdir().unwrap();
2563 let base_path = temp_dir.path();
2564
2565 let paren_file = base_path.join("file(inner).md");
2567 File::create(&paren_file)
2568 .unwrap()
2569 .write_all(b"# file(inner).md\n")
2570 .unwrap();
2571
2572 let folder = base_path.join("folder(inner)");
2574 std::fs::create_dir(&folder).unwrap();
2575 File::create(folder.join("file.md"))
2576 .unwrap()
2577 .write_all(b"# folder(inner)/file.md\n")
2578 .unwrap();
2579
2580 let content = r#"
2581# Test cases
2582
2583[File with parenthesis exists](file(inner).md)
2584[Folder with parenthesis exists](folder(inner)/file.md)
2585[Missing with parenthesis](missing(inner).md)
2586"#;
2587
2588 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2589
2590 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2591 let result = rule.check(&ctx).unwrap();
2592
2593 assert_eq!(
2596 result.len(),
2597 1,
2598 "Expected exactly one warning (the missing file). Got: {result:?}"
2599 );
2600 assert!(
2601 result[0].message.contains("missing(inner).md"),
2602 "Warning should name the full path `missing(inner).md`, got: {}",
2603 result[0].message
2604 );
2605 }
2606
2607 #[test]
2608 fn test_all_file_types_checked() {
2609 let temp_dir = tempdir().unwrap();
2611 let base_path = temp_dir.path();
2612
2613 let content = r#"
2615[Image Link](image.jpg)
2616[Video Link](video.mp4)
2617[Markdown Link](document.md)
2618[PDF Link](file.pdf)
2619"#;
2620
2621 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2622
2623 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2624 let result = rule.check(&ctx).unwrap();
2625
2626 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2628 }
2629
2630 #[test]
2631 fn test_code_span_detection() {
2632 let rule = MD057ExistingRelativeLinks::new();
2633
2634 let temp_dir = tempdir().unwrap();
2636 let base_path = temp_dir.path();
2637
2638 let rule = rule.with_path(base_path);
2639
2640 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2642
2643 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2644 let result = rule.check(&ctx).unwrap();
2645
2646 assert_eq!(result.len(), 1, "Should only flag the real link");
2648 assert!(result[0].message.contains("nonexistent.md"));
2649 }
2650
2651 #[test]
2652 fn test_inline_code_spans() {
2653 let temp_dir = tempdir().unwrap();
2655 let base_path = temp_dir.path();
2656
2657 let content = r#"
2659# Test Document
2660
2661This is a normal link: [Link](missing.md)
2662
2663This is a code span with a link: `[Link](another-missing.md)`
2664
2665Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2666
2667 "#;
2668
2669 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2671
2672 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2674 let result = rule.check(&ctx).unwrap();
2675
2676 assert_eq!(result.len(), 1, "Should have exactly one warning");
2678 assert!(
2679 result[0].message.contains("missing.md"),
2680 "Warning should be for missing.md"
2681 );
2682 assert!(
2683 !result.iter().any(|w| w.message.contains("another-missing.md")),
2684 "Should not warn about link in code span"
2685 );
2686 assert!(
2687 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2688 "Should not warn about link in inline code"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_extensionless_link_resolution() {
2694 let temp_dir = tempdir().unwrap();
2696 let base_path = temp_dir.path();
2697
2698 let page_path = base_path.join("page.md");
2700 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2701
2702 let content = r#"
2704# Test Document
2705
2706[Link without extension](page)
2707[Link with extension](page.md)
2708[Missing link](nonexistent)
2709"#;
2710
2711 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2712
2713 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2714 let result = rule.check(&ctx).unwrap();
2715
2716 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2719 assert!(
2720 result[0].message.contains("nonexistent"),
2721 "Warning should be for 'nonexistent' not 'page'"
2722 );
2723 }
2724
2725 #[test]
2727 fn test_cross_file_scope() {
2728 let rule = MD057ExistingRelativeLinks::new();
2729 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2730 }
2731
2732 #[test]
2733 fn test_contribute_to_index_extracts_markdown_links() {
2734 let rule = MD057ExistingRelativeLinks::new();
2735 let content = r#"
2736# Document
2737
2738[Link to docs](./docs/guide.md)
2739[Link with fragment](./other.md#section)
2740[External link](https://example.com)
2741[Image link](image.png)
2742[Media file](video.mp4)
2743"#;
2744
2745 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2746 let mut index = FileIndex::new();
2747 rule.contribute_to_index(&ctx, &mut index);
2748
2749 assert_eq!(index.cross_file_links.len(), 2);
2751
2752 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2754 assert_eq!(index.cross_file_links[0].fragment, "");
2755
2756 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2758 assert_eq!(index.cross_file_links[1].fragment, "section");
2759 }
2760
2761 #[test]
2762 fn test_contribute_to_index_skips_external_and_anchors() {
2763 let rule = MD057ExistingRelativeLinks::new();
2764 let content = r#"
2765# Document
2766
2767[External](https://example.com)
2768[Another external](http://example.org)
2769[Fragment only](#section)
2770[FTP link](ftp://files.example.com)
2771[Mail link](mailto:test@example.com)
2772[WWW link](www.example.com)
2773"#;
2774
2775 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2776 let mut index = FileIndex::new();
2777 rule.contribute_to_index(&ctx, &mut index);
2778
2779 assert_eq!(index.cross_file_links.len(), 0);
2781 }
2782
2783 #[test]
2784 fn test_cross_file_check_valid_link() {
2785 use crate::workspace_index::WorkspaceIndex;
2786
2787 let rule = MD057ExistingRelativeLinks::new();
2788
2789 let mut workspace_index = WorkspaceIndex::new();
2791 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2792
2793 let mut file_index = FileIndex::new();
2795 file_index.add_cross_file_link(CrossFileLinkIndex {
2796 target_path: "guide.md".to_string(),
2797 fragment: "".to_string(),
2798 line: 5,
2799 column: 1,
2800 origin: LinkOrigin::Body,
2801 });
2802
2803 let warnings = rule
2805 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2806 .unwrap();
2807
2808 assert!(warnings.is_empty());
2810 }
2811
2812 #[test]
2813 fn test_cross_file_check_missing_link() {
2814 use crate::workspace_index::WorkspaceIndex;
2817
2818 let rule = MD057ExistingRelativeLinks::new();
2819 let workspace_index = WorkspaceIndex::new();
2820
2821 let mut file_index = FileIndex::new();
2822 file_index.add_cross_file_link(CrossFileLinkIndex {
2823 target_path: "missing.md".to_string(),
2824 fragment: "".to_string(),
2825 line: 5,
2826 column: 1,
2827 origin: LinkOrigin::Body,
2828 });
2829
2830 let warnings = rule
2831 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2832 .unwrap();
2833
2834 assert!(
2836 warnings.is_empty(),
2837 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2838 );
2839 }
2840
2841 #[test]
2842 fn test_cross_file_check_parent_path() {
2843 use crate::workspace_index::WorkspaceIndex;
2844
2845 let rule = MD057ExistingRelativeLinks::new();
2846
2847 let mut workspace_index = WorkspaceIndex::new();
2849 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2850
2851 let mut file_index = FileIndex::new();
2853 file_index.add_cross_file_link(CrossFileLinkIndex {
2854 target_path: "../readme.md".to_string(),
2855 fragment: "".to_string(),
2856 line: 5,
2857 column: 1,
2858 origin: LinkOrigin::Body,
2859 });
2860
2861 let warnings = rule
2863 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2864 .unwrap();
2865
2866 assert!(warnings.is_empty());
2868 }
2869
2870 #[test]
2871 fn test_cross_file_check_html_link_with_md_source() {
2872 use crate::workspace_index::WorkspaceIndex;
2875
2876 let rule = MD057ExistingRelativeLinks::new();
2877
2878 let mut workspace_index = WorkspaceIndex::new();
2880 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2881
2882 let mut file_index = FileIndex::new();
2884 file_index.add_cross_file_link(CrossFileLinkIndex {
2885 target_path: "guide.html".to_string(),
2886 fragment: "section".to_string(),
2887 line: 10,
2888 column: 5,
2889 origin: LinkOrigin::Body,
2890 });
2891
2892 let warnings = rule
2894 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2895 .unwrap();
2896
2897 assert!(
2899 warnings.is_empty(),
2900 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2901 );
2902 }
2903
2904 #[test]
2905 fn test_cross_file_check_html_link_without_source() {
2906 use crate::workspace_index::WorkspaceIndex;
2910
2911 let rule = MD057ExistingRelativeLinks::new();
2912 let workspace_index = WorkspaceIndex::new();
2913
2914 let mut file_index = FileIndex::new();
2915 file_index.add_cross_file_link(CrossFileLinkIndex {
2916 target_path: "missing.html".to_string(),
2917 fragment: "".to_string(),
2918 line: 10,
2919 column: 5,
2920 origin: LinkOrigin::Body,
2921 });
2922
2923 let warnings = rule
2924 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2925 .unwrap();
2926
2927 assert!(
2929 warnings.is_empty(),
2930 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_normalize_path_function() {
2936 assert_eq!(
2938 normalize_relative_path(Path::new("docs/guide.md")),
2939 PathBuf::from("docs/guide.md")
2940 );
2941
2942 assert_eq!(
2944 normalize_relative_path(Path::new("./docs/guide.md")),
2945 PathBuf::from("docs/guide.md")
2946 );
2947
2948 assert_eq!(
2950 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2951 PathBuf::from("docs/guide.md")
2952 );
2953
2954 assert_eq!(
2956 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2957 PathBuf::from("a/d.md")
2958 );
2959 }
2960
2961 #[test]
2962 fn test_html_link_with_md_source() {
2963 let temp_dir = tempdir().unwrap();
2965 let base_path = temp_dir.path();
2966
2967 let md_file = base_path.join("guide.md");
2969 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2970
2971 let content = r#"
2972[Read the guide](guide.html)
2973[Also here](getting-started.html)
2974"#;
2975
2976 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2977 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2978 let result = rule.check(&ctx).unwrap();
2979
2980 assert_eq!(
2982 result.len(),
2983 1,
2984 "Should only warn about missing source. Got: {result:?}"
2985 );
2986 assert!(result[0].message.contains("getting-started.html"));
2987 }
2988
2989 #[test]
2990 fn test_htm_link_with_md_source() {
2991 let temp_dir = tempdir().unwrap();
2993 let base_path = temp_dir.path();
2994
2995 let md_file = base_path.join("page.md");
2996 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2997
2998 let content = "[Page](page.htm)";
2999
3000 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3001 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3002 let result = rule.check(&ctx).unwrap();
3003
3004 assert!(
3005 result.is_empty(),
3006 "Should not warn when .md source exists for .htm link"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_html_link_finds_various_markdown_extensions() {
3012 let temp_dir = tempdir().unwrap();
3014 let base_path = temp_dir.path();
3015
3016 File::create(base_path.join("doc.md")).unwrap();
3017 File::create(base_path.join("tutorial.mdx")).unwrap();
3018 File::create(base_path.join("guide.markdown")).unwrap();
3019
3020 let content = r#"
3021[Doc](doc.html)
3022[Tutorial](tutorial.html)
3023[Guide](guide.html)
3024"#;
3025
3026 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3027 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3028 let result = rule.check(&ctx).unwrap();
3029
3030 assert!(
3031 result.is_empty(),
3032 "Should find all markdown variants as source files. Got: {result:?}"
3033 );
3034 }
3035
3036 #[test]
3037 fn test_html_link_in_subdirectory() {
3038 let temp_dir = tempdir().unwrap();
3040 let base_path = temp_dir.path();
3041
3042 let docs_dir = base_path.join("docs");
3043 std::fs::create_dir(&docs_dir).unwrap();
3044 File::create(docs_dir.join("guide.md"))
3045 .unwrap()
3046 .write_all(b"# Guide")
3047 .unwrap();
3048
3049 let content = "[Guide](docs/guide.html)";
3050
3051 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3052 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3053 let result = rule.check(&ctx).unwrap();
3054
3055 assert!(result.is_empty(), "Should find markdown source in subdirectory");
3056 }
3057
3058 #[test]
3059 fn test_absolute_path_skipped_in_check() {
3060 let temp_dir = tempdir().unwrap();
3063 let base_path = temp_dir.path();
3064
3065 let content = r#"
3066# Test Document
3067
3068[Go Runtime](/pkg/runtime)
3069[Go Runtime with Fragment](/pkg/runtime#section)
3070[API Docs](/api/v1/users)
3071[Blog Post](/blog/2024/release.html)
3072[React Hook](/react/hooks/use-state.html)
3073"#;
3074
3075 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3076 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3077 let result = rule.check(&ctx).unwrap();
3078
3079 assert!(
3081 result.is_empty(),
3082 "Absolute paths should be skipped. Got warnings: {result:?}"
3083 );
3084 }
3085
3086 #[test]
3087 fn test_absolute_path_skipped_in_cross_file_check() {
3088 use crate::workspace_index::WorkspaceIndex;
3090
3091 let rule = MD057ExistingRelativeLinks::new();
3092
3093 let workspace_index = WorkspaceIndex::new();
3095
3096 let mut file_index = FileIndex::new();
3098 file_index.add_cross_file_link(CrossFileLinkIndex {
3099 target_path: "/pkg/runtime.md".to_string(),
3100 fragment: "".to_string(),
3101 line: 5,
3102 column: 1,
3103 origin: LinkOrigin::Body,
3104 });
3105 file_index.add_cross_file_link(CrossFileLinkIndex {
3106 target_path: "/api/v1/users.md".to_string(),
3107 fragment: "section".to_string(),
3108 line: 10,
3109 column: 1,
3110 origin: LinkOrigin::Body,
3111 });
3112
3113 let warnings = rule
3115 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3116 .unwrap();
3117
3118 assert!(
3120 warnings.is_empty(),
3121 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
3122 );
3123 }
3124
3125 #[test]
3126 fn test_protocol_relative_url_not_skipped() {
3127 let temp_dir = tempdir().unwrap();
3130 let base_path = temp_dir.path();
3131
3132 let content = r#"
3133# Test Document
3134
3135[External](//example.com/page)
3136[Another](//cdn.example.com/asset.js)
3137"#;
3138
3139 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3140 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3141 let result = rule.check(&ctx).unwrap();
3142
3143 assert!(
3145 result.is_empty(),
3146 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_email_addresses_skipped() {
3152 let temp_dir = tempdir().unwrap();
3155 let base_path = temp_dir.path();
3156
3157 let content = r#"
3158# Test Document
3159
3160[Contact](user@example.com)
3161[Steering](steering@kubernetes.io)
3162[Support](john.doe+filter@company.co.uk)
3163[User](user_name@sub.domain.com)
3164"#;
3165
3166 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3167 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3168 let result = rule.check(&ctx).unwrap();
3169
3170 assert!(
3172 result.is_empty(),
3173 "Email addresses should be skipped. Got warnings: {result:?}"
3174 );
3175 }
3176
3177 #[test]
3178 fn test_email_addresses_vs_file_paths() {
3179 let temp_dir = tempdir().unwrap();
3182 let base_path = temp_dir.path();
3183
3184 let content = r#"
3185# Test Document
3186
3187[Email](user@example.com) <!-- Should be skipped (email) -->
3188[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
3189[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
3190"#;
3191
3192 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3193 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3194 let result = rule.check(&ctx).unwrap();
3195
3196 assert!(
3198 result.is_empty(),
3199 "All email addresses should be skipped. Got: {result:?}"
3200 );
3201 }
3202
3203 #[test]
3204 fn test_diagnostic_position_accuracy() {
3205 let temp_dir = tempdir().unwrap();
3207 let base_path = temp_dir.path();
3208
3209 let content = "prefix [text](missing.md) suffix";
3212 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3216 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3217 let result = rule.check(&ctx).unwrap();
3218
3219 assert_eq!(result.len(), 1, "Should have exactly one warning");
3220 assert_eq!(result[0].line, 1, "Should be on line 1");
3221 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3222 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3223 }
3224
3225 #[test]
3226 fn test_diagnostic_position_non_ascii_link() {
3227 let temp_dir = tempdir().unwrap();
3230 let base_path = temp_dir.path();
3231
3232 let content = "你好你好[你好](not-exist.md) bar";
3236
3237 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3238 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3239 let result = rule.check(&ctx).unwrap();
3240
3241 assert_eq!(result.len(), 1, "Should have exactly one warning");
3242 assert_eq!(result[0].line, 1, "Should be on line 1");
3243 assert_eq!(
3244 result[0].column, 10,
3245 "Column must be a character offset, not a byte offset"
3246 );
3247 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3248 }
3249
3250 #[test]
3251 fn test_diagnostic_position_angle_brackets() {
3252 let temp_dir = tempdir().unwrap();
3254 let base_path = temp_dir.path();
3255
3256 let content = "[link](<missing.md>)";
3259 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3262 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3263 let result = rule.check(&ctx).unwrap();
3264
3265 assert_eq!(result.len(), 1, "Should have exactly one warning");
3266 assert_eq!(result[0].line, 1, "Should be on line 1");
3267 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3268 }
3269
3270 #[test]
3271 fn test_diagnostic_position_multiline() {
3272 let temp_dir = tempdir().unwrap();
3274 let base_path = temp_dir.path();
3275
3276 let content = r#"# Title
3277Some text on line 2
3278[link on line 3](missing1.md)
3279More text
3280[link on line 5](missing2.md)"#;
3281
3282 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3284 let result = rule.check(&ctx).unwrap();
3285
3286 assert_eq!(result.len(), 2, "Should have two warnings");
3287
3288 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3290 assert!(result[0].message.contains("missing1.md"));
3291
3292 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3294 assert!(result[1].message.contains("missing2.md"));
3295 }
3296
3297 #[test]
3298 fn test_diagnostic_position_with_spaces() {
3299 let temp_dir = tempdir().unwrap();
3301 let base_path = temp_dir.path();
3302
3303 let content = "[link]( missing.md )";
3304 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3309 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3310 let result = rule.check(&ctx).unwrap();
3311
3312 assert_eq!(result.len(), 1, "Should have exactly one warning");
3313 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3315 }
3316
3317 #[test]
3318 fn test_diagnostic_position_image() {
3319 let temp_dir = tempdir().unwrap();
3321 let base_path = temp_dir.path();
3322
3323 let content = "";
3324
3325 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3326 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3327 let result = rule.check(&ctx).unwrap();
3328
3329 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3330 assert_eq!(result[0].line, 1);
3331 assert!(result[0].column > 0, "Should have valid column position");
3333 assert!(result[0].message.contains("missing.jpg"));
3334 }
3335
3336 #[test]
3337 fn test_diagnostic_position_non_ascii_image() {
3338 let temp_dir = tempdir().unwrap();
3340 let base_path = temp_dir.path();
3341
3342 let content = "你好你好";
3345
3346 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3347 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3348 let result = rule.check(&ctx).unwrap();
3349
3350 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3351 assert_eq!(result[0].line, 1, "Should be on line 1");
3352 assert_eq!(
3353 result[0].column, 5,
3354 "Column must be a character offset, not a byte offset"
3355 );
3356 assert!(result[0].message.contains("not-exist.png"));
3357 }
3358
3359 #[test]
3360 fn test_diagnostic_position_non_ascii_reference_def() {
3361 let temp_dir = tempdir().unwrap();
3365 let base_path = temp_dir.path();
3366
3367 let content = "[你好]: not-exist.md";
3370
3371 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3372 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3373 let result = rule.check(&ctx).unwrap();
3374
3375 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3376 assert_eq!(result[0].line, 1, "Should be on line 1");
3377 assert_eq!(
3378 result[0].column, 7,
3379 "Column must be a character offset, not a byte offset"
3380 );
3381 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3382 }
3383
3384 #[test]
3385 fn test_wikilinks_skipped() {
3386 let temp_dir = tempdir().unwrap();
3389 let base_path = temp_dir.path();
3390
3391 let content = r#"# Test Document
3392
3393[[Microsoft#Windows OS]]
3394[[SomePage]]
3395[[Page With Spaces]]
3396[[path/to/page#section]]
3397[[page|Display Text]]
3398
3399This is a [real missing link](missing.md) that should be flagged.
3400"#;
3401
3402 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3403 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3404 let result = rule.check(&ctx).unwrap();
3405
3406 assert_eq!(
3408 result.len(),
3409 1,
3410 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3411 );
3412 assert!(
3413 result[0].message.contains("missing.md"),
3414 "Warning should be for missing.md, not wikilinks"
3415 );
3416 }
3417
3418 #[test]
3419 fn test_wiki_embeds_skipped() {
3420 let temp_dir = tempdir().unwrap();
3424 let base_path = temp_dir.path();
3425
3426 let content = r#"# Test Document
3427
3428![[diagram.png]]
3429![[subfolder/diagram.png]]
3430![[diagram.png|300]]
3431![[Some Note]]
3432
3433This is a [real missing link](missing.md) that should be flagged.
3434"#;
3435
3436 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3437 for flavor in [
3438 crate::config::MarkdownFlavor::Obsidian,
3439 crate::config::MarkdownFlavor::Standard,
3440 ] {
3441 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3442 let result = rule.check(&ctx).unwrap();
3443
3444 assert_eq!(
3445 result.len(),
3446 1,
3447 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3448 );
3449 assert!(result[0].message.contains("missing.md"));
3450 }
3451 }
3452
3453 #[test]
3454 fn test_wikilinks_not_added_to_index() {
3455 let temp_dir = tempdir().unwrap();
3457 let base_path = temp_dir.path();
3458
3459 let content = r#"# Test Document
3460
3461[[Microsoft#Windows OS]]
3462[[SomePage#section]]
3463[Regular Link](other.md)
3464"#;
3465
3466 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3467 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3468
3469 let mut file_index = FileIndex::new();
3470 rule.contribute_to_index(&ctx, &mut file_index);
3471
3472 let cross_file_links = &file_index.cross_file_links;
3475 assert_eq!(
3476 cross_file_links.len(),
3477 1,
3478 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3479 );
3480 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3481 }
3482
3483 #[test]
3484 fn test_reference_definition_missing_file() {
3485 let temp_dir = tempdir().unwrap();
3487 let base_path = temp_dir.path();
3488
3489 let content = r#"# Test Document
3490
3491[test]: ./missing.md
3492[example]: ./nonexistent.html
3493
3494Use [test] and [example] here.
3495"#;
3496
3497 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3498 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3499 let result = rule.check(&ctx).unwrap();
3500
3501 assert_eq!(
3503 result.len(),
3504 2,
3505 "Should have warnings for missing reference definition targets. Got: {result:?}"
3506 );
3507 assert!(
3508 result.iter().any(|w| w.message.contains("missing.md")),
3509 "Should warn about missing.md"
3510 );
3511 assert!(
3512 result.iter().any(|w| w.message.contains("nonexistent.html")),
3513 "Should warn about nonexistent.html"
3514 );
3515 }
3516
3517 #[test]
3518 fn test_reference_definition_existing_file() {
3519 let temp_dir = tempdir().unwrap();
3521 let base_path = temp_dir.path();
3522
3523 let exists_path = base_path.join("exists.md");
3525 File::create(&exists_path)
3526 .unwrap()
3527 .write_all(b"# Existing file")
3528 .unwrap();
3529
3530 let content = r#"# Test Document
3531
3532[test]: ./exists.md
3533
3534Use [test] here.
3535"#;
3536
3537 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3538 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3539 let result = rule.check(&ctx).unwrap();
3540
3541 assert!(
3543 result.is_empty(),
3544 "Should not warn about existing file. Got: {result:?}"
3545 );
3546 }
3547
3548 #[test]
3549 fn test_reference_definition_external_url_skipped() {
3550 let temp_dir = tempdir().unwrap();
3552 let base_path = temp_dir.path();
3553
3554 let content = r#"# Test Document
3555
3556[google]: https://google.com
3557[example]: http://example.org
3558[mail]: mailto:test@example.com
3559[ftp]: ftp://files.example.com
3560[local]: ./missing.md
3561
3562Use [google], [example], [mail], [ftp], [local] here.
3563"#;
3564
3565 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3566 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3567 let result = rule.check(&ctx).unwrap();
3568
3569 assert_eq!(
3571 result.len(),
3572 1,
3573 "Should only warn about local missing file. Got: {result:?}"
3574 );
3575 assert!(
3576 result[0].message.contains("missing.md"),
3577 "Warning should be for missing.md"
3578 );
3579 }
3580
3581 #[test]
3582 fn test_reference_definition_fragment_only_skipped() {
3583 let temp_dir = tempdir().unwrap();
3585 let base_path = temp_dir.path();
3586
3587 let content = r#"# Test Document
3588
3589[section]: #my-section
3590
3591Use [section] here.
3592"#;
3593
3594 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3595 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3596 let result = rule.check(&ctx).unwrap();
3597
3598 assert!(
3600 result.is_empty(),
3601 "Should not warn about fragment-only reference. Got: {result:?}"
3602 );
3603 }
3604
3605 #[test]
3606 fn test_reference_definition_column_position() {
3607 let temp_dir = tempdir().unwrap();
3609 let base_path = temp_dir.path();
3610
3611 let content = "[ref]: ./missing.md";
3614 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3618 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3619 let result = rule.check(&ctx).unwrap();
3620
3621 assert_eq!(result.len(), 1, "Should have exactly one warning");
3622 assert_eq!(result[0].line, 1, "Should be on line 1");
3623 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3624 }
3625
3626 #[test]
3627 fn test_reference_definition_html_with_md_source() {
3628 let temp_dir = tempdir().unwrap();
3630 let base_path = temp_dir.path();
3631
3632 let md_file = base_path.join("guide.md");
3634 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3635
3636 let content = r#"# Test Document
3637
3638[guide]: ./guide.html
3639[missing]: ./missing.html
3640
3641Use [guide] and [missing] here.
3642"#;
3643
3644 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3646 let result = rule.check(&ctx).unwrap();
3647
3648 assert_eq!(
3650 result.len(),
3651 1,
3652 "Should only warn about missing source. Got: {result:?}"
3653 );
3654 assert!(result[0].message.contains("missing.html"));
3655 }
3656
3657 #[test]
3658 fn test_reference_definition_url_encoded() {
3659 let temp_dir = tempdir().unwrap();
3661 let base_path = temp_dir.path();
3662
3663 let file_with_spaces = base_path.join("file with spaces.md");
3665 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3666
3667 let content = r#"# Test Document
3668
3669[spaces]: ./file%20with%20spaces.md
3670[missing]: ./missing%20file.md
3671
3672Use [spaces] and [missing] here.
3673"#;
3674
3675 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3676 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3677 let result = rule.check(&ctx).unwrap();
3678
3679 assert_eq!(
3681 result.len(),
3682 1,
3683 "Should only warn about missing URL-encoded file. Got: {result:?}"
3684 );
3685 assert!(result[0].message.contains("missing%20file.md"));
3686 }
3687
3688 #[test]
3689 fn test_inline_and_reference_both_checked() {
3690 let temp_dir = tempdir().unwrap();
3692 let base_path = temp_dir.path();
3693
3694 let content = r#"# Test Document
3695
3696[inline link](./inline-missing.md)
3697[ref]: ./ref-missing.md
3698
3699Use [ref] here.
3700"#;
3701
3702 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3703 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3704 let result = rule.check(&ctx).unwrap();
3705
3706 assert_eq!(
3708 result.len(),
3709 2,
3710 "Should warn about both inline and reference links. Got: {result:?}"
3711 );
3712 assert!(
3713 result.iter().any(|w| w.message.contains("inline-missing.md")),
3714 "Should warn about inline-missing.md"
3715 );
3716 assert!(
3717 result.iter().any(|w| w.message.contains("ref-missing.md")),
3718 "Should warn about ref-missing.md"
3719 );
3720 }
3721
3722 #[test]
3723 fn test_footnote_definitions_not_flagged() {
3724 let rule = MD057ExistingRelativeLinks::default();
3727
3728 let content = r#"# Title
3729
3730A footnote[^1].
3731
3732[^1]: [link](https://www.google.com).
3733"#;
3734
3735 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3736 let result = rule.check(&ctx).unwrap();
3737
3738 assert!(
3739 result.is_empty(),
3740 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3741 );
3742 }
3743
3744 #[test]
3745 fn test_footnote_with_relative_link_inside() {
3746 let rule = MD057ExistingRelativeLinks::default();
3749
3750 let content = r#"# Title
3751
3752See the footnote[^1].
3753
3754[^1]: Check out [this file](./existing.md) for more info.
3755[^2]: Also see [missing](./does-not-exist.md).
3756"#;
3757
3758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3759 let result = rule.check(&ctx).unwrap();
3760
3761 for warning in &result {
3766 assert!(
3767 !warning.message.contains("[this file]"),
3768 "Footnote content should not be treated as URL: {warning:?}"
3769 );
3770 assert!(
3771 !warning.message.contains("[missing]"),
3772 "Footnote content should not be treated as URL: {warning:?}"
3773 );
3774 }
3775 }
3776
3777 #[test]
3778 fn test_mixed_footnotes_and_reference_definitions() {
3779 let temp_dir = tempdir().unwrap();
3781 let base_path = temp_dir.path();
3782
3783 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3784
3785 let content = r#"# Title
3786
3787A footnote[^1] and a [ref link][myref].
3788
3789[^1]: This is a footnote with [link](https://example.com).
3790
3791[myref]: ./missing-file.md "This should be checked"
3792"#;
3793
3794 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3795 let result = rule.check(&ctx).unwrap();
3796
3797 assert_eq!(
3799 result.len(),
3800 1,
3801 "Should only warn about the regular reference definition. Got: {result:?}"
3802 );
3803 assert!(
3804 result[0].message.contains("missing-file.md"),
3805 "Should warn about missing-file.md in reference definition"
3806 );
3807 }
3808
3809 #[test]
3810 fn test_absolute_links_ignore_by_default() {
3811 let temp_dir = tempdir().unwrap();
3813 let base_path = temp_dir.path();
3814
3815 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3816
3817 let content = r#"# Links
3818
3819[API docs](/api/v1/users)
3820[Blog post](/blog/2024/release.html)
3821
3822
3823[ref]: /docs/reference.md
3824"#;
3825
3826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3827 let result = rule.check(&ctx).unwrap();
3828
3829 assert!(
3831 result.is_empty(),
3832 "Absolute links should be ignored by default. Got: {result:?}"
3833 );
3834 }
3835
3836 #[test]
3837 fn test_absolute_links_warn_config() {
3838 let temp_dir = tempdir().unwrap();
3840 let base_path = temp_dir.path();
3841
3842 let config = MD057Config {
3843 absolute_links: AbsoluteLinksOption::Warn,
3844 ..Default::default()
3845 };
3846 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3847
3848 let content = r#"# Links
3849
3850[API docs](/api/v1/users)
3851[Blog post](/blog/2024/release.html)
3852"#;
3853
3854 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3855 let result = rule.check(&ctx).unwrap();
3856
3857 assert_eq!(
3859 result.len(),
3860 2,
3861 "Should warn about both absolute links. Got: {result:?}"
3862 );
3863 assert!(
3864 result[0].message.contains("cannot be validated locally"),
3865 "Warning should explain why: {}",
3866 result[0].message
3867 );
3868 assert!(
3869 result[0].message.contains("/api/v1/users"),
3870 "Warning should include the link path"
3871 );
3872 }
3873
3874 #[test]
3875 fn test_absolute_links_warn_images() {
3876 let temp_dir = tempdir().unwrap();
3878 let base_path = temp_dir.path();
3879
3880 let config = MD057Config {
3881 absolute_links: AbsoluteLinksOption::Warn,
3882 ..Default::default()
3883 };
3884 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3885
3886 let content = r#"# Images
3887
3888
3889"#;
3890
3891 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3892 let result = rule.check(&ctx).unwrap();
3893
3894 assert_eq!(
3895 result.len(),
3896 1,
3897 "Should warn about absolute image path. Got: {result:?}"
3898 );
3899 assert!(
3900 result[0].message.contains("/assets/logo.png"),
3901 "Warning should include the image path"
3902 );
3903 }
3904
3905 #[test]
3906 fn test_absolute_links_warn_reference_definitions() {
3907 let temp_dir = tempdir().unwrap();
3909 let base_path = temp_dir.path();
3910
3911 let config = MD057Config {
3912 absolute_links: AbsoluteLinksOption::Warn,
3913 ..Default::default()
3914 };
3915 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3916
3917 let content = r#"# Reference
3918
3919See the [docs][ref].
3920
3921[ref]: /docs/reference.md
3922"#;
3923
3924 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3925 let result = rule.check(&ctx).unwrap();
3926
3927 assert_eq!(
3928 result.len(),
3929 1,
3930 "Should warn about absolute reference definition. Got: {result:?}"
3931 );
3932 assert!(
3933 result[0].message.contains("/docs/reference.md"),
3934 "Warning should include the reference path"
3935 );
3936 }
3937
3938 #[test]
3939 fn test_search_paths_inline_link() {
3940 let temp_dir = tempdir().unwrap();
3941 let base_path = temp_dir.path();
3942
3943 let assets_dir = base_path.join("assets");
3945 std::fs::create_dir_all(&assets_dir).unwrap();
3946 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3947
3948 let config = MD057Config {
3949 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3950 ..Default::default()
3951 };
3952 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3953
3954 let content = "# Test\n\n[Photo](photo.png)\n";
3955 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3956 let result = rule.check(&ctx).unwrap();
3957
3958 assert!(
3959 result.is_empty(),
3960 "Should find photo.png via search-paths. Got: {result:?}"
3961 );
3962 }
3963
3964 #[test]
3965 fn test_search_paths_image() {
3966 let temp_dir = tempdir().unwrap();
3967 let base_path = temp_dir.path();
3968
3969 let assets_dir = base_path.join("attachments");
3970 std::fs::create_dir_all(&assets_dir).unwrap();
3971 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3972
3973 let config = MD057Config {
3974 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3975 ..Default::default()
3976 };
3977 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3978
3979 let content = "# Test\n\n\n";
3980 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3981 let result = rule.check(&ctx).unwrap();
3982
3983 assert!(
3984 result.is_empty(),
3985 "Should find diagram.svg via search-paths. Got: {result:?}"
3986 );
3987 }
3988
3989 #[test]
3990 fn test_search_paths_reference_definition() {
3991 let temp_dir = tempdir().unwrap();
3992 let base_path = temp_dir.path();
3993
3994 let assets_dir = base_path.join("images");
3995 std::fs::create_dir_all(&assets_dir).unwrap();
3996 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3997
3998 let config = MD057Config {
3999 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4000 ..Default::default()
4001 };
4002 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4003
4004 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
4005 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4006 let result = rule.check(&ctx).unwrap();
4007
4008 assert!(
4009 result.is_empty(),
4010 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
4011 );
4012 }
4013
4014 #[test]
4015 fn test_search_paths_still_warns_when_truly_missing() {
4016 let temp_dir = tempdir().unwrap();
4017 let base_path = temp_dir.path();
4018
4019 let assets_dir = base_path.join("assets");
4020 std::fs::create_dir_all(&assets_dir).unwrap();
4021
4022 let config = MD057Config {
4023 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4024 ..Default::default()
4025 };
4026 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4027
4028 let content = "# Test\n\n\n";
4029 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4030 let result = rule.check(&ctx).unwrap();
4031
4032 assert_eq!(
4033 result.len(),
4034 1,
4035 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
4036 );
4037 }
4038
4039 #[test]
4040 fn test_search_paths_nonexistent_directory() {
4041 let temp_dir = tempdir().unwrap();
4042 let base_path = temp_dir.path();
4043
4044 let config = MD057Config {
4045 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
4046 ..Default::default()
4047 };
4048 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4049
4050 let content = "# Test\n\n\n";
4051 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4052 let result = rule.check(&ctx).unwrap();
4053
4054 assert_eq!(
4055 result.len(),
4056 1,
4057 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
4058 );
4059 }
4060
4061 #[test]
4062 fn test_obsidian_attachment_folder_named() {
4063 let temp_dir = tempdir().unwrap();
4064 let vault = temp_dir.path().join("vault");
4065 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4066 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4067 std::fs::create_dir_all(vault.join("notes")).unwrap();
4068
4069 std::fs::write(
4070 vault.join(".obsidian/app.json"),
4071 r#"{"attachmentFolderPath": "Attachments"}"#,
4072 )
4073 .unwrap();
4074 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4075
4076 let notes_dir = vault.join("notes");
4077 let source_file = notes_dir.join("test.md");
4078 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
4079
4080 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4081
4082 let content = "# Test\n\n\n";
4083 let ctx =
4084 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4085 let result = rule.check(&ctx).unwrap();
4086
4087 assert!(
4088 result.is_empty(),
4089 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
4090 );
4091 }
4092
4093 #[test]
4094 fn test_obsidian_attachment_same_folder_as_file() {
4095 let temp_dir = tempdir().unwrap();
4096 let vault = temp_dir.path().join("vault-rf");
4097 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4098 std::fs::create_dir_all(vault.join("notes")).unwrap();
4099
4100 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
4101
4102 let notes_dir = vault.join("notes");
4104 let source_file = notes_dir.join("test.md");
4105 std::fs::write(&source_file, "placeholder").unwrap();
4106 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
4107
4108 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4109
4110 let content = "# Test\n\n\n";
4111 let ctx =
4112 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4113 let result = rule.check(&ctx).unwrap();
4114
4115 assert!(
4116 result.is_empty(),
4117 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
4118 );
4119 }
4120
4121 #[test]
4122 fn test_obsidian_not_triggered_without_obsidian_flavor() {
4123 let temp_dir = tempdir().unwrap();
4124 let vault = temp_dir.path().join("vault-nf");
4125 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4126 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4127 std::fs::create_dir_all(vault.join("notes")).unwrap();
4128
4129 std::fs::write(
4130 vault.join(".obsidian/app.json"),
4131 r#"{"attachmentFolderPath": "Attachments"}"#,
4132 )
4133 .unwrap();
4134 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4135
4136 let notes_dir = vault.join("notes");
4137 let source_file = notes_dir.join("test.md");
4138 std::fs::write(&source_file, "placeholder").unwrap();
4139
4140 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4141
4142 let content = "# Test\n\n\n";
4143 let ctx =
4145 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4146 let result = rule.check(&ctx).unwrap();
4147
4148 assert_eq!(
4149 result.len(),
4150 1,
4151 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4152 );
4153 }
4154
4155 #[test]
4156 fn test_search_paths_combined_with_obsidian() {
4157 let temp_dir = tempdir().unwrap();
4158 let vault = temp_dir.path().join("vault-combo");
4159 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4160 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4161 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4162 std::fs::create_dir_all(vault.join("notes")).unwrap();
4163
4164 std::fs::write(
4165 vault.join(".obsidian/app.json"),
4166 r#"{"attachmentFolderPath": "Attachments"}"#,
4167 )
4168 .unwrap();
4169 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4170 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4171
4172 let notes_dir = vault.join("notes");
4173 let source_file = notes_dir.join("test.md");
4174 std::fs::write(&source_file, "placeholder").unwrap();
4175
4176 let extra_assets_dir = vault.join("extra-assets");
4177 let config = MD057Config {
4178 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4179 ..Default::default()
4180 };
4181 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
4182
4183 let content = "# Test\n\n\n\n\n";
4185 let ctx =
4186 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4187 let result = rule.check(&ctx).unwrap();
4188
4189 assert!(
4190 result.is_empty(),
4191 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4192 );
4193 }
4194
4195 #[test]
4196 fn test_obsidian_attachment_subfolder_under_file() {
4197 let temp_dir = tempdir().unwrap();
4198 let vault = temp_dir.path().join("vault-sub");
4199 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4200 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4201
4202 std::fs::write(
4203 vault.join(".obsidian/app.json"),
4204 r#"{"attachmentFolderPath": "./assets"}"#,
4205 )
4206 .unwrap();
4207 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4208
4209 let notes_dir = vault.join("notes");
4210 let source_file = notes_dir.join("test.md");
4211 std::fs::write(&source_file, "placeholder").unwrap();
4212
4213 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4214
4215 let content = "# Test\n\n\n";
4216 let ctx =
4217 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4218 let result = rule.check(&ctx).unwrap();
4219
4220 assert!(
4221 result.is_empty(),
4222 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4223 );
4224 }
4225
4226 #[test]
4227 fn test_obsidian_attachment_vault_root() {
4228 let temp_dir = tempdir().unwrap();
4229 let vault = temp_dir.path().join("vault-root");
4230 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4231 std::fs::create_dir_all(vault.join("notes")).unwrap();
4232
4233 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4235 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4236
4237 let notes_dir = vault.join("notes");
4238 let source_file = notes_dir.join("test.md");
4239 std::fs::write(&source_file, "placeholder").unwrap();
4240
4241 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4242
4243 let content = "# Test\n\n\n";
4244 let ctx =
4245 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4246 let result = rule.check(&ctx).unwrap();
4247
4248 assert!(
4249 result.is_empty(),
4250 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4251 );
4252 }
4253
4254 #[test]
4255 fn test_search_paths_multiple_directories() {
4256 let temp_dir = tempdir().unwrap();
4257 let base_path = temp_dir.path();
4258
4259 let dir_a = base_path.join("dir-a");
4260 let dir_b = base_path.join("dir-b");
4261 std::fs::create_dir_all(&dir_a).unwrap();
4262 std::fs::create_dir_all(&dir_b).unwrap();
4263 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4264 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4265
4266 let config = MD057Config {
4267 search_paths: vec![
4268 dir_a.to_string_lossy().into_owned(),
4269 dir_b.to_string_lossy().into_owned(),
4270 ],
4271 ..Default::default()
4272 };
4273 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4274
4275 let content = "# Test\n\n\n\n\n";
4276 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4277 let result = rule.check(&ctx).unwrap();
4278
4279 assert!(
4280 result.is_empty(),
4281 "Should find files across multiple search paths. Got: {result:?}"
4282 );
4283 }
4284
4285 #[test]
4294 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4295 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4296
4297 let temp_dir = tempdir().unwrap();
4298 let base_path = temp_dir.path();
4299
4300 let file_path = base_path.join("README.md");
4301 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4302 std::fs::write(&file_path, content).unwrap();
4303
4304 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4305
4306 let ctx = crate::lint_context::LintContext::new(
4307 content,
4308 crate::config::MarkdownFlavor::Standard,
4309 Some(file_path.clone()),
4310 );
4311 let per_file = rule.check(&ctx).unwrap();
4312 assert_eq!(
4313 per_file.len(),
4314 1,
4315 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4316 );
4317
4318 let mut file_index = FileIndex::default();
4319 file_index.cross_file_links.push(CrossFileLinkIndex {
4320 target_path: "missing-guide.md".to_string(),
4321 fragment: String::new(),
4322 line: 3,
4323 column: 1,
4324 origin: LinkOrigin::Body,
4325 });
4326
4327 let result = rule
4328 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4329 .unwrap();
4330
4331 assert!(
4332 result.is_empty(),
4333 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4334 );
4335 }
4336
4337 #[test]
4338 fn test_check_clears_stale_cache() {
4339 let temp_dir = tempdir().unwrap();
4342 let base_path = temp_dir.path();
4343
4344 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4345
4346 let phantom_path = base_path.join("phantom.md");
4348 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(phantom_path.clone(), true));
4349
4350 let content = "[phantom](phantom.md)\n";
4351 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4352 let warnings = rule.check(&ctx).unwrap();
4353
4354 assert_eq!(
4356 warnings.len(),
4357 1,
4358 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4359 );
4360 assert!(warnings[0].message.contains("phantom.md"));
4361 }
4362
4363 #[test]
4364 fn test_check_does_not_carry_over_cache_between_runs() {
4365 let temp_dir = tempdir().unwrap();
4367 let base_path = temp_dir.path();
4368
4369 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4370
4371 let content = "[missing](nonexistent.md)\n";
4372 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4373
4374 let warnings_1 = rule.check(&ctx).unwrap();
4376 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4377
4378 let nonexistent_path = base_path.join("nonexistent.md");
4380 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(nonexistent_path.clone(), true));
4381
4382 let warnings_2 = rule.check(&ctx).unwrap();
4384 assert_eq!(
4385 warnings_2.len(),
4386 1,
4387 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4388 );
4389 }
4390
4391 fn anchor_resolutions_for(dir: &Path, name: &str, content: &str, config: MD057Config) -> (Vec<LintWarning>, usize) {
4394 let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4395 let warnings = self_referential_links_tests::check_as_file(dir, name, content, config);
4396 (warnings, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4397 }
4398
4399 #[test]
4400 fn a_document_without_relative_destinations_never_resolves_its_anchor() {
4401 let temp_dir = tempdir().unwrap();
4402 let content = "[web](https://example.com/a.md) [top](#top) [abs](/docs/a.md)\n\
4403  \n\n\
4404 [ref]: https://example.com/r.md\n[aref]: /r.md\n";
4405 let (warnings, resolutions) =
4406 anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4407 assert!(warnings.is_empty(), "{warnings:?}");
4408 assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4409 }
4410
4411 #[test]
4412 fn a_document_resolves_its_anchor_once_for_every_relative_destination() {
4413 let temp_dir = tempdir().unwrap();
4414 std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4415 let content = "[a](there.md) [b](missing.md) \n\n[d]: there.md\n";
4416 let (warnings, resolutions) =
4417 anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4418 assert_eq!(warnings.len(), 2, "{warnings:?}");
4419 assert_eq!(resolutions, 1);
4420 }
4421
4422 #[test]
4423 fn an_absolute_link_resolved_from_the_docs_dir_resolves_the_anchor() {
4424 let temp_dir = tempdir().unwrap();
4425 std::fs::write(temp_dir.path().join("mkdocs.yml"), "site_name: x\n").unwrap();
4426 std::fs::create_dir(temp_dir.path().join("docs")).unwrap();
4427 let config = MD057Config {
4428 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4429 ..Default::default()
4430 };
4431 let (warnings, resolutions) =
4432 anchor_resolutions_for(&temp_dir.path().join("docs"), "doc.md", "[abs](/missing.md)\n", config);
4433 assert_eq!(warnings.len(), 1, "{warnings:?}");
4434 assert!(warnings[0].message.contains("does not exist"), "{warnings:?}");
4435 assert_eq!(resolutions, 1);
4436 }
4437
4438 #[cfg(feature = "blake3")]
4441 fn fingerprint_anchor_resolutions(dir: &Path, content: &str) -> (String, usize) {
4442 let source_file = dir.join("doc.md");
4443 std::fs::write(&source_file, content).unwrap();
4444 let rule = MD057ExistingRelativeLinks::default();
4445 let ctx = crate::lint_context::LintContext::new(
4446 content,
4447 crate::config::MarkdownFlavor::Standard,
4448 Some(source_file.clone()),
4449 );
4450 let mut file_index = FileIndex::new();
4451 rule.contribute_to_index(&ctx, &mut file_index);
4452 reset_file_existence_cache();
4454 let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4455 let fingerprint =
4456 rule.cache_dependency_fingerprint(&source_file, crate::config::MarkdownFlavor::Standard, &file_index);
4457 (fingerprint, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4458 }
4459
4460 #[cfg(feature = "blake3")]
4461 #[test]
4462 fn a_fingerprint_over_absolute_destinations_never_resolves_the_anchor() {
4463 let temp_dir = tempdir().unwrap();
4464 let (_, resolutions) =
4465 fingerprint_anchor_resolutions(temp_dir.path(), "[a](/docs/a.md) \n\n[c]: /r.md\n");
4466 assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4467 }
4468
4469 #[cfg(feature = "blake3")]
4470 #[test]
4471 fn a_fingerprint_over_relative_destinations_resolves_the_anchor_once_and_tracks_them() {
4472 let temp_dir = tempdir().unwrap();
4473 let content = "[a](there.md) [b](missing.md)\n\n[c]: there.md\n";
4474 let (missing, resolutions) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4475 assert_eq!(resolutions, 1);
4476 std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4477 let (present, _) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4478 assert_ne!(missing, present, "creating a link target must change the fingerprint");
4479 }
4480
4481 #[test]
4487 fn test_no_duplicate_warnings_for_broken_relative_link() {
4488 use crate::workspace_index::WorkspaceIndex;
4489
4490 let temp_dir = tempdir().unwrap();
4491 let base_path = temp_dir.path();
4492
4493 let source_file = base_path.join("index.md");
4495 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4496
4497 let content = "[broken](does/not/exist.md)\n";
4498
4499 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4500
4501 let ctx = crate::lint_context::LintContext::new(
4503 content,
4504 crate::config::MarkdownFlavor::Standard,
4505 Some(source_file.clone()),
4506 );
4507 let check_warnings = rule.check(&ctx).unwrap();
4508
4509 let mut file_index = FileIndex::new();
4511 rule.contribute_to_index(&ctx, &mut file_index);
4512 let workspace_index = WorkspaceIndex::new();
4513 let cross_warnings = rule
4514 .cross_file_check(&source_file, &file_index, &workspace_index)
4515 .unwrap();
4516
4517 let total = check_warnings.len() + cross_warnings.len();
4518 assert_eq!(
4519 total, 1,
4520 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4521 check={check_warnings:?}, cross={cross_warnings:?}"
4522 );
4523 }
4524
4525 #[test]
4530 fn test_absolute_dir_link_accepted_relative_to_roots() {
4531 let temp_dir = tempdir().unwrap();
4532 let root = temp_dir.path();
4533
4534 let dir_d = root.join("d");
4536 std::fs::create_dir_all(&dir_d).unwrap();
4537 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4538
4539 let content = "\
4542[absolute dir](/d)\n\
4543[relative dir](d)\n\
4544[absolute file](/d/foo.md)\n\
4545[relative file](d/foo.md)\n";
4546
4547 let config = MD057Config {
4548 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4549 roots: vec![],
4550 ..Default::default()
4551 };
4552 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4553
4554 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4555 let result = rule.check(&ctx).unwrap();
4556
4557 assert!(
4558 result.is_empty(),
4559 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4560 );
4561 }
4562
4563 #[test]
4569 fn test_absolute_directory_link_is_accepted_however_it_is_spelled() {
4570 let temp_dir = tempdir().unwrap();
4571 let root = temp_dir.path();
4572
4573 let dir_d = root.join("d");
4575 std::fs::create_dir_all(&dir_d).unwrap();
4576 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4577
4578 let content = "\
4579[no slash](/d)\n\
4580[trailing slash](/d/)\n\
4581[trailing slash and fragment](/d/#intro)\n\
4582[relative](d/)\n";
4583
4584 let config = MD057Config {
4585 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4586 roots: vec![],
4587 ..Default::default()
4588 };
4589 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4590
4591 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4592 let result = rule.check(&ctx).unwrap();
4593
4594 assert!(
4595 result.is_empty(),
4596 "Every spelling of a link to an existing directory must agree. Got: {result:?}"
4597 );
4598 }
4599
4600 #[test]
4603 fn test_absolute_directory_link_to_a_missing_directory_is_still_reported() {
4604 let temp_dir = tempdir().unwrap();
4605 let root = temp_dir.path();
4606
4607 let content = "[gone](/nodir/)\n";
4608
4609 let config = MD057Config {
4610 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4611 roots: vec![],
4612 ..Default::default()
4613 };
4614 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4615
4616 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4617 let result = rule.check(&ctx).unwrap();
4618
4619 assert_eq!(
4620 result.len(),
4621 1,
4622 "A directory link naming nothing on disk must still be reported. Got: {result:?}"
4623 );
4624 }
4625
4626 #[test]
4630 fn test_docs_dir_variant_still_enforces_index_md() {
4631 let temp_dir = tempdir().unwrap();
4632 let root = temp_dir.path();
4633
4634 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4636
4637 let docs_dir = root.join("docs");
4639 std::fs::create_dir_all(&docs_dir).unwrap();
4640 let section_dir = docs_dir.join("section");
4641 std::fs::create_dir_all(§ion_dir).unwrap();
4642 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4643
4644 let source_file = docs_dir.join("index.md");
4646 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4647
4648 let config = MD057Config {
4649 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4650 ..Default::default()
4651 };
4652 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4653
4654 let content = "[sec](/section)\n";
4655 let ctx = crate::lint_context::LintContext::new(
4656 content,
4657 crate::config::MarkdownFlavor::Standard,
4658 Some(source_file.clone()),
4659 );
4660 let result = rule.check(&ctx).unwrap();
4661
4662 assert_eq!(
4664 result.len(),
4665 1,
4666 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4667 );
4668 assert!(
4669 result[0].message.contains("index.md") || result[0].message.contains("section"),
4670 "Message should mention the directory or missing index.md: {}",
4671 result[0].message
4672 );
4673 }
4674
4675 #[test]
4680 fn test_docs_mode_requires_index_for_every_spelling_of_a_directory_link() {
4681 let temp_dir = tempdir().unwrap();
4682 let root = temp_dir.path();
4683 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4684
4685 let docs_dir = root.join("docs");
4687 let guide_dir = docs_dir.join("guide");
4688 std::fs::create_dir_all(&guide_dir).unwrap();
4689 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4690
4691 let source_file = docs_dir.join("t.md");
4692 let content = "\
4693[no slash](/guide)\n\
4694[trailing slash](/guide/)\n\
4695[trailing slash and fragment](/guide/#intro)\n";
4696 std::fs::write(&source_file, content).unwrap();
4697
4698 let config = MD057Config {
4699 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4700 ..Default::default()
4701 };
4702 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4703 let ctx = crate::lint_context::LintContext::new(
4704 content,
4705 crate::config::MarkdownFlavor::Standard,
4706 Some(source_file.clone()),
4707 );
4708 let result = rule.check(&ctx).unwrap();
4709
4710 assert_eq!(
4711 result.len(),
4712 3,
4713 "Every directory link must be routed through the index.md check. Got: {result:?}"
4714 );
4715 for warning in &result {
4716 assert!(
4717 warning.message.contains("which has no index.md"),
4718 "The message must name the reason, not report the directory as missing: {}",
4719 warning.message
4720 );
4721 }
4722 }
4723}
4724
4725#[cfg(test)]
4726mod self_referential_links_tests {
4727 use super::*;
4728 use tempfile::tempdir;
4729
4730 pub(super) fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4732 let source_file = dir.join(name);
4733 std::fs::write(&source_file, content).unwrap();
4734 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4735 let ctx =
4736 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4737 rule.check(&ctx).unwrap()
4738 }
4739
4740 fn enabled() -> MD057Config {
4741 MD057Config {
4742 self_referential_links: true,
4743 ..Default::default()
4744 }
4745 }
4746
4747 #[test]
4748 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4749 let temp_dir = tempdir().unwrap();
4750 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4751 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4752
4753 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4754 assert_eq!(
4755 result[0].message,
4756 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4757 );
4758 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4759 assert_eq!(fix.replacement, "#level-2-heading");
4760 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4761 }
4762
4763 #[test]
4764 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4765 let temp_dir = tempdir().unwrap();
4766 let content = "# Title\n\nSee [this file](test.md).\n";
4767 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4768
4769 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4770 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4771 assert!(
4772 result[0].fix.is_none(),
4773 "Dropping the link would change the document, so there is no fix"
4774 );
4775 }
4776
4777 #[test]
4778 fn test_the_check_is_off_by_default() {
4779 let temp_dir = tempdir().unwrap();
4780 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4781 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4782
4783 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4784 }
4785
4786 #[test]
4787 fn test_a_link_to_another_file_is_left_alone() {
4788 let temp_dir = tempdir().unwrap();
4789 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4790 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4791 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4792
4793 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4794 }
4795
4796 #[test]
4797 fn test_a_self_link_written_with_traversal_reports_once() {
4798 let temp_dir = tempdir().unwrap();
4799 let sub_dir = temp_dir.path().join("sub");
4800 std::fs::create_dir_all(&sub_dir).unwrap();
4801
4802 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4803 let config = MD057Config {
4804 self_referential_links: true,
4805 compact_paths: true,
4806 ..Default::default()
4807 };
4808 let result = check_as_file(&sub_dir, "test.md", content, config);
4809
4810 assert_eq!(
4811 result.len(),
4812 1,
4813 "A compacted path would still be a link back to this file. Got: {result:?}"
4814 );
4815 assert_eq!(
4816 result[0].message,
4817 "Relative link '../sub/test.md' points to the file it is in"
4818 );
4819 }
4820
4821 #[test]
4822 fn test_compact_paths_still_reports_a_link_to_another_file() {
4823 let temp_dir = tempdir().unwrap();
4824 let sub_dir = temp_dir.path().join("sub");
4825 std::fs::create_dir_all(&sub_dir).unwrap();
4826 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4827
4828 let content = "# Title\n\nSee [the long way round](../sub/other.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!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4837 assert_eq!(
4838 result[0].message,
4839 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4840 );
4841 }
4842
4843 #[test]
4844 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4845 let temp_dir = tempdir().unwrap();
4846 let content = "# Title\n\nSee [this file](test#title).\n";
4847 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4848
4849 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4850 assert_eq!(
4851 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4852 Some("#title"),
4853 "Got: {result:?}"
4854 );
4855 }
4856
4857 #[test]
4858 fn test_a_reference_definition_pointing_at_its_own_file() {
4859 let temp_dir = tempdir().unwrap();
4860 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4861 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4862
4863 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4864 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4865 assert_eq!(fix.replacement, "#level-2-heading");
4866 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4867 }
4868
4869 #[test]
4870 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4871 let temp_dir = tempdir().unwrap();
4872 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#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 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4877 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4880 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4881 .fix(&crate::lint_context::LintContext::new(
4882 content,
4883 crate::config::MarkdownFlavor::Standard,
4884 Some(temp_dir.path().join("test.md")),
4885 ))
4886 .unwrap();
4887 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4888 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4889 }
4890
4891 #[test]
4892 fn test_a_self_link_resolved_through_a_search_path() {
4893 let temp_dir = tempdir().unwrap();
4894 let guide_dir = temp_dir.path().join("docs/guide");
4895 std::fs::create_dir_all(&guide_dir).unwrap();
4896 let config = MD057Config {
4897 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4898 ..enabled()
4899 };
4900 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4901 let result = check_as_file(&guide_dir, "test.md", content, config);
4902
4903 assert_eq!(
4904 result.len(),
4905 1,
4906 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4907 );
4908 assert_eq!(
4909 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4910 Some("#title"),
4911 "Got: {result:?}"
4912 );
4913 }
4914
4915 #[test]
4916 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4917 let temp_dir = tempdir().unwrap();
4918 let guide_dir = temp_dir.path().join("docs/guide");
4919 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4920 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4921 let config = MD057Config {
4922 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4923 ..enabled()
4924 };
4925 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4926 let result = check_as_file(&guide_dir, "test.md", content, config);
4927
4928 assert!(
4929 result.is_empty(),
4930 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4931 );
4932 }
4933
4934 #[test]
4935 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4936 let temp_dir = tempdir().unwrap();
4937 let content = "# Title\n\n\n";
4938 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4939
4940 assert!(
4941 result.is_empty(),
4942 "An image is not a link the reader follows. Got: {result:?}"
4943 );
4944 }
4945
4946 #[test]
4947 fn test_a_query_string_is_reported_without_a_suggestion() {
4948 let temp_dir = tempdir().unwrap();
4949 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4950 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4951
4952 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4953 assert!(
4954 result[0].fix.is_none(),
4955 "A query does not survive losing its path. Got: {result:?}"
4956 );
4957 }
4958
4959 #[test]
4960 fn test_fix_rewrites_the_document_and_settles() {
4961 let temp_dir = tempdir().unwrap();
4962 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4963 let source_file = temp_dir.path().join("test.md");
4964 std::fs::write(&source_file, content).unwrap();
4965
4966 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4967 let ctx = crate::lint_context::LintContext::new(
4968 content,
4969 crate::config::MarkdownFlavor::Standard,
4970 Some(source_file.clone()),
4971 );
4972 let fixed = rule.fix(&ctx).unwrap();
4973 assert_eq!(
4974 fixed,
4975 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4976 );
4977
4978 let refixed =
4979 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4980 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4981 }
4982
4983 #[test]
4984 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4985 let unfixable = MD057ExistingRelativeLinks::default();
4986 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4987
4988 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4989 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4990 }
4991
4992 #[test]
4993 fn test_the_option_is_read_from_kebab_and_snake_case() {
4994 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4995 assert!(kebab.self_referential_links);
4996
4997 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4998 assert!(snake.self_referential_links);
4999 }
5000
5001 fn front_matter_checked() -> MD057Config {
5002 MD057Config {
5003 check_frontmatter: true,
5004 ..Default::default()
5005 }
5006 }
5007
5008 #[test]
5009 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
5010 let temp_dir = tempdir().unwrap();
5011 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5012 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5013
5014 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5015 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5016 assert_eq!(result[0].line, 2);
5017 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
5018 assert_eq!(result[0].end_column, 23);
5019 }
5020
5021 #[test]
5022 fn test_frontmatter_paths_are_not_checked_by_default() {
5023 let temp_dir = tempdir().unwrap();
5024 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5025 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5026
5027 assert!(
5028 result.is_empty(),
5029 "Frontmatter is only checked on request. Got: {result:?}"
5030 );
5031 }
5032
5033 #[test]
5034 fn test_an_existing_frontmatter_path_is_not_reported() {
5035 let temp_dir = tempdir().unwrap();
5036 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5037 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.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, "Only the missing target is reported. Got: {result:?}");
5041 assert_eq!(result[0].line, 3);
5042 }
5043
5044 #[test]
5045 fn test_an_ignored_frontmatter_field_is_not_checked() {
5046 let temp_dir = tempdir().unwrap();
5047 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
5048 let config = MD057Config {
5049 check_frontmatter: true,
5050 ignore_frontmatter_fields: vec!["Image".to_string()],
5051 ..Default::default()
5052 };
5053 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5054
5055 assert_eq!(
5056 result.len(),
5057 1,
5058 "The ignored field is skipped and the other is not. Got: {result:?}"
5059 );
5060 assert_eq!(result[0].line, 3);
5061 }
5062
5063 #[test]
5064 fn test_an_external_frontmatter_url_is_not_reported() {
5065 let temp_dir = tempdir().unwrap();
5066 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
5067 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5068
5069 assert!(
5070 result.is_empty(),
5071 "An external URL has no local target. Got: {result:?}"
5072 );
5073 }
5074
5075 #[test]
5076 fn test_a_frontmatter_fragment_is_left_to_md051() {
5077 let temp_dir = tempdir().unwrap();
5078 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
5079 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5080
5081 assert!(
5082 result.is_empty(),
5083 "A fragment names a heading, not a file. Got: {result:?}"
5084 );
5085 }
5086
5087 #[test]
5088 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
5089 let temp_dir = tempdir().unwrap();
5090 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
5091
5092 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
5093 assert!(
5094 ignored.is_empty(),
5095 "Absolute paths are ignored by default. Got: {ignored:?}"
5096 );
5097
5098 let warning_config = MD057Config {
5099 check_frontmatter: true,
5100 absolute_links: AbsoluteLinksOption::Warn,
5101 ..Default::default()
5102 };
5103 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
5104 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
5105 assert_eq!(
5106 warned[0].message,
5107 "Absolute link '/docs/guide.md' cannot be validated locally"
5108 );
5109 }
5110
5111 #[test]
5112 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
5113 let temp_dir = tempdir().unwrap();
5114 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5115 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
5116 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5117
5118 assert_eq!(
5119 result.len(),
5120 1,
5121 "A query names no file, so only the missing target is reported. Got: {result:?}"
5122 );
5123 assert_eq!(result[0].line, 2);
5124 assert_eq!(
5125 result[0].message,
5126 "Relative link 'docs/missing.md?raw=true' does not exist"
5127 );
5128 }
5129
5130 #[test]
5131 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
5132 let temp_dir = tempdir().unwrap();
5133 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
5134 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5135
5136 assert!(
5137 result.is_empty(),
5138 "Only path-shaped values are destinations. Got: {result:?}"
5139 );
5140 }
5141}
5142
5143#[cfg(test)]
5148mod wrapped_link_text_tests {
5149 use super::self_referential_links_tests::check_as_file;
5150 use super::*;
5151 use tempfile::tempdir;
5152
5153 #[test]
5154 fn test_a_wrapped_link_in_a_list_item_is_reported() {
5155 let temp_dir = tempdir().unwrap();
5156 let content = "- Items reimbursable by the various [one-off\n expense](does-not-exist-anywhere)\n budgets.\n";
5157 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5158
5159 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5160 assert_eq!(
5161 result[0].message,
5162 "Relative link 'does-not-exist-anywhere' does not exist"
5163 );
5164 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5165 assert_eq!(result[0].end_line, 2);
5166 assert_eq!(result[0].column, 12);
5169 assert_eq!(result[0].end_column, 35);
5170 }
5171
5172 #[test]
5173 fn test_a_wrapped_link_in_a_paragraph_is_reported() {
5174 let temp_dir = tempdir().unwrap();
5175 let content = "Paragraph with [wrapped\ntext](also-missing) here.\n";
5176 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5177
5178 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5179 assert_eq!(result[0].message, "Relative link 'also-missing' does not exist");
5180 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5181 assert_eq!(result[0].column, 7);
5184 assert_eq!(result[0].end_column, 19);
5185 }
5186
5187 #[test]
5188 fn test_a_wrapped_link_carrying_a_title_is_reported() {
5189 let temp_dir = tempdir().unwrap();
5190 let content = "[wrapped\ntext](missing-titled.md \"t\")\n";
5191 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5192
5193 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5194 assert_eq!(result[0].message, "Relative link 'missing-titled.md' does not exist");
5195 assert_eq!(result[0].line, 2);
5196 assert_eq!(result[0].column, 7);
5199 assert_eq!(result[0].end_column, 24);
5200 }
5201
5202 #[test]
5203 fn test_a_wrapped_link_to_an_existing_file_is_left_alone() {
5204 let temp_dir = tempdir().unwrap();
5205 std::fs::write(temp_dir.path().join("target.md"), "# Target\n").unwrap();
5206 let content = "Paragraph with [wrapped\ntext](target.md) here.\n";
5207 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5208
5209 assert!(result.is_empty(), "The target exists. Got: {result:?}");
5210 }
5211
5212 #[test]
5213 fn test_a_wrapped_link_target_reaches_the_dependency_index() {
5214 let rule = MD057ExistingRelativeLinks::new();
5215 let content = "Paragraph with [wrapped\ntext](./docs/guide.md) here.\n";
5216
5217 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5218 let mut index = FileIndex::new();
5219 rule.contribute_to_index(&ctx, &mut index);
5220
5221 let targets: Vec<&str> = index
5222 .md057_link_targets
5223 .iter()
5224 .map(|target| target.target.as_str())
5225 .collect();
5226 assert_eq!(
5227 targets,
5228 vec!["./docs/guide.md"],
5229 "The index must record the target so a cached verdict is invalidated when the file appears"
5230 );
5231 }
5232
5233 #[test]
5234 fn test_a_wrapped_link_is_compacted_over_the_right_bytes() {
5235 let temp_dir = tempdir().unwrap();
5236 let sub_dir = temp_dir.path().join("sub");
5237 std::fs::create_dir_all(&sub_dir).unwrap();
5238 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
5239
5240 let content = "See [the long way\nround](../sub/other.md#part) here.\n";
5241 let config = MD057Config {
5242 compact_paths: true,
5243 ..Default::default()
5244 };
5245 let result = check_as_file(&sub_dir, "test.md", content, config);
5246
5247 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
5248 assert_eq!(
5249 result[0].message,
5250 "Relative link '../sub/other.md#part' can be simplified to 'other.md#part'"
5251 );
5252 assert_eq!(result[0].line, 2);
5253 let fix = result[0].fix.as_ref().expect("a compaction is fixable");
5254 assert_eq!(&content[fix.range.clone()], "../sub/other.md#part");
5255 assert_eq!(fix.replacement, "other.md#part");
5256 }
5257
5258 #[test]
5259 fn test_a_wrapped_self_link_is_reduced_over_the_right_bytes() {
5260 let temp_dir = tempdir().unwrap();
5261 let content = "# Title\n\nSee [the section\nbelow](test.md#level-2-heading).\n\n## Level 2 heading\n";
5262 let config = MD057Config {
5263 self_referential_links: true,
5264 ..Default::default()
5265 };
5266 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5267
5268 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5269 assert_eq!(
5270 result[0].message,
5271 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
5272 );
5273 assert_eq!(result[0].line, 4);
5274 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5275 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
5276 assert_eq!(fix.replacement, "#level-2-heading");
5277 }
5278}
5279
5280#[cfg(test)]
5286mod destination_boundary_tests {
5287 use super::self_referential_links_tests::check_as_file;
5288 use super::*;
5289 use tempfile::tempdir;
5290
5291 fn compacting() -> MD057Config {
5292 MD057Config {
5293 compact_paths: true,
5294 ..Default::default()
5295 }
5296 }
5297
5298 #[test]
5302 fn test_a_title_spelling_a_bracket_paren_is_not_the_destination() {
5303 let temp_dir = tempdir().unwrap();
5304 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5305 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5306
5307 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5308
5309 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5310 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5311 assert_eq!(result[0].line, 1);
5312 assert_eq!(result[0].column, 13);
5314 assert_eq!(result[0].end_column, 23);
5315 assert!(result[0].fix.is_none(), "A missing target carries no fix");
5316 }
5317
5318 #[test]
5321 fn test_compaction_never_rewrites_a_title() {
5322 let temp_dir = tempdir().unwrap();
5323 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5324 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5325
5326 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5327
5328 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5329 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5330 assert_eq!(result[0].column, 13);
5331 assert!(
5332 result.iter().all(|warning| warning.fix.is_none()),
5333 "Nothing in this document is rewritable. Got: {result:?}"
5334 );
5335 }
5336
5337 #[test]
5340 fn test_a_nested_bracket_does_not_close_the_label() {
5341 let temp_dir = tempdir().unwrap();
5342 let content = "[see [note]](./missing.md)\n";
5343
5344 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5345
5346 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5347 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5348 assert_eq!(result[0].column, 14);
5350 assert_eq!(result[0].end_column, 26);
5351 }
5352
5353 #[test]
5357 fn test_an_autolink_carries_no_destination() {
5358 let temp_dir = tempdir().unwrap();
5359 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5360 let content = "<https://example.com/](./existing.md)> [ok](existing.md)\n";
5361
5362 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5363
5364 assert!(result.is_empty(), "An autolink is not a relative link. Got: {result:?}");
5365 }
5366
5367 #[test]
5369 fn test_an_email_autolink_carries_no_destination() {
5370 let temp_dir = tempdir().unwrap();
5371 let content = "<user@example.com>\n";
5372
5373 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5374
5375 assert!(
5376 result.is_empty(),
5377 "An email autolink is not a relative link. Got: {result:?}"
5378 );
5379 }
5380
5381 #[test]
5386 fn test_a_code_span_spelling_a_bracket_paren_is_not_the_destination() {
5387 let temp_dir = tempdir().unwrap();
5388 std::fs::write(temp_dir.path().join("exists.md"), "# Exists\n").unwrap();
5389 let content = "[``a\n](./exists.md)``](exists.md)\n";
5390
5391 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5392
5393 assert!(
5394 result.is_empty(),
5395 "Both destinations exist, so nothing is reported. Got: {result:?}"
5396 );
5397 assert!(
5398 result.iter().all(|warning| warning.fix.is_none()),
5399 "Nothing here is rewritable, the code span least of all. Got: {result:?}"
5400 );
5401 }
5402
5403 #[test]
5407 fn test_a_bracket_in_an_html_attribute_does_not_open_a_label() {
5408 let temp_dir = tempdir().unwrap();
5409 let content = "[<i title=\"[\">text</i>](missing.md)\n";
5410
5411 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5412
5413 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5414 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5415 assert_eq!(result[0].line, 1);
5416 assert_eq!(result[0].column, 25);
5418 assert_eq!(result[0].end_column, 35);
5419 }
5420
5421 #[test]
5425 fn test_a_link_inside_an_image_description_is_not_a_link() {
5426 let temp_dir = tempdir().unwrap();
5427 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5428 let content = "](exists.png)\n";
5429
5430 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5431
5432 assert!(
5433 result.is_empty(),
5434 "The description of an image carries no link. Got: {result:?}"
5435 );
5436 }
5437
5438 #[test]
5442 fn test_a_link_inside_literal_brackets_is_still_a_link() {
5443 let temp_dir = tempdir().unwrap();
5444 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5445 let content = "[outer [inner](missing2.md)](exists.png)\n";
5446
5447 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5448
5449 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5450 assert_eq!(result[0].message, "Relative link 'missing2.md' does not exist");
5451 assert_eq!(result[0].line, 1);
5452 assert_eq!(result[0].column, 16);
5454 }
5455
5456 #[test]
5459 fn test_an_image_of_its_own_is_still_reported() {
5460 let temp_dir = tempdir().unwrap();
5461 let content = "\n";
5462
5463 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5464
5465 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5466 assert_eq!(result[0].message, "Relative link 'missing.png' does not exist");
5467 assert_eq!(result[0].column, 1);
5468 }
5469
5470 #[test]
5473 fn test_an_image_used_as_link_text_leaves_the_link_readable() {
5474 let temp_dir = tempdir().unwrap();
5475 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5476 let content = "[](missing.md)\n";
5477
5478 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5479
5480 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5481 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5482 assert_eq!(result[0].column, 22);
5484 }
5485
5486 #[test]
5490 fn test_a_link_wrapping_a_collapsed_reference_image_is_still_read() {
5491 let temp_dir = tempdir().unwrap();
5492 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5493 let content = "[![alt][]](missing.md)\n\n[alt]: exists.png\n";
5494
5495 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5496
5497 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5498 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5499 assert_eq!(result[0].line, 1);
5500 assert_eq!(result[0].column, 12);
5502 assert_eq!(result[0].end_column, 22);
5503 }
5504
5505 #[test]
5510 fn test_a_link_inside_an_undefined_reference_image_is_a_link() {
5511 for content in [
5512 "][nodef]\n",
5513 "][]\n",
5514 "]\n",
5515 ] {
5516 let temp_dir = tempdir().unwrap();
5517
5518 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5519
5520 assert_eq!(result.len(), 1, "Expected one warning for {content:?}. Got: {result:?}");
5521 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5522 assert_eq!(result[0].column, 11, "Wrong column for {content:?}");
5524 }
5525 }
5526
5527 #[test]
5530 fn test_an_image_with_an_empty_destination_is_still_an_image() {
5531 let temp_dir = tempdir().unwrap();
5532 let content = "]()\n";
5533
5534 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5535
5536 assert!(
5537 result.is_empty(),
5538 "An image with no destination is still an image. Got: {result:?}"
5539 );
5540 }
5541
5542 #[test]
5546 fn test_a_link_after_an_inner_image_is_still_inside_the_outer_image() {
5547 let temp_dir = tempdir().unwrap();
5548 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5549 let content = " [link](missing.md)](exists.png)\n";
5550
5551 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5552
5553 assert!(
5554 result.is_empty(),
5555 "The link sits inside the outer image's description. Got: {result:?}"
5556 );
5557 }
5558
5559 #[test]
5562 fn test_a_link_after_an_image_is_a_link() {
5563 let temp_dir = tempdir().unwrap();
5564 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5565 let content = " [link](missing.md)\n";
5566
5567 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5568
5569 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5570 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5571 assert_eq!(result[0].column, 29);
5573 }
5574
5575 #[test]
5577 fn test_a_link_inside_a_defined_reference_image_is_not_a_link() {
5578 let temp_dir = tempdir().unwrap();
5579 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5580 let content = "][def]\n\n[def]: exists.png\n";
5581
5582 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5583
5584 assert!(
5585 result.is_empty(),
5586 "The description of a resolved reference image carries no link. Got: {result:?}"
5587 );
5588 }
5589
5590 #[test]
5593 fn test_a_destination_on_the_next_line_is_reported_there() {
5594 let temp_dir = tempdir().unwrap();
5595 let content = "[a](\n ./gone.md)\n";
5596
5597 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5598
5599 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5600 assert_eq!(result[0].message, "Relative link './gone.md' does not exist");
5601 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5602 assert_eq!(result[0].end_line, 2);
5603 assert_eq!(result[0].column, 3);
5605 assert_eq!(result[0].end_column, 12);
5606 }
5607}
5608
5609#[cfg(test)]
5617mod exact_case_tests {
5618 use super::self_referential_links_tests::check_as_file;
5619 use super::*;
5620 use tempfile::tempdir;
5621
5622 fn volume_folds_case(dir: &Path) -> bool {
5627 let probe = dir.join("case-fold-probe.tmp");
5628 std::fs::write(&probe, "").unwrap();
5629 let folded = dir.join("CASE-FOLD-PROBE.TMP").exists();
5630 std::fs::remove_file(&probe).unwrap();
5631 folded
5632 }
5633
5634 fn write_case_fixture(dir: &Path) {
5638 std::fs::write(dir.join("Foo.md"), "# Foo\n").unwrap();
5639 std::fs::create_dir_all(dir.join("Docs")).unwrap();
5640 std::fs::write(dir.join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5641 }
5642
5643 #[test]
5649 fn test_the_filesystem_answers_for_a_spelling_it_does_not_store() {
5650 let temp_dir = tempdir().unwrap();
5651 let anchor = temp_dir.path();
5652 write_case_fixture(anchor);
5653 reset_file_existence_cache();
5654
5655 assert_eq!(anchor.join("foo.md").exists(), volume_folds_case(anchor));
5656 assert!(!exists_exact_case(anchor, &anchor.join("foo.md")));
5657 }
5658
5659 #[test]
5660 fn test_only_the_stored_spelling_of_a_target_exists() {
5661 let temp_dir = tempdir().unwrap();
5662 let anchor = temp_dir.path();
5663 write_case_fixture(anchor);
5664 reset_file_existence_cache();
5665
5666 for target in ["Foo.md", "Docs/Guide.md", "Docs"] {
5667 assert!(
5668 exists_exact_case(anchor, &anchor.join(target)),
5669 "{target} is on disk under this spelling"
5670 );
5671 }
5672 for target in ["foo.md", "docs/Guide.md", "Docs/guide.md", "docs", "Bar.md"] {
5673 assert!(
5674 !exists_exact_case(anchor, &anchor.join(target)),
5675 "{target} is not on disk under this spelling"
5676 );
5677 }
5678 }
5679
5680 #[test]
5683 fn test_the_extension_fallback_keeps_the_case_of_the_target() {
5684 let temp_dir = tempdir().unwrap();
5685 let anchor = temp_dir.path();
5686 write_case_fixture(anchor);
5687 reset_file_existence_cache();
5688
5689 assert!(resolve_existing_target(anchor, &anchor.join("Foo")).is_some());
5690 assert!(resolve_existing_target(anchor, &anchor.join("foo")).is_none());
5691 }
5692
5693 #[test]
5696 fn test_a_target_reached_through_a_parent_step_is_checked() {
5697 let temp_dir = tempdir().unwrap();
5698 write_case_fixture(temp_dir.path());
5699 let anchor = temp_dir.path().join("Docs");
5700 reset_file_existence_cache();
5701
5702 assert!(exists_exact_case(&anchor, &anchor.join("..").join("Foo.md")));
5703 assert!(!exists_exact_case(&anchor, &anchor.join("..").join("foo.md")));
5704 }
5705
5706 #[test]
5709 fn test_a_component_above_the_anchor_is_accepted() {
5710 let temp_dir = tempdir().unwrap();
5711 write_case_fixture(temp_dir.path());
5712 let anchor = temp_dir.path().join("docs");
5713 reset_file_existence_cache();
5714
5715 assert!(has_exact_case_components(&anchor, &anchor.join("Guide.md")));
5716 }
5717
5718 #[cfg(unix)]
5722 #[test]
5723 fn test_a_link_through_a_symlinked_directory_exists() {
5724 let temp_dir = tempdir().unwrap();
5725 let anchor = temp_dir.path();
5726 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5727 std::fs::write(anchor.join("shared").join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5728 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5729 reset_file_existence_cache();
5730
5731 assert!(exists_exact_case(anchor, &anchor.join("docs").join("Guide.md")));
5732 }
5733
5734 #[cfg(target_os = "macos")]
5737 #[test]
5738 fn test_a_decomposed_name_matches_a_composed_link() {
5739 let temp_dir = tempdir().unwrap();
5740 let anchor = temp_dir.path();
5741 std::fs::write(anchor.join("cafe\u{301}.md"), "# Cafe\n").unwrap();
5742 reset_file_existence_cache();
5743
5744 assert!(exists_exact_case(anchor, &anchor.join("caf\u{e9}.md")));
5745 assert!(!exists_exact_case(anchor, &anchor.join("CAF\u{c9}.md")));
5746 }
5747
5748 #[test]
5749 fn test_a_link_reports_every_spelling_the_filesystem_does_not_store() {
5750 let temp_dir = tempdir().unwrap();
5751 write_case_fixture(temp_dir.path());
5752 let content = "\
5753[a](foo.md)\n\
5754[b](docs/Guide.md)\n\
5755[c](foo)\n\
5756[d](docs)\n\
5757[e](Foo.md)\n\
5758[f](Docs/Guide.md)\n\
5759[g](Docs)\n\
5760[h](Bar.md)\n";
5761
5762 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5763
5764 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5765 assert_eq!(
5766 messages,
5767 vec![
5768 "Relative link 'foo.md' does not exist",
5769 "Relative link 'docs/Guide.md' does not exist",
5770 "Relative link 'foo' does not exist",
5771 "Relative link 'docs' does not exist",
5772 "Relative link 'Bar.md' does not exist",
5773 ],
5774 "Got: {result:?}"
5775 );
5776 }
5777
5778 #[test]
5781 fn test_the_reporters_link_is_reported() {
5782 let temp_dir = tempdir().unwrap();
5783 std::fs::write(temp_dir.path().join("changelog.md"), "").unwrap();
5784 let content = "# Test\n\nSee the [changelog](CHANGELOG.md).\n";
5785
5786 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5787
5788 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5789 assert_eq!(result[0].message, "Relative link 'CHANGELOG.md' does not exist");
5790 assert_eq!(result[0].line, 3);
5791 assert_eq!(result[0].column, 21);
5792 }
5793
5794 #[test]
5797 fn test_the_html_fallback_keeps_the_case_of_the_stem() {
5798 let temp_dir = tempdir().unwrap();
5799 std::fs::write(temp_dir.path().join("Guide.md"), "# Guide\n").unwrap();
5800 let content = "[a](Guide.html)\n[b](guide.html)\n";
5801
5802 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5803
5804 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5805 assert_eq!(result[0].message, "Relative link 'guide.html' does not exist");
5806 assert_eq!(result[0].line, 2);
5807 }
5808
5809 #[test]
5812 fn test_a_search_path_answers_for_the_exact_spelling() {
5813 let temp_dir = tempdir().unwrap();
5814 let assets = temp_dir.path().join("assets");
5815 std::fs::create_dir_all(&assets).unwrap();
5816 std::fs::write(assets.join("Logo.png"), "").unwrap();
5817 let config = MD057Config {
5818 search_paths: vec![assets.to_string_lossy().into_owned()],
5819 ..Default::default()
5820 };
5821 let content = "[a](Logo.png)\n[b](logo.png)\n";
5822
5823 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5824
5825 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5826 assert_eq!(result[0].message, "Relative link 'logo.png' does not exist");
5827 assert_eq!(result[0].line, 2);
5828 }
5829
5830 #[test]
5833 fn test_an_absolute_link_under_a_root_keeps_its_case() {
5834 let temp_dir = tempdir().unwrap();
5835 let root = temp_dir.path();
5836 write_case_fixture(root);
5837 let content = "[a](/Docs/Guide.md)\n[b](/docs/Guide.md)\n[c](/Docs)\n[d](/docs)\n";
5838
5839 let config = MD057Config {
5840 absolute_links: AbsoluteLinksOption::RelativeToRoots,
5841 roots: vec![],
5842 ..Default::default()
5843 };
5844 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
5845 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5846 let result = rule.check(&ctx).unwrap();
5847
5848 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5849 assert_eq!(
5850 messages,
5851 vec![
5852 "Absolute link '/docs/Guide.md' was not found under the project root",
5853 "Absolute link '/docs' was not found under the project root",
5854 ],
5855 "Got: {result:?}"
5856 );
5857 }
5858
5859 #[cfg(unix)]
5864 #[test]
5865 fn test_a_parent_step_is_resolved_lexically_through_a_symlink() {
5866 let temp_dir = tempdir().unwrap();
5867 let anchor = temp_dir.path();
5868 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5869 std::fs::write(anchor.join("shared").join("Foo.md"), "# Foo\n").unwrap();
5870 std::fs::write(anchor.join("shared").join("Docs").join("Bar.md"), "# Bar\n").unwrap();
5871 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5872 let content = "[a](docs/../Foo.md)\n[b](docs/Bar.md)\n[c](docs/bar.md)\n";
5873
5874 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5875
5876 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5877 assert_eq!(
5878 messages,
5879 vec![
5880 "Relative link 'docs/../Foo.md' does not exist",
5881 "Relative link 'docs/bar.md' does not exist",
5882 ],
5883 "Got: {result:?}"
5884 );
5885 }
5886
5887 #[cfg(unix)]
5892 struct RestoreMode {
5893 directory: PathBuf,
5894 mode: u32,
5895 }
5896
5897 #[cfg(unix)]
5898 impl RestoreMode {
5899 fn take(directory: &Path) -> Self {
5900 use std::os::unix::fs::PermissionsExt;
5901 Self {
5902 directory: directory.to_path_buf(),
5903 mode: std::fs::metadata(directory).unwrap().permissions().mode(),
5904 }
5905 }
5906 }
5907
5908 #[cfg(unix)]
5909 impl Drop for RestoreMode {
5910 fn drop(&mut self) {
5911 use std::os::unix::fs::PermissionsExt;
5912 let _ = std::fs::set_permissions(&self.directory, std::fs::Permissions::from_mode(self.mode));
5913 }
5914 }
5915
5916 #[cfg(unix)]
5920 #[test]
5921 fn test_a_directory_that_cannot_be_listed_is_accepted() {
5922 use std::os::unix::fs::PermissionsExt;
5923
5924 let temp_dir = tempdir().unwrap();
5925 let anchor = temp_dir.path();
5926 let locked = anchor.join("locked");
5927 std::fs::create_dir_all(&locked).unwrap();
5928 std::fs::write(locked.join("Target.md"), "# Target\n").unwrap();
5929
5930 let _restore = RestoreMode::take(&locked);
5931 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o111)).unwrap();
5932 if std::fs::read_dir(&locked).is_ok() {
5933 return;
5936 }
5937 assert!(locked.join("Target.md").exists(), "the directory can still be entered");
5938 let content = "[a](locked/Target.md)\n";
5939
5940 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5941
5942 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
5943 }
5944
5945 #[cfg(target_os = "macos")]
5949 #[test]
5950 fn test_a_composed_name_matches_a_decomposed_link() {
5951 let temp_dir = tempdir().unwrap();
5952 let anchor = temp_dir.path();
5953 std::fs::write(anchor.join("caf\u{e9}.md"), "# Cafe\n").unwrap();
5954 reset_file_existence_cache();
5955
5956 assert!(exists_exact_case(anchor, &anchor.join("cafe\u{301}.md")));
5957 assert!(!exists_exact_case(anchor, &anchor.join("CAFE\u{301}.md")));
5958
5959 let content = "[a](cafe\u{301}.md)\n[b](CAFE\u{301}.md)\n";
5960
5961 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5962
5963 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5964 assert_eq!(
5965 messages,
5966 vec!["Relative link 'CAFE\u{301}.md' does not exist"],
5967 "Got: {result:?}"
5968 );
5969 }
5970
5971 #[cfg(target_os = "macos")]
5976 #[test]
5977 fn test_a_name_composing_to_ascii_matches_an_ascii_link() {
5978 let temp_dir = tempdir().unwrap();
5979 let anchor = temp_dir.path();
5980 std::fs::write(anchor.join("\u{212a}elvin.md"), "# Kelvin\n").unwrap();
5981 reset_file_existence_cache();
5982
5983 assert!(exists_exact_case(anchor, &anchor.join("Kelvin.md")));
5984 assert!(!exists_exact_case(anchor, &anchor.join("kelvin.md")));
5985
5986 let content = "[a](Kelvin.md)\n[b](kelvin.md)\n";
5987
5988 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5989
5990 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5991 assert_eq!(
5992 messages,
5993 vec!["Relative link 'kelvin.md' does not exist"],
5994 "Got: {result:?}"
5995 );
5996 }
5997
5998 #[cfg(target_os = "macos")]
6002 #[test]
6003 fn test_an_ascii_name_matches_a_link_composing_to_it() {
6004 let temp_dir = tempdir().unwrap();
6005 let anchor = temp_dir.path();
6006 std::fs::write(anchor.join("Kelvin.md"), "# Kelvin\n").unwrap();
6007 reset_file_existence_cache();
6008
6009 assert!(exists_exact_case(anchor, &anchor.join("\u{212a}elvin.md")));
6010 assert!(!exists_exact_case(anchor, &anchor.join("\u{212a}ELVIN.md")));
6011
6012 let content = "[a](\u{212a}elvin.md)\n[b](\u{212a}ELVIN.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 '\u{212a}ELVIN.md' does not exist"],
6020 "Got: {result:?}"
6021 );
6022 }
6023
6024 fn seed_listing(directory: &Path, modified: SystemTime) {
6032 let listing = DirectoryListing {
6033 modified: Some(modified),
6034 listed: true,
6035 names: HashSet::from([OsString::from("Other.md")]),
6036 composed: HashSet::new(),
6037 };
6038 let key = std::fs::canonicalize(directory).unwrap();
6039 DIRECTORY_LISTING_CACHE.lock().unwrap().insert(key, Arc::new(listing));
6040 }
6041
6042 fn set_directory_modified(directory: &Path, modified: SystemTime) {
6045 let mut options = std::fs::OpenOptions::new();
6046 options.read(true);
6047 #[cfg(windows)]
6048 {
6049 use std::os::windows::fs::OpenOptionsExt;
6050 options.access_mode(0x0100).custom_flags(0x02000000);
6053 }
6054 options
6055 .open(directory)
6056 .unwrap()
6057 .set_times(std::fs::FileTimes::new().set_modified(modified))
6058 .unwrap();
6059 }
6060
6061 #[test]
6066 fn test_a_listing_answers_until_the_directory_changes() {
6067 let temp_dir = tempdir().unwrap();
6068 let anchor = temp_dir.path();
6069 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6070 std::fs::write(anchor.join("test.md"), "").unwrap();
6073 let initial_time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
6074 set_directory_modified(anchor, initial_time);
6075 let read_at = std::fs::metadata(anchor).unwrap().modified().unwrap();
6076 seed_listing(anchor, read_at);
6077
6078 let reported = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6079
6080 let messages: Vec<&str> = reported.iter().map(|warning| warning.message.as_str()).collect();
6081 assert_eq!(
6082 messages,
6083 vec!["Relative link 'README.md' does not exist"],
6084 "the seeded listing answers while the directory's time stands. Got: {reported:?}"
6085 );
6086
6087 std::fs::write(anchor.join("Another.md"), "# Another\n").unwrap();
6088 set_directory_modified(anchor, initial_time + std::time::Duration::from_secs(10));
6089 assert_ne!(
6090 std::fs::metadata(anchor).unwrap().modified().unwrap(),
6091 read_at,
6092 "the fixture must expose a distinct directory modification time"
6093 );
6094
6095 let accepted = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6096
6097 assert!(
6098 accepted.is_empty(),
6099 "the listing is read again once the directory has moved on. Got: {accepted:?}"
6100 );
6101 }
6102
6103 #[test]
6107 fn test_a_listing_from_another_moment_is_discarded() {
6108 let temp_dir = tempdir().unwrap();
6109 let anchor = temp_dir.path();
6110 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6111 seed_listing(anchor, SystemTime::UNIX_EPOCH);
6112
6113 let result = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6114
6115 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6116 }
6117}
6118
6119#[cfg(test)]
6120mod cached_position_tests {
6121 use super::*;
6122 use crate::config::MarkdownFlavor;
6123
6124 #[test]
6125 fn cached_positions_preserve_scalar_line_boundary_semantics() {
6126 for content in [
6127 String::new(),
6128 "\r\n".to_string(),
6129 format!("{}\r\n{}\n", "é日本🙂 ".repeat(130), "é\t".repeat(300)),
6130 format!("{}\r", "é日本🙂 ".repeat(130)),
6131 ] {
6132 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
6133 let mut positions = SourcePositions::new(&content, &ctx.lines);
6134 for offset in (0..=content.len() + 3).rev().chain([usize::MAX, 0]) {
6135 assert_eq!(
6136 MD057ExistingRelativeLinks::diagnostic_position(&ctx, &mut positions, offset),
6137 ctx.offset_to_line_col(offset),
6138 "offset {offset}"
6139 );
6140 }
6141 }
6142 }
6143
6144 #[test]
6145 fn warning_and_fix_ranges_keep_unicode_source_coordinates() {
6146 let temp = tempfile::tempdir().unwrap();
6147 let base = temp.path().join("sub");
6148 std::fs::create_dir(&base).unwrap();
6149 std::fs::write(base.join("target.md"), "target").unwrap();
6150 let source = base.join("page.md");
6151 std::fs::write(&source, "source").unwrap();
6152 let config = MD057Config {
6153 absolute_links: AbsoluteLinksOption::Warn,
6154 compact_paths: true,
6155 self_referential_links: true,
6156 ..Default::default()
6157 };
6158 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&base);
6159 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs, MarkdownFlavor::MDX] {
6160 for newline in ["\n", "\r\n"] {
6161 let prefix = "é日本🙂 ".repeat(150);
6162 let content = format!(
6163 "[{}]: 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)",
6164 "é".repeat(300)
6165 );
6166 let ctx = LintContext::new(&content, flavor, Some(source.clone()));
6167 let warnings = rule.check(&ctx).unwrap();
6168 let destinations: Vec<_> = ctx.body_link_destinations().collect();
6169 assert_eq!(destinations.len(), 5);
6170 assert_eq!(warnings.len(), 6);
6171 for (warning, destination) in warnings.iter().zip(destinations) {
6172 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
6173 let end = if warning.fix.is_some() {
6174 destination.fix_range.end
6175 } else {
6176 destination.url_range.end
6177 };
6178 assert_eq!(
6179 (warning.line, warning.column, warning.end_line, warning.end_column),
6180 (line, column, line, ctx.offset_to_line_col(end).1)
6181 );
6182 if let Some(fix) = &warning.fix {
6183 assert_eq!(fix.range, destination.fix_range);
6184 assert_eq!(
6185 fix.replacement,
6186 if destination.url == "page.md" {
6187 "#part"
6188 } else {
6189 "target.md#part"
6190 }
6191 );
6192 }
6193 }
6194 let definition = &ctx.reference_definitions()[0];
6195 let range = MD057ExistingRelativeLinks::ref_def_url_range(&content, definition).unwrap();
6196 let (line, column) = ctx.offset_to_line_col(range.start);
6197 let warning = warnings.last().unwrap();
6198 assert_eq!(
6199 (warning.line, warning.column, warning.end_line, warning.end_column),
6200 (line, column, line, column + definition.url.chars().count())
6201 );
6202 }
6203 }
6204 }
6205}