1use crate::rule::{
7 CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12 FileIndex, LinkOrigin, Md057LinkTarget, extract_cross_file_links, normalize_relative_path,
13};
14use pulldown_cmark::LinkType;
15use regex::Regex;
16use std::collections::{HashMap, HashSet};
17use std::env;
18use std::path::{Path, PathBuf};
19use std::sync::LazyLock;
20use std::sync::{Arc, Mutex};
21
22mod md057_config;
23use crate::utils::mkdocs_config::resolve_docs_dir;
24use crate::utils::obsidian_config::resolve_attachment_folder;
25use crate::utils::project_root::discover_project_root_from;
26pub use md057_config::{AbsoluteLinksOption, MD057Config};
27
28static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
30 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
31
32fn reset_file_existence_cache() {
34 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
35 cache.clear();
36 }
37}
38
39fn file_exists_with_cache(path: &Path) -> bool {
41 match FILE_EXISTENCE_CACHE.lock() {
42 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
43 Err(_) => path.exists(), }
45}
46
47fn file_exists_or_markdown_extension(path: &Path) -> bool {
50 resolve_existing_target(path).is_some()
51}
52
53fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
60 if file_exists_with_cache(path) {
62 return Some(path.to_path_buf());
63 }
64
65 if path.extension().is_none() {
67 for ext in MARKDOWN_EXTENSIONS {
68 let path_with_ext = path.with_extension(&ext[1..]);
70 if file_exists_with_cache(&path_with_ext) {
71 return Some(path_with_ext);
72 }
73 }
74 }
75
76 None
77}
78
79static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
81
82static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
87
88static URL_EXTRACT_REGEX: LazyLock<Regex> =
91 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
92
93static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
98
99static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
101
102static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
108
109#[inline]
112fn hex_digit_to_value(byte: u8) -> Option<u8> {
113 match byte {
114 b'0'..=b'9' => Some(byte - b'0'),
115 b'a'..=b'f' => Some(byte - b'a' + 10),
116 b'A'..=b'F' => Some(byte - b'A' + 10),
117 _ => None,
118 }
119}
120
121const MARKDOWN_EXTENSIONS: &[&str] = &[
123 ".md",
124 ".markdown",
125 ".mdx",
126 ".mkd",
127 ".mkdn",
128 ".mdown",
129 ".mdwn",
130 ".qmd",
131 ".rmd",
132];
133
134#[derive(Debug, PartialEq, Eq)]
136enum SelfReferentialLink {
137 WholeFile,
140 Fragment(String),
143}
144
145#[cfg(feature = "blake3")]
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum DependencyPathState {
148 Missing,
149 File,
150 Directory,
151 Other,
152}
153
154#[derive(Debug, Clone)]
156pub struct MD057ExistingRelativeLinks {
157 base_path: Arc<Mutex<Option<PathBuf>>>,
162 config: MD057Config,
164}
165
166impl Default for MD057ExistingRelativeLinks {
167 fn default() -> Self {
168 Self {
169 base_path: Arc::new(Mutex::new(None)),
170 config: MD057Config::default(),
171 }
172 }
173}
174
175impl MD057ExistingRelativeLinks {
176 pub fn new() -> Self {
178 Self::default()
179 }
180
181 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
183 let path = path.as_ref();
184 let dir_path = if path.is_file() {
185 path.parent().map(std::path::Path::to_path_buf)
186 } else {
187 Some(path.to_path_buf())
188 };
189
190 if let Ok(mut guard) = self.base_path.lock() {
191 *guard = dir_path;
192 }
193 self
194 }
195
196 pub fn from_config_struct(config: MD057Config) -> Self {
197 Self {
198 base_path: Arc::new(Mutex::new(None)),
199 config,
200 }
201 }
202
203 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
207 if Path::new(path_str).is_absolute() {
208 PathBuf::from(path_str)
209 } else {
210 project_root.join(path_str)
211 }
212 }
213
214 #[inline]
226 fn is_external_url(&self, url: &str) -> bool {
227 if url.is_empty() {
228 return false;
229 }
230
231 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
233 return true;
234 }
235
236 if url.starts_with("{{") || url.starts_with("{%") {
239 return true;
240 }
241
242 if url.contains('@') {
245 return true; }
247
248 if !url.contains('/') && url.ends_with(".com") {
258 return true;
259 }
260
261 if url.starts_with('~') || url.starts_with('@') {
265 return true;
266 }
267
268 false
270 }
271
272 #[inline]
275 fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
276 self.is_external_url(url)
277 || (flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_output_placeholder(url))
278 }
279
280 #[inline]
282 fn is_fragment_only_link(&self, url: &str) -> bool {
283 url.starts_with('#')
284 }
285
286 #[inline]
289 fn is_absolute_path(url: &str) -> bool {
290 url.starts_with('/')
291 }
292
293 fn url_decode(path: &str) -> String {
297 if !path.contains('%') {
299 return path.to_string();
300 }
301
302 let bytes = path.as_bytes();
303 let mut result = Vec::with_capacity(bytes.len());
304 let mut i = 0;
305
306 while i < bytes.len() {
307 if bytes[i] == b'%' && i + 2 < bytes.len() {
308 let hex1 = bytes[i + 1];
310 let hex2 = bytes[i + 2];
311 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
312 result.push(d1 * 16 + d2);
313 i += 3;
314 continue;
315 }
316 }
317 result.push(bytes[i]);
318 i += 1;
319 }
320
321 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
323 }
324
325 fn strip_query_and_fragment(url: &str) -> &str {
333 let query_pos = url.find('?');
336 let fragment_pos = url.find('#');
337
338 match (query_pos, fragment_pos) {
339 (Some(q), Some(f)) => {
340 &url[..q.min(f)]
342 }
343 (Some(q), None) => &url[..q],
344 (None, Some(f)) => &url[..f],
345 (None, None) => url,
346 }
347 }
348
349 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
351 base_path.join(link)
352 }
353
354 fn compute_search_paths(
359 &self,
360 flavor: crate::config::MarkdownFlavor,
361 source_file: Option<&Path>,
362 base_path: &Path,
363 project_root: &Path,
364 ) -> Vec<PathBuf> {
365 let mut paths = Vec::new();
366
367 if flavor == crate::config::MarkdownFlavor::Obsidian
369 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
370 && attachment_dir != *base_path
371 {
372 paths.push(attachment_dir);
373 }
374
375 for search_path in &self.config.search_paths {
379 let resolved = Self::resolve_against_project_root(search_path, project_root);
380 if resolved != *base_path && !paths.contains(&resolved) {
381 paths.push(resolved);
382 }
383 }
384
385 paths
386 }
387
388 fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
393 if !ctx.links().is_empty() {
394 let lines = ctx.raw_lines();
395 let mut processed_lines = HashSet::new();
396
397 for link in ctx.links() {
398 let line_index = link.line - 1;
399 if line_index >= lines.len()
400 || ctx
401 .line_info(link.line)
402 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
403 || !processed_lines.insert(line_index)
404 {
405 continue;
406 }
407 let line = lines[line_index];
408 if !line.contains("](") {
409 continue;
410 }
411
412 let line_start_byte = ctx.line_start_byte(link.line).unwrap_or(0);
413 for link_match in LINK_START_REGEX.find_iter(line) {
414 if link_match.as_str().starts_with('!') {
415 let escapes = line[..link_match.start()]
416 .bytes()
417 .rev()
418 .take_while(|&byte| byte == b'\\')
419 .count();
420 if escapes % 2 == 0 {
421 continue;
422 }
423 }
424
425 let absolute_start = line_start_byte + link_match.start();
426 if ctx.is_in_code_span_byte(absolute_start)
427 || ctx.is_in_math_span(absolute_start)
428 || ctx.is_in_shortcode(absolute_start)
429 {
430 continue;
431 }
432 let expected_start = link_match.end() - 1;
433 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, expected_start)
434 .and_then(|caps| caps.get(1).map(|url| (caps, url)))
435 .or_else(|| {
436 extract_url_at(&URL_EXTRACT_REGEX, line, expected_start)
437 .and_then(|caps| caps.get(1).map(|url| (caps, url)))
438 });
439 let Some((_, url_match)) = caps_and_url else {
440 continue;
441 };
442 let url = url_match.as_str().trim();
443 if url.is_empty()
444 || (url.starts_with('`') && url.ends_with('`'))
445 || self.is_non_file_destination(url, ctx.flavor)
446 || self.is_fragment_only_link(url)
447 {
448 continue;
449 }
450 index.add_md057_link_target(Md057LinkTarget {
451 target: url.to_string(),
452 origin: LinkOrigin::Body,
453 });
454 }
455 }
456 }
457
458 for image in ctx.images() {
459 if ctx
460 .line_info(image.line)
461 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
462 || matches!(image.link_type, LinkType::WikiLink { .. })
463 || ctx.is_in_shortcode(image.byte_offset)
464 {
465 continue;
466 }
467 let url = image.url.as_ref();
468 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
469 continue;
470 }
471 index.add_md057_link_target(Md057LinkTarget {
472 target: url.to_string(),
473 origin: LinkOrigin::Body,
474 });
475 }
476
477 for reference in ctx.reference_definitions() {
478 if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
479 continue;
480 }
481 let url = reference.url.as_str();
482 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
483 continue;
484 }
485 index.add_md057_link_target(Md057LinkTarget {
486 target: url.to_string(),
487 origin: LinkOrigin::Body,
488 });
489 }
490
491 for link in frontmatter_values::link_destinations(ctx) {
492 let line = ctx.lines[link.line - 1].content(ctx.content);
493 let url = &line[link.range];
494 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
495 continue;
496 }
497 index.add_md057_link_target(Md057LinkTarget {
498 target: url.to_string(),
499 origin: LinkOrigin::FrontMatter { field: link.field },
500 });
501 }
502 }
503
504 fn exists_in_search_paths(
506 decoded_path: &str,
507 search_paths: &[PathBuf],
508 policy: Option<&crate::lint_context::LinkTargetPolicy>,
509 ) -> bool {
510 search_paths.iter().any(|dir| {
511 let candidate = dir.join(decoded_path);
512 Self::target_exists(&candidate, policy)
513 })
514 }
515
516 fn target_exists(path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> bool {
517 Self::resolve_target(path, policy).is_some()
518 }
519
520 fn resolve_target(path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> Option<PathBuf> {
521 if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
522 return Some(supplied);
523 }
524 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
525 return None;
526 }
527 resolve_existing_target(path)
528 }
529
530 fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
531 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
532 format!("Relative link '{url}' target not in the supplied document set")
533 } else {
534 format!("Relative link '{url}' does not exist")
535 }
536 }
537
538 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
544 if !self.config.compact_paths {
545 return None;
546 }
547
548 let path_end = url
550 .find('?')
551 .unwrap_or(url.len())
552 .min(url.find('#').unwrap_or(url.len()));
553 let path_part = &url[..path_end];
554 let suffix = &url[path_end..];
555
556 let decoded_path = Self::url_decode(path_part);
558
559 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
560 }
561
562 fn self_referential_link(
574 &self,
575 url: &str,
576 base_path: &Path,
577 search_paths: &[PathBuf],
578 source_file: Option<&Path>,
579 policy: Option<&crate::lint_context::LinkTargetPolicy>,
580 ) -> Option<SelfReferentialLink> {
581 if !self.config.self_referential_links {
582 return None;
583 }
584 let source_file = source_file?;
585
586 let path_part = Self::strip_query_and_fragment(url);
587 if path_part.is_empty() {
588 return None;
589 }
590 let suffix = &url[path_part.len()..];
591
592 let decoded_path = Self::url_decode(path_part);
593 let resolved = std::iter::once(base_path)
597 .chain(search_paths.iter().map(PathBuf::as_path))
598 .find_map(|dir| Self::resolve_target(&Self::resolve_link_path_with_base(&decoded_path, dir), policy))?;
599 if !Self::is_same_file(&resolved, source_file) {
600 return None;
601 }
602
603 match suffix.strip_prefix('#') {
607 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
608 _ => Some(SelfReferentialLink::WholeFile),
609 }
610 }
611
612 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
619 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
620 let label_end = Self::label_end(def)?;
621 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
622 title.saturating_sub(ref_def.byte_offset).min(def.len())
623 });
624 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
625 let start = ref_def.byte_offset + offset;
626 Some(start..start + ref_def.url.len())
627 }
628
629 fn label_end(def: &str) -> Option<usize> {
634 let bytes = def.as_bytes();
635 let mut i = 0;
636 while i < bytes.len() {
637 match bytes[i] {
638 b'\\' => i += 2,
639 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
640 _ => i += 1,
641 }
642 }
643 None
644 }
645
646 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
650 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
651 }
652
653 fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
656 match self.config.absolute_links {
657 AbsoluteLinksOption::Ignore => None,
658 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
659 AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
660 AbsoluteLinksOption::RelativeToRoots => {
661 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
662 }
663 }
664 }
665
666 fn check_front_matter(
673 &self,
674 ctx: &crate::lint_context::LintContext,
675 base_path: &Path,
676 search_paths: &[PathBuf],
677 project_root: &Path,
678 warnings: &mut Vec<LintWarning>,
679 ) {
680 if !self.config.check_frontmatter {
681 return;
682 }
683
684 let ignored: HashSet<String> = self
685 .config
686 .ignore_frontmatter_fields
687 .iter()
688 .map(|field| field.to_lowercase())
689 .collect();
690
691 for link in frontmatter_values::link_destinations(ctx) {
692 if link.field_is_in(&ignored) {
693 continue;
694 }
695
696 let line = ctx.lines[link.line - 1].content(ctx.content);
697 let url = &line[link.range.clone()];
698
699 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
702 continue;
703 }
704
705 let column = byte_to_char_count(line, link.range.start);
706 let end_column = column + url.chars().count();
707
708 if Self::is_absolute_path(url) {
709 if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
710 warnings.push(LintWarning {
711 rule_name: Some(self.name().to_string()),
712 line: link.line,
713 column,
714 end_line: link.line,
715 end_column,
716 message,
717 severity: Severity::Warning,
718 fix: None,
719 });
720 }
721 continue;
722 }
723
724 if Self::relative_target_exists(url, base_path, search_paths, ctx.link_target_policy()) {
725 continue;
726 }
727
728 warnings.push(LintWarning {
729 rule_name: Some(self.name().to_string()),
730 line: link.line,
731 column,
732 end_line: link.line,
733 end_column,
734 message: Self::missing_relative_message(url, ctx.link_target_policy()),
735 severity: Severity::Error,
736 fix: None,
737 });
738 }
739 }
740
741 fn relative_target_exists(
748 url: &str,
749 base_path: &Path,
750 search_paths: &[PathBuf],
751 policy: Option<&crate::lint_context::LinkTargetPolicy>,
752 ) -> bool {
753 let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
754 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
755
756 if Self::target_exists(&resolved_path, policy) {
758 return true;
759 }
760
761 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
762 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
763 && let (Some(stem), Some(parent)) = (
764 resolved_path.file_stem().and_then(|s| s.to_str()),
765 resolved_path.parent(),
766 )
767 && MARKDOWN_EXTENSIONS
768 .iter()
769 .any(|md_ext| Self::target_exists(&parent.join(format!("{stem}{md_ext}")), policy))
770 {
771 return true;
772 }
773
774 Self::exists_in_search_paths(&decoded_path, search_paths, policy)
775 }
776
777 fn produces_fixes(&self) -> bool {
781 self.config.compact_paths || self.config.self_referential_links
782 }
783
784 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
786 match self_link {
787 SelfReferentialLink::Fragment(fragment) => {
788 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
789 }
790 SelfReferentialLink::WholeFile => {
791 format!("Relative link '{url}' points to the file it is in")
792 }
793 }
794 }
795
796 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
802 if resolved.file_name() != source_file.file_name() {
804 return false;
805 }
806 match (resolved.canonicalize(), source_file.canonicalize()) {
807 (Ok(link), Ok(source)) => link == source,
808 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
809 }
810 }
811
812 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
818 let Some(docs_dir) = resolve_docs_dir(source_path) else {
819 return Some(format!(
820 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
821 ));
822 };
823
824 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
825
826 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
829 Resolution::Found => None,
830 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
831 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
832 resolved.display()
833 )),
834 Resolution::NotFound { resolved } => Some(format!(
835 "Absolute link '{url}' resolves to '{}' which does not exist",
836 resolved.display()
837 )),
838 }
839 }
840
841 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
850 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
851
852 for root in roots {
853 let root_path = Self::resolve_against_project_root(root, project_root);
854 if matches!(
857 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
858 Resolution::Found
859 ) {
860 return None;
861 }
862 }
863
864 if matches!(
865 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
867 Resolution::Found
868 ) {
869 return None;
870 }
871
872 let msg = if roots.is_empty() {
873 format!("Absolute link '{url}' was not found under the project root")
874 } else {
875 format!("Absolute link '{url}' was not found under any configured root or the project root")
876 };
877 Some(msg)
878 }
879
880 fn prepare_absolute_url(url: &str) -> (String, bool) {
884 let relative_url = url.trim_start_matches('/');
885 let file_path = Self::strip_query_and_fragment(relative_url);
886 let decoded = Self::url_decode(file_path);
887 let is_directory_link = url.ends_with('/') || decoded.is_empty();
888 (decoded, is_directory_link)
889 }
890
891 fn resolve_under_root_with_opts(
913 root_path: &Path,
914 decoded: &str,
915 is_directory_link: bool,
916 require_index_for_dirs: bool,
917 ) -> Resolution {
918 let resolved = root_path.join(decoded);
919
920 let is_dir = resolved.is_dir();
921
922 if is_directory_link || (require_index_for_dirs && is_dir) {
927 let index_path = resolved.join("index.md");
928 if file_exists_with_cache(&index_path) {
929 return Resolution::Found;
930 }
931 if is_dir {
932 return Resolution::DirectoryWithoutIndex { resolved };
933 }
934 }
935
936 let decoded_has_trailing_slash = decoded.ends_with('/');
942 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
943 return Resolution::Found;
944 }
945
946 if file_exists_or_markdown_extension(&resolved) {
947 return Resolution::Found;
948 }
949
950 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
953 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
954 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
955 {
956 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
957 let source_path = parent.join(format!("{stem}{md_ext}"));
958 file_exists_with_cache(&source_path)
959 });
960 if has_md_source {
961 return Resolution::Found;
962 }
963 }
964
965 Resolution::NotFound { resolved }
966 }
967}
968
969#[cfg(feature = "blake3")]
973impl MD057ExistingRelativeLinks {
974 pub fn cache_dependency_fingerprint(
980 &self,
981 source_file: &Path,
982 flavor: crate::config::MarkdownFlavor,
983 file_index: &FileIndex,
984 ) -> String {
985 let mut hasher = blake3::Hasher::new();
986 hasher.update(b"rumdl-md057-dependencies-v1");
987 if file_index.md057_link_targets.is_empty() {
988 return hasher.finalize().to_hex().to_string();
989 }
990
991 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
992 let project_root = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
993 let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
994 let base_path = explicit_base.unwrap_or_else(|| {
995 resolved_source
996 .parent()
997 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
998 });
999 let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
1000 let ignored_frontmatter_fields: HashSet<String> = self
1001 .config
1002 .ignore_frontmatter_fields
1003 .iter()
1004 .map(|field| field.to_lowercase())
1005 .collect();
1006
1007 for dependency in &file_index.md057_link_targets {
1008 if let LinkOrigin::FrontMatter { field } = &dependency.origin
1009 && (!self.config.check_frontmatter
1010 || field
1011 .as_ref()
1012 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1013 {
1014 continue;
1015 }
1016
1017 let url = dependency.target.as_str();
1018 if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1019 continue;
1020 }
1021
1022 Self::hash_bytes(&mut hasher, url.as_bytes());
1023 if Self::is_absolute_path(url) {
1024 match self.config.absolute_links {
1025 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1026 AbsoluteLinksOption::RelativeToDocs => {
1027 hasher.update(b"docs");
1028 if let Some(docs_dir) = resolve_docs_dir(source_file) {
1029 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1030 } else {
1031 hasher.update(b"no-docs-dir");
1032 }
1033 }
1034 AbsoluteLinksOption::RelativeToRoots => {
1035 hasher.update(b"roots");
1036 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1037 let mut found = false;
1038 for root in &self.config.roots {
1039 let root_path = Self::resolve_against_project_root(root, &project_root);
1040 if Self::observe_under_root(&mut hasher, &root_path, &decoded, is_directory_link, false) {
1041 found = true;
1042 break;
1043 }
1044 }
1045 if !found {
1046 Self::observe_under_root(&mut hasher, &project_root, &decoded, is_directory_link, false);
1047 }
1048 }
1049 }
1050 } else {
1051 hasher.update(b"relative");
1052 if self.config.self_referential_links
1053 && Self::observe_self_referential_resolution(
1054 &mut hasher,
1055 url,
1056 &base_path,
1057 &search_paths,
1058 &resolved_source,
1059 )
1060 {
1061 continue;
1062 }
1063 Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
1064 }
1065 }
1066
1067 hasher.finalize().to_hex().to_string()
1068 }
1069
1070 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1071 hasher.update(&(bytes.len() as u64).to_le_bytes());
1072 hasher.update(bytes);
1073 }
1074
1075 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1076 #[cfg(unix)]
1077 {
1078 use std::os::unix::ffi::OsStrExt;
1079 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1080 }
1081 #[cfg(windows)]
1082 {
1083 use std::os::windows::ffi::OsStrExt;
1084 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1085 Self::hash_bytes(hasher, &encoded);
1086 }
1087 #[cfg(not(any(unix, windows)))]
1088 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1089 }
1090
1091 fn observe_path(hasher: &mut blake3::Hasher, path: &Path) -> DependencyPathState {
1092 Self::hash_path(hasher, path);
1093 let state = match std::fs::metadata(path) {
1094 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1095 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1096 Ok(_) => DependencyPathState::Other,
1097 Err(_) => DependencyPathState::Missing,
1098 };
1099 hasher.update(&[match state {
1100 DependencyPathState::Missing => 0,
1101 DependencyPathState::File => 1,
1102 DependencyPathState::Directory => 2,
1103 DependencyPathState::Other => 3,
1104 }]);
1105 state
1106 }
1107
1108 fn observe_existing_target(hasher: &mut blake3::Hasher, path: &Path) -> Option<PathBuf> {
1109 if Self::observe_path(hasher, path) != DependencyPathState::Missing {
1110 return Some(path.to_path_buf());
1111 }
1112 if path.extension().is_none() {
1113 for extension in MARKDOWN_EXTENSIONS {
1114 let candidate = path.with_extension(&extension[1..]);
1115 if Self::observe_path(hasher, &candidate) != DependencyPathState::Missing {
1116 return Some(candidate);
1117 }
1118 }
1119 }
1120 None
1121 }
1122
1123 fn observe_self_referential_resolution(
1124 hasher: &mut blake3::Hasher,
1125 url: &str,
1126 base_path: &Path,
1127 search_paths: &[PathBuf],
1128 source_file: &Path,
1129 ) -> bool {
1130 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1131 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1132 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1133 if let Some(resolved) = Self::observe_existing_target(hasher, &candidate) {
1134 let canonical = resolved.canonicalize().unwrap_or(resolved);
1135 hasher.update(b"resolved-identity");
1136 Self::hash_path(hasher, &canonical);
1137 return Self::is_same_file(&canonical, source_file);
1138 }
1139 }
1140 false
1141 }
1142
1143 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1144 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1145 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1146 if Self::observe_existing_target(hasher, &resolved).is_some() {
1147 return;
1148 }
1149
1150 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1151 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1152 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1153 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1154 Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1155 })
1156 {
1157 return;
1158 }
1159
1160 for search_path in search_paths {
1161 if Self::observe_existing_target(hasher, &search_path.join(&decoded)).is_some() {
1162 return;
1163 }
1164 }
1165 }
1166
1167 fn observe_absolute_resolution(
1168 hasher: &mut blake3::Hasher,
1169 root: &Path,
1170 url: &str,
1171 require_index_for_dirs: bool,
1172 ) -> bool {
1173 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1174 Self::observe_under_root(hasher, root, &decoded, is_directory_link, require_index_for_dirs)
1175 }
1176
1177 fn observe_under_root(
1178 hasher: &mut blake3::Hasher,
1179 root: &Path,
1180 decoded: &str,
1181 is_directory_link: bool,
1182 require_index_for_dirs: bool,
1183 ) -> bool {
1184 let resolved = root.join(decoded);
1185 let resolved_state = Self::observe_path(hasher, &resolved);
1186 let is_dir = resolved_state == DependencyPathState::Directory;
1187
1188 if is_directory_link || (require_index_for_dirs && is_dir) {
1189 if Self::observe_path(hasher, &resolved.join("index.md")) != DependencyPathState::Missing {
1190 return true;
1191 }
1192 if is_dir {
1193 return false;
1194 }
1195 }
1196
1197 if !require_index_for_dirs && !is_directory_link && !decoded.ends_with('/') && is_dir {
1198 return true;
1199 }
1200 if resolved_state != DependencyPathState::Missing {
1201 return true;
1202 }
1203 if resolved.extension().is_none()
1204 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1205 Self::observe_path(hasher, &resolved.with_extension(&extension[1..])) != DependencyPathState::Missing
1206 })
1207 {
1208 return true;
1209 }
1210
1211 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1212 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1213 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1214 {
1215 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1216 Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1217 });
1218 }
1219
1220 false
1221 }
1222}
1223
1224enum Resolution {
1228 Found,
1229 DirectoryWithoutIndex { resolved: PathBuf },
1230 NotFound { resolved: PathBuf },
1231}
1232
1233fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1244 let caps = re.captures_at(line, expected_start)?;
1245 if caps.get(0)?.start() != expected_start {
1246 return None;
1247 }
1248 Some(caps)
1249}
1250
1251impl Rule for MD057ExistingRelativeLinks {
1252 fn name(&self) -> &'static str {
1253 "MD057"
1254 }
1255
1256 fn description(&self) -> &'static str {
1257 "Relative links should point to existing files"
1258 }
1259
1260 fn category(&self) -> RuleCategory {
1261 RuleCategory::Link
1262 }
1263
1264 fn skippable_by_category(&self) -> bool {
1265 !self.config.check_frontmatter
1268 }
1269
1270 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1271 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1272 }
1273
1274 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1275 let content = ctx.content;
1276
1277 if content.is_empty() {
1278 return Ok(Vec::new());
1279 }
1280
1281 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1285 if !has_body_links && !self.checks_front_matter_of(ctx) {
1286 return Ok(Vec::new());
1287 }
1288
1289 reset_file_existence_cache();
1291
1292 let mut warnings = Vec::new();
1293
1294 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1298
1299 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
1303
1304 let self_path: Option<PathBuf> = ctx
1307 .source_file()
1308 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
1309
1310 let base_path: Option<PathBuf> = {
1314 if explicit_base.is_some() {
1315 explicit_base
1316 } else if let Some(ref resolved_file) = self_path {
1317 resolved_file
1321 .parent()
1322 .map(std::path::Path::to_path_buf)
1323 .or_else(|| Some(CURRENT_DIR.clone()))
1324 } else {
1325 None
1327 }
1328 };
1329
1330 let Some(base_path) = base_path else {
1332 return Ok(warnings);
1333 };
1334
1335 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
1337
1338 if !ctx.links().is_empty() {
1340 let lines = ctx.raw_lines();
1344
1345 let mut processed_lines = std::collections::HashSet::new();
1348
1349 for link in ctx.links() {
1350 let line_idx = link.line - 1;
1351 if line_idx >= lines.len() {
1352 continue;
1353 }
1354
1355 if ctx
1357 .line_info(link.line)
1358 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1359 {
1360 continue;
1361 }
1362
1363 if !processed_lines.insert(line_idx) {
1365 continue;
1366 }
1367
1368 let line = lines[line_idx];
1369
1370 if !line.contains("](") {
1372 continue;
1373 }
1374
1375 for link_match in LINK_START_REGEX.find_iter(line) {
1377 if link_match.as_str().starts_with('!') {
1384 let escapes = line[..link_match.start()]
1385 .bytes()
1386 .rev()
1387 .take_while(|&b| b == b'\\')
1388 .count();
1389 if escapes % 2 == 0 {
1390 continue;
1391 }
1392 }
1393
1394 let start_pos = link_match.start();
1395 let end_pos = link_match.end();
1396
1397 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
1399 let absolute_start_pos = line_start_byte + start_pos;
1400
1401 if ctx.is_in_code_span_byte(absolute_start_pos) {
1403 continue;
1404 }
1405
1406 if ctx.is_in_math_span(absolute_start_pos) {
1408 continue;
1409 }
1410
1411 if ctx.is_in_shortcode(absolute_start_pos) {
1416 continue;
1417 }
1418
1419 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1426 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1427 .or_else(|| {
1428 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1429 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1430 });
1431
1432 if let Some((caps, url_group)) = caps_and_url {
1433 let url = url_group.as_str().trim();
1434
1435 if url.is_empty() {
1437 continue;
1438 }
1439
1440 if url.starts_with('`') && url.ends_with('`') {
1444 continue;
1445 }
1446
1447 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1449 continue;
1450 }
1451
1452 if Self::is_absolute_path(url) {
1454 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1455 warnings.push(LintWarning {
1456 rule_name: Some(self.name().to_string()),
1457 line: link.line,
1458 column: byte_to_char_count(line, url_group.start()),
1459 end_line: link.line,
1460 end_column: byte_to_char_count(line, url_group.end()),
1461 message,
1462 severity: Severity::Warning,
1463 fix: None,
1464 });
1465 }
1466 continue;
1467 }
1468
1469 let full_url_for_compact = if let Some(frag) = caps.get(2) {
1473 format!("{url}{}", frag.as_str())
1474 } else {
1475 url.to_string()
1476 };
1477 if let Some(self_link) = self.self_referential_link(
1482 &full_url_for_compact,
1483 &base_path,
1484 &extra_search_paths,
1485 self_path.as_deref(),
1486 ctx.link_target_policy(),
1487 ) {
1488 let url_start = url_group.start();
1489 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1490 let fix_byte_start = line_start_byte + url_start;
1491 let fix_byte_end = line_start_byte + url_end;
1492 warnings.push(LintWarning {
1493 rule_name: Some(self.name().to_string()),
1494 line: link.line,
1495 column: byte_to_char_count(line, url_start),
1496 end_line: link.line,
1497 end_column: byte_to_char_count(line, url_end),
1498 message: Self::self_referential_message(&full_url_for_compact, &self_link),
1499 severity: Severity::Warning,
1500 fix: match &self_link {
1501 SelfReferentialLink::Fragment(fragment) => {
1502 Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1503 }
1504 SelfReferentialLink::WholeFile => None,
1505 },
1506 });
1507 continue;
1508 }
1509
1510 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1511 let url_start = url_group.start();
1512 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1513 let fix_byte_start = line_start_byte + url_start;
1514 let fix_byte_end = line_start_byte + url_end;
1515 warnings.push(LintWarning {
1516 rule_name: Some(self.name().to_string()),
1517 line: link.line,
1518 column: byte_to_char_count(line, url_start),
1519 end_line: link.line,
1520 end_column: byte_to_char_count(line, url_end),
1521 message: format!(
1522 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1523 ),
1524 severity: Severity::Warning,
1525 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1526 });
1527 }
1528
1529 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy())
1530 {
1531 continue;
1532 }
1533
1534 let url_start = url_group.start();
1538 let url_end = url_group.end();
1539
1540 warnings.push(LintWarning {
1541 rule_name: Some(self.name().to_string()),
1542 line: link.line,
1543 column: byte_to_char_count(line, url_start),
1544 end_line: link.line,
1545 end_column: byte_to_char_count(line, url_end),
1546 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1547 severity: Severity::Error,
1548 fix: None,
1549 });
1550 }
1551 }
1552 }
1553 }
1554
1555 for image in ctx.images() {
1557 if ctx
1559 .line_info(image.line)
1560 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1561 {
1562 continue;
1563 }
1564
1565 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1569 continue;
1570 }
1571
1572 if ctx.is_in_shortcode(image.byte_offset) {
1575 continue;
1576 }
1577
1578 let url = image.url.as_ref();
1579
1580 if url.is_empty() {
1582 continue;
1583 }
1584
1585 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1587 continue;
1588 }
1589
1590 if Self::is_absolute_path(url) {
1592 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1593 warnings.push(LintWarning {
1594 rule_name: Some(self.name().to_string()),
1595 line: image.line,
1596 column: image.start_col + 1,
1597 end_line: image.line,
1598 end_column: image.start_col + 1 + url.chars().count(),
1599 message,
1600 severity: Severity::Warning,
1601 fix: None,
1602 });
1603 }
1604 continue;
1605 }
1606
1607 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1609 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1612 let fix_byte_start = image.byte_offset + url_offset;
1613 let fix_byte_end = fix_byte_start + url.len();
1614 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1615 });
1616
1617 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1618 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1619 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1622 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1623 });
1624 warnings.push(LintWarning {
1625 rule_name: Some(self.name().to_string()),
1626 line: image.line,
1627 column: url_col,
1628 end_line: image.line,
1629 end_column: url_col + url.chars().count(),
1630 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1631 severity: Severity::Warning,
1632 fix,
1633 });
1634 }
1635
1636 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1637 continue;
1638 }
1639
1640 warnings.push(LintWarning {
1643 rule_name: Some(self.name().to_string()),
1644 line: image.line,
1645 column: image.start_col + 1,
1646 end_line: image.line,
1647 end_column: image.start_col + 1 + url.chars().count(),
1648 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1649 severity: Severity::Error,
1650 fix: None,
1651 });
1652 }
1653
1654 for ref_def in ctx.reference_definitions() {
1656 if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1657 continue;
1658 }
1659 let url = &ref_def.url;
1660
1661 if url.is_empty() {
1663 continue;
1664 }
1665
1666 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1668 continue;
1669 }
1670
1671 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1675 let (line, col) = url_range
1676 .as_ref()
1677 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1678 let end_col = col + url.chars().count();
1679
1680 if Self::is_absolute_path(url) {
1682 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1683 warnings.push(LintWarning {
1684 rule_name: Some(self.name().to_string()),
1685 line,
1686 column: col,
1687 end_line: line,
1688 end_column: end_col,
1689 message,
1690 severity: Severity::Warning,
1691 fix: None,
1692 });
1693 }
1694 continue;
1695 }
1696
1697 if let Some(self_link) = self.self_referential_link(
1699 url,
1700 &base_path,
1701 &extra_search_paths,
1702 self_path.as_deref(),
1703 ctx.link_target_policy(),
1704 ) {
1705 warnings.push(LintWarning {
1706 rule_name: Some(self.name().to_string()),
1707 line,
1708 column: col,
1709 end_line: line,
1710 end_column: end_col,
1711 message: Self::self_referential_message(url, &self_link),
1712 severity: Severity::Warning,
1713 fix: match (&self_link, &url_range) {
1714 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1715 Some(Fix::new(range.clone(), fragment.clone()))
1716 }
1717 _ => None,
1718 },
1719 });
1720 continue;
1721 }
1722
1723 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1725 warnings.push(LintWarning {
1726 rule_name: Some(self.name().to_string()),
1727 line,
1728 column: col,
1729 end_line: line,
1730 end_column: end_col,
1731 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1732 severity: Severity::Warning,
1733 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1734 });
1735 }
1736
1737 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1738 continue;
1739 }
1740
1741 warnings.push(LintWarning {
1743 rule_name: Some(self.name().to_string()),
1744 line,
1745 column: col,
1746 end_line: line,
1747 end_column: end_col,
1748 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1749 severity: Severity::Error,
1750 fix: None,
1751 });
1752 }
1753
1754 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1755
1756 Ok(warnings)
1757 }
1758
1759 fn fix_capability(&self) -> FixCapability {
1760 if self.produces_fixes() {
1761 FixCapability::ConditionallyFixable
1762 } else {
1763 FixCapability::Unfixable
1764 }
1765 }
1766
1767 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1768 if !self.produces_fixes() {
1769 return Ok(ctx.content.to_string());
1770 }
1771
1772 let warnings = self.check(ctx)?;
1773 let warnings =
1774 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1775 let mut content = ctx.content.to_string();
1776
1777 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1779 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1780
1781 let mut last_applied_start: Option<usize> = None;
1787 for fix in fixes {
1788 if let Some(prev_start) = last_applied_start
1789 && fix.range.end > prev_start
1790 {
1791 continue;
1792 }
1793 if fix.range.end <= content.len() {
1794 content.replace_range(fix.range.clone(), &fix.replacement);
1795 last_applied_start = Some(fix.range.start);
1796 }
1797 }
1798
1799 Ok(content)
1800 }
1801
1802 fn as_any(&self) -> &dyn std::any::Any {
1803 self
1804 }
1805
1806 crate::impl_rule_config_sections!(MD057Config);
1807
1808 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1809 where
1810 Self: Sized,
1811 {
1812 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1813 Box::new(Self::from_config_struct(rule_config))
1817 }
1818
1819 fn cross_file_scope(&self) -> CrossFileScope {
1820 CrossFileScope::Workspace
1821 }
1822
1823 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1824 self.contribute_dependency_targets(ctx, index);
1825
1826 let links = extract_cross_file_links(ctx);
1829 for link in links.relative {
1830 index.add_cross_file_link(link);
1831 }
1832 for link in links.root_relative {
1835 index.add_root_relative_link(link);
1836 }
1837 }
1838
1839 fn cross_file_check(
1840 &self,
1841 _file_path: &Path,
1842 _file_index: &FileIndex,
1843 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1844 ) -> LintResult {
1845 Ok(Vec::new())
1855 }
1856}
1857
1858fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1863 let from_components: Vec<_> = from_dir.components().collect();
1864 let to_components: Vec<_> = to_path.components().collect();
1865
1866 let common_len = from_components
1868 .iter()
1869 .zip(to_components.iter())
1870 .take_while(|(a, b)| a == b)
1871 .count();
1872
1873 let mut result = PathBuf::new();
1874
1875 for _ in common_len..from_components.len() {
1877 result.push("..");
1878 }
1879
1880 for component in &to_components[common_len..] {
1882 result.push(component);
1883 }
1884
1885 result
1886}
1887
1888fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1894 let link_path = Path::new(raw_link_path);
1895
1896 let has_traversal = link_path
1898 .components()
1899 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1900
1901 if !has_traversal {
1902 return None;
1903 }
1904
1905 let combined = source_dir.join(link_path);
1907 let normalized_target = normalize_relative_path(&combined);
1908
1909 let normalized_source = normalize_relative_path(source_dir);
1911 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1912
1913 if shortest != link_path {
1915 let compact = shortest.to_string_lossy().to_string();
1916 if compact.is_empty() {
1918 return None;
1919 }
1920 Some(compact.replace('\\', "/"))
1922 } else {
1923 None
1924 }
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929 use super::*;
1930 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1931 use std::fs::File;
1932 use std::io::Write;
1933 use tempfile::tempdir;
1934
1935 #[test]
1936 fn test_strip_query_and_fragment() {
1937 assert_eq!(
1939 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1940 "file.png"
1941 );
1942 assert_eq!(
1943 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1944 "file.png"
1945 );
1946 assert_eq!(
1947 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1948 "file.png"
1949 );
1950
1951 assert_eq!(
1953 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1954 "file.md"
1955 );
1956 assert_eq!(
1957 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1958 "file.md"
1959 );
1960
1961 assert_eq!(
1963 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1964 "file.md"
1965 );
1966
1967 assert_eq!(
1969 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1970 "file.png"
1971 );
1972
1973 assert_eq!(
1975 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1976 "path/to/image.png"
1977 );
1978 assert_eq!(
1979 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1980 "path/to/image.png"
1981 );
1982
1983 assert_eq!(
1985 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1986 "file.md"
1987 );
1988 }
1989
1990 #[test]
1991 fn test_url_decode() {
1992 assert_eq!(
1994 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1995 "penguin with space.jpg"
1996 );
1997
1998 assert_eq!(
2000 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
2001 "assets/my file name.png"
2002 );
2003
2004 assert_eq!(
2006 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
2007 "hello world!.md"
2008 );
2009
2010 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
2012
2013 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
2015
2016 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
2018
2019 assert_eq!(
2021 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
2022 "normal-file.md"
2023 );
2024
2025 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
2027
2028 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
2030
2031 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
2033
2034 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
2036
2037 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
2039
2040 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
2042
2043 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
2045
2046 assert_eq!(
2048 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
2049 "path/to/file.md"
2050 );
2051
2052 assert_eq!(
2054 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
2055 "hello world/foo bar.md"
2056 );
2057
2058 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
2060
2061 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
2063 }
2064
2065 #[test]
2066 fn test_url_encoded_filenames() {
2067 let temp_dir = tempdir().unwrap();
2069 let base_path = temp_dir.path();
2070
2071 let file_with_spaces = base_path.join("penguin with space.jpg");
2073 File::create(&file_with_spaces)
2074 .unwrap()
2075 .write_all(b"image data")
2076 .unwrap();
2077
2078 let subdir = base_path.join("my images");
2080 std::fs::create_dir(&subdir).unwrap();
2081 let nested_file = subdir.join("photo 1.png");
2082 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2083
2084 let content = r#"
2086# Test Document with URL-Encoded Links
2087
2088
2089
2090
2091"#;
2092
2093 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2094
2095 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096 let result = rule.check(&ctx).unwrap();
2097
2098 assert_eq!(
2100 result.len(),
2101 1,
2102 "Should only warn about missing%20file.jpg. Got: {result:?}"
2103 );
2104 assert!(
2105 result[0].message.contains("missing%20file.jpg"),
2106 "Warning should mention the URL-encoded filename"
2107 );
2108 }
2109
2110 #[test]
2111 fn test_external_urls() {
2112 let rule = MD057ExistingRelativeLinks::new();
2113
2114 assert!(rule.is_external_url("https://example.com"));
2116 assert!(rule.is_external_url("http://example.com"));
2117 assert!(rule.is_external_url("ftp://example.com"));
2118 assert!(rule.is_external_url("www.example.com"));
2119 assert!(rule.is_external_url("example.com"));
2120
2121 assert!(rule.is_external_url("file:///path/to/file"));
2123 assert!(rule.is_external_url("smb://server/share"));
2124 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
2125 assert!(rule.is_external_url("mailto:user@example.com"));
2126 assert!(rule.is_external_url("tel:+1234567890"));
2127 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
2128 assert!(rule.is_external_url("javascript:void(0)"));
2129 assert!(rule.is_external_url("ssh://git@github.com/repo"));
2130 assert!(rule.is_external_url("git://github.com/repo.git"));
2131
2132 assert!(rule.is_external_url("user@example.com"));
2135 assert!(rule.is_external_url("steering@kubernetes.io"));
2136 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
2137 assert!(rule.is_external_url("user_name@sub.domain.com"));
2138 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2139
2140 assert!(rule.is_external_url("{{URL}}")); assert!(rule.is_external_url("{{#URL}}")); assert!(rule.is_external_url("{{> partial}}")); assert!(rule.is_external_url("{{ variable }}")); assert!(rule.is_external_url("{{% include %}}")); assert!(rule.is_external_url("{{")); assert!(!rule.is_external_url("/api/v1/users"));
2151 assert!(!rule.is_external_url("/blog/2024/release.html"));
2152 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
2153 assert!(!rule.is_external_url("/pkg/runtime"));
2154 assert!(!rule.is_external_url("/doc/go1compat"));
2155 assert!(!rule.is_external_url("/index.html"));
2156 assert!(!rule.is_external_url("/assets/logo.png"));
2157
2158 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2160 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2161 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2162 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2163 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2164
2165 assert!(rule.is_external_url("~/assets/image.png"));
2168 assert!(rule.is_external_url("~/components/Button.vue"));
2169 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
2173 assert!(rule.is_external_url("@images/photo.jpg"));
2174 assert!(rule.is_external_url("@assets/styles.css"));
2175
2176 assert!(!rule.is_external_url("./relative/path.md"));
2178 assert!(!rule.is_external_url("relative/path.md"));
2179 assert!(!rule.is_external_url("../parent/path.md"));
2180 }
2181
2182 #[test]
2183 fn test_dot_com_only_skips_bare_domains() {
2184 let rule = MD057ExistingRelativeLinks::new();
2185
2186 assert!(rule.is_external_url("example.com"));
2188 assert!(rule.is_external_url("sub.example.com"));
2189
2190 assert!(!rule.is_external_url("../../vendor.com"));
2194 assert!(!rule.is_external_url("./vendor.com"));
2195 assert!(!rule.is_external_url("docs/vendor.com"));
2196 }
2197
2198 #[test]
2199 fn test_framework_path_aliases() {
2200 let temp_dir = tempdir().unwrap();
2202 let base_path = temp_dir.path();
2203
2204 let content = r#"
2206# Framework Path Aliases
2207
2208
2209
2210
2211
2212[Link](@/pages/about.md)
2213
2214This is a [real missing link](missing.md) that should be flagged.
2215"#;
2216
2217 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2218
2219 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2220 let result = rule.check(&ctx).unwrap();
2221
2222 assert_eq!(
2224 result.len(),
2225 1,
2226 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2227 );
2228 assert!(
2229 result[0].message.contains("missing.md"),
2230 "Warning should be for missing.md"
2231 );
2232 }
2233
2234 #[test]
2235 fn test_url_decode_security_path_traversal() {
2236 let temp_dir = tempdir().unwrap();
2239 let base_path = temp_dir.path();
2240
2241 let file_in_base = base_path.join("safe.md");
2243 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2244
2245 let content = r#"
2250[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2251[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2252[Safe link](safe.md)
2253"#;
2254
2255 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2256
2257 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2258 let result = rule.check(&ctx).unwrap();
2259
2260 assert_eq!(
2263 result.len(),
2264 2,
2265 "Should have warnings for traversal attempts. Got: {result:?}"
2266 );
2267 }
2268
2269 #[test]
2270 fn test_url_encoded_utf8_filenames() {
2271 let temp_dir = tempdir().unwrap();
2273 let base_path = temp_dir.path();
2274
2275 let cafe_file = base_path.join("café.md");
2277 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2278
2279 let content = r#"
2280[Café link](caf%C3%A9.md)
2281[Missing unicode](r%C3%A9sum%C3%A9.md)
2282"#;
2283
2284 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2285
2286 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2287 let result = rule.check(&ctx).unwrap();
2288
2289 assert_eq!(
2291 result.len(),
2292 1,
2293 "Should only warn about missing résumé.md. Got: {result:?}"
2294 );
2295 assert!(
2296 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2297 "Warning should mention the URL-encoded filename"
2298 );
2299 }
2300
2301 #[test]
2302 fn test_url_encoded_emoji_filenames() {
2303 let temp_dir = tempdir().unwrap();
2306 let base_path = temp_dir.path();
2307
2308 let emoji_dir = base_path.join("👤 Personal");
2310 std::fs::create_dir(&emoji_dir).unwrap();
2311
2312 let file_path = emoji_dir.join("TV Shows.md");
2314 File::create(&file_path)
2315 .unwrap()
2316 .write_all(b"# TV Shows\n\nContent here.")
2317 .unwrap();
2318
2319 let content = r#"
2322# Test Document
2323
2324[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2325[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2326"#;
2327
2328 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2329
2330 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331 let result = rule.check(&ctx).unwrap();
2332
2333 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2335 assert!(
2336 result[0].message.contains("Missing.md"),
2337 "Warning should be for Missing.md, got: {}",
2338 result[0].message
2339 );
2340 }
2341
2342 #[test]
2343 fn test_no_warnings_without_base_path() {
2344 let rule = MD057ExistingRelativeLinks::new();
2345 let content = "[Link](missing.md)";
2346
2347 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2348 let result = rule.check(&ctx).unwrap();
2349 assert!(result.is_empty(), "Should have no warnings without base path");
2350 }
2351
2352 #[test]
2353 fn test_existing_and_missing_links() {
2354 let temp_dir = tempdir().unwrap();
2356 let base_path = temp_dir.path();
2357
2358 let exists_path = base_path.join("exists.md");
2360 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2361
2362 assert!(exists_path.exists(), "exists.md should exist for this test");
2364
2365 let content = r#"
2367# Test Document
2368
2369[Valid Link](exists.md)
2370[Invalid Link](missing.md)
2371[External Link](https://example.com)
2372[Media Link](image.jpg)
2373 "#;
2374
2375 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2377
2378 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2380 let result = rule.check(&ctx).unwrap();
2381
2382 assert_eq!(result.len(), 2);
2384 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2385 assert!(messages.iter().any(|m| m.contains("missing.md")));
2386 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2387 }
2388
2389 #[test]
2390 fn test_angle_bracket_links() {
2391 let temp_dir = tempdir().unwrap();
2393 let base_path = temp_dir.path();
2394
2395 let exists_path = base_path.join("exists.md");
2397 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2398
2399 let content = r#"
2401# Test Document
2402
2403[Valid Link](<exists.md>)
2404[Invalid Link](<missing.md>)
2405[External Link](<https://example.com>)
2406 "#;
2407
2408 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2410
2411 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2412 let result = rule.check(&ctx).unwrap();
2413
2414 assert_eq!(result.len(), 1, "Should have exactly one warning");
2416 assert!(
2417 result[0].message.contains("missing.md"),
2418 "Warning should mention missing.md"
2419 );
2420 }
2421
2422 #[test]
2423 fn test_angle_bracket_links_with_parens() {
2424 let temp_dir = tempdir().unwrap();
2426 let base_path = temp_dir.path();
2427
2428 let app_dir = base_path.join("app");
2430 std::fs::create_dir(&app_dir).unwrap();
2431 let upload_dir = app_dir.join("(upload)");
2432 std::fs::create_dir(&upload_dir).unwrap();
2433 let page_file = upload_dir.join("page.tsx");
2434 File::create(&page_file)
2435 .unwrap()
2436 .write_all(b"export default function Page() {}")
2437 .unwrap();
2438
2439 let content = r#"
2441# Test Document with Paths Containing Parens
2442
2443[Upload Page](<app/(upload)/page.tsx>)
2444[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2445[Missing](<app/(missing)/file.md>)
2446"#;
2447
2448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2449
2450 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2451 let result = rule.check(&ctx).unwrap();
2452
2453 assert_eq!(
2455 result.len(),
2456 1,
2457 "Should have exactly one warning for missing file. Got: {result:?}"
2458 );
2459 assert!(
2460 result[0].message.contains("app/(missing)/file.md"),
2461 "Warning should mention app/(missing)/file.md"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_all_file_types_checked() {
2467 let temp_dir = tempdir().unwrap();
2469 let base_path = temp_dir.path();
2470
2471 let content = r#"
2473[Image Link](image.jpg)
2474[Video Link](video.mp4)
2475[Markdown Link](document.md)
2476[PDF Link](file.pdf)
2477"#;
2478
2479 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2480
2481 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482 let result = rule.check(&ctx).unwrap();
2483
2484 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2486 }
2487
2488 #[test]
2489 fn test_code_span_detection() {
2490 let rule = MD057ExistingRelativeLinks::new();
2491
2492 let temp_dir = tempdir().unwrap();
2494 let base_path = temp_dir.path();
2495
2496 let rule = rule.with_path(base_path);
2497
2498 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2500
2501 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2502 let result = rule.check(&ctx).unwrap();
2503
2504 assert_eq!(result.len(), 1, "Should only flag the real link");
2506 assert!(result[0].message.contains("nonexistent.md"));
2507 }
2508
2509 #[test]
2510 fn test_inline_code_spans() {
2511 let temp_dir = tempdir().unwrap();
2513 let base_path = temp_dir.path();
2514
2515 let content = r#"
2517# Test Document
2518
2519This is a normal link: [Link](missing.md)
2520
2521This is a code span with a link: `[Link](another-missing.md)`
2522
2523Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2524
2525 "#;
2526
2527 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2529
2530 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2532 let result = rule.check(&ctx).unwrap();
2533
2534 assert_eq!(result.len(), 1, "Should have exactly one warning");
2536 assert!(
2537 result[0].message.contains("missing.md"),
2538 "Warning should be for missing.md"
2539 );
2540 assert!(
2541 !result.iter().any(|w| w.message.contains("another-missing.md")),
2542 "Should not warn about link in code span"
2543 );
2544 assert!(
2545 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2546 "Should not warn about link in inline code"
2547 );
2548 }
2549
2550 #[test]
2551 fn test_extensionless_link_resolution() {
2552 let temp_dir = tempdir().unwrap();
2554 let base_path = temp_dir.path();
2555
2556 let page_path = base_path.join("page.md");
2558 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2559
2560 let content = r#"
2562# Test Document
2563
2564[Link without extension](page)
2565[Link with extension](page.md)
2566[Missing link](nonexistent)
2567"#;
2568
2569 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2570
2571 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2572 let result = rule.check(&ctx).unwrap();
2573
2574 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2577 assert!(
2578 result[0].message.contains("nonexistent"),
2579 "Warning should be for 'nonexistent' not 'page'"
2580 );
2581 }
2582
2583 #[test]
2585 fn test_cross_file_scope() {
2586 let rule = MD057ExistingRelativeLinks::new();
2587 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2588 }
2589
2590 #[test]
2591 fn test_contribute_to_index_extracts_markdown_links() {
2592 let rule = MD057ExistingRelativeLinks::new();
2593 let content = r#"
2594# Document
2595
2596[Link to docs](./docs/guide.md)
2597[Link with fragment](./other.md#section)
2598[External link](https://example.com)
2599[Image link](image.png)
2600[Media file](video.mp4)
2601"#;
2602
2603 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2604 let mut index = FileIndex::new();
2605 rule.contribute_to_index(&ctx, &mut index);
2606
2607 assert_eq!(index.cross_file_links.len(), 2);
2609
2610 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2612 assert_eq!(index.cross_file_links[0].fragment, "");
2613
2614 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2616 assert_eq!(index.cross_file_links[1].fragment, "section");
2617 }
2618
2619 #[test]
2620 fn test_contribute_to_index_skips_external_and_anchors() {
2621 let rule = MD057ExistingRelativeLinks::new();
2622 let content = r#"
2623# Document
2624
2625[External](https://example.com)
2626[Another external](http://example.org)
2627[Fragment only](#section)
2628[FTP link](ftp://files.example.com)
2629[Mail link](mailto:test@example.com)
2630[WWW link](www.example.com)
2631"#;
2632
2633 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634 let mut index = FileIndex::new();
2635 rule.contribute_to_index(&ctx, &mut index);
2636
2637 assert_eq!(index.cross_file_links.len(), 0);
2639 }
2640
2641 #[test]
2642 fn test_cross_file_check_valid_link() {
2643 use crate::workspace_index::WorkspaceIndex;
2644
2645 let rule = MD057ExistingRelativeLinks::new();
2646
2647 let mut workspace_index = WorkspaceIndex::new();
2649 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2650
2651 let mut file_index = FileIndex::new();
2653 file_index.add_cross_file_link(CrossFileLinkIndex {
2654 target_path: "guide.md".to_string(),
2655 fragment: "".to_string(),
2656 line: 5,
2657 column: 1,
2658 origin: LinkOrigin::Body,
2659 });
2660
2661 let warnings = rule
2663 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2664 .unwrap();
2665
2666 assert!(warnings.is_empty());
2668 }
2669
2670 #[test]
2671 fn test_cross_file_check_missing_link() {
2672 use crate::workspace_index::WorkspaceIndex;
2675
2676 let rule = MD057ExistingRelativeLinks::new();
2677 let workspace_index = WorkspaceIndex::new();
2678
2679 let mut file_index = FileIndex::new();
2680 file_index.add_cross_file_link(CrossFileLinkIndex {
2681 target_path: "missing.md".to_string(),
2682 fragment: "".to_string(),
2683 line: 5,
2684 column: 1,
2685 origin: LinkOrigin::Body,
2686 });
2687
2688 let warnings = rule
2689 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2690 .unwrap();
2691
2692 assert!(
2694 warnings.is_empty(),
2695 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn test_cross_file_check_parent_path() {
2701 use crate::workspace_index::WorkspaceIndex;
2702
2703 let rule = MD057ExistingRelativeLinks::new();
2704
2705 let mut workspace_index = WorkspaceIndex::new();
2707 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2708
2709 let mut file_index = FileIndex::new();
2711 file_index.add_cross_file_link(CrossFileLinkIndex {
2712 target_path: "../readme.md".to_string(),
2713 fragment: "".to_string(),
2714 line: 5,
2715 column: 1,
2716 origin: LinkOrigin::Body,
2717 });
2718
2719 let warnings = rule
2721 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2722 .unwrap();
2723
2724 assert!(warnings.is_empty());
2726 }
2727
2728 #[test]
2729 fn test_cross_file_check_html_link_with_md_source() {
2730 use crate::workspace_index::WorkspaceIndex;
2733
2734 let rule = MD057ExistingRelativeLinks::new();
2735
2736 let mut workspace_index = WorkspaceIndex::new();
2738 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2739
2740 let mut file_index = FileIndex::new();
2742 file_index.add_cross_file_link(CrossFileLinkIndex {
2743 target_path: "guide.html".to_string(),
2744 fragment: "section".to_string(),
2745 line: 10,
2746 column: 5,
2747 origin: LinkOrigin::Body,
2748 });
2749
2750 let warnings = rule
2752 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2753 .unwrap();
2754
2755 assert!(
2757 warnings.is_empty(),
2758 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_cross_file_check_html_link_without_source() {
2764 use crate::workspace_index::WorkspaceIndex;
2768
2769 let rule = MD057ExistingRelativeLinks::new();
2770 let workspace_index = WorkspaceIndex::new();
2771
2772 let mut file_index = FileIndex::new();
2773 file_index.add_cross_file_link(CrossFileLinkIndex {
2774 target_path: "missing.html".to_string(),
2775 fragment: "".to_string(),
2776 line: 10,
2777 column: 5,
2778 origin: LinkOrigin::Body,
2779 });
2780
2781 let warnings = rule
2782 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2783 .unwrap();
2784
2785 assert!(
2787 warnings.is_empty(),
2788 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2789 );
2790 }
2791
2792 #[test]
2793 fn test_normalize_path_function() {
2794 assert_eq!(
2796 normalize_relative_path(Path::new("docs/guide.md")),
2797 PathBuf::from("docs/guide.md")
2798 );
2799
2800 assert_eq!(
2802 normalize_relative_path(Path::new("./docs/guide.md")),
2803 PathBuf::from("docs/guide.md")
2804 );
2805
2806 assert_eq!(
2808 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2809 PathBuf::from("docs/guide.md")
2810 );
2811
2812 assert_eq!(
2814 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2815 PathBuf::from("a/d.md")
2816 );
2817 }
2818
2819 #[test]
2820 fn test_html_link_with_md_source() {
2821 let temp_dir = tempdir().unwrap();
2823 let base_path = temp_dir.path();
2824
2825 let md_file = base_path.join("guide.md");
2827 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2828
2829 let content = r#"
2830[Read the guide](guide.html)
2831[Also here](getting-started.html)
2832"#;
2833
2834 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2835 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2836 let result = rule.check(&ctx).unwrap();
2837
2838 assert_eq!(
2840 result.len(),
2841 1,
2842 "Should only warn about missing source. Got: {result:?}"
2843 );
2844 assert!(result[0].message.contains("getting-started.html"));
2845 }
2846
2847 #[test]
2848 fn test_htm_link_with_md_source() {
2849 let temp_dir = tempdir().unwrap();
2851 let base_path = temp_dir.path();
2852
2853 let md_file = base_path.join("page.md");
2854 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2855
2856 let content = "[Page](page.htm)";
2857
2858 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2859 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2860 let result = rule.check(&ctx).unwrap();
2861
2862 assert!(
2863 result.is_empty(),
2864 "Should not warn when .md source exists for .htm link"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_html_link_finds_various_markdown_extensions() {
2870 let temp_dir = tempdir().unwrap();
2872 let base_path = temp_dir.path();
2873
2874 File::create(base_path.join("doc.md")).unwrap();
2875 File::create(base_path.join("tutorial.mdx")).unwrap();
2876 File::create(base_path.join("guide.markdown")).unwrap();
2877
2878 let content = r#"
2879[Doc](doc.html)
2880[Tutorial](tutorial.html)
2881[Guide](guide.html)
2882"#;
2883
2884 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2885 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2886 let result = rule.check(&ctx).unwrap();
2887
2888 assert!(
2889 result.is_empty(),
2890 "Should find all markdown variants as source files. Got: {result:?}"
2891 );
2892 }
2893
2894 #[test]
2895 fn test_html_link_in_subdirectory() {
2896 let temp_dir = tempdir().unwrap();
2898 let base_path = temp_dir.path();
2899
2900 let docs_dir = base_path.join("docs");
2901 std::fs::create_dir(&docs_dir).unwrap();
2902 File::create(docs_dir.join("guide.md"))
2903 .unwrap()
2904 .write_all(b"# Guide")
2905 .unwrap();
2906
2907 let content = "[Guide](docs/guide.html)";
2908
2909 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2910 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2911 let result = rule.check(&ctx).unwrap();
2912
2913 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2914 }
2915
2916 #[test]
2917 fn test_absolute_path_skipped_in_check() {
2918 let temp_dir = tempdir().unwrap();
2921 let base_path = temp_dir.path();
2922
2923 let content = r#"
2924# Test Document
2925
2926[Go Runtime](/pkg/runtime)
2927[Go Runtime with Fragment](/pkg/runtime#section)
2928[API Docs](/api/v1/users)
2929[Blog Post](/blog/2024/release.html)
2930[React Hook](/react/hooks/use-state.html)
2931"#;
2932
2933 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2935 let result = rule.check(&ctx).unwrap();
2936
2937 assert!(
2939 result.is_empty(),
2940 "Absolute paths should be skipped. Got warnings: {result:?}"
2941 );
2942 }
2943
2944 #[test]
2945 fn test_absolute_path_skipped_in_cross_file_check() {
2946 use crate::workspace_index::WorkspaceIndex;
2948
2949 let rule = MD057ExistingRelativeLinks::new();
2950
2951 let workspace_index = WorkspaceIndex::new();
2953
2954 let mut file_index = FileIndex::new();
2956 file_index.add_cross_file_link(CrossFileLinkIndex {
2957 target_path: "/pkg/runtime.md".to_string(),
2958 fragment: "".to_string(),
2959 line: 5,
2960 column: 1,
2961 origin: LinkOrigin::Body,
2962 });
2963 file_index.add_cross_file_link(CrossFileLinkIndex {
2964 target_path: "/api/v1/users.md".to_string(),
2965 fragment: "section".to_string(),
2966 line: 10,
2967 column: 1,
2968 origin: LinkOrigin::Body,
2969 });
2970
2971 let warnings = rule
2973 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2974 .unwrap();
2975
2976 assert!(
2978 warnings.is_empty(),
2979 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2980 );
2981 }
2982
2983 #[test]
2984 fn test_protocol_relative_url_not_skipped() {
2985 let temp_dir = tempdir().unwrap();
2988 let base_path = temp_dir.path();
2989
2990 let content = r#"
2991# Test Document
2992
2993[External](//example.com/page)
2994[Another](//cdn.example.com/asset.js)
2995"#;
2996
2997 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2998 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2999 let result = rule.check(&ctx).unwrap();
3000
3001 assert!(
3003 result.is_empty(),
3004 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3005 );
3006 }
3007
3008 #[test]
3009 fn test_email_addresses_skipped() {
3010 let temp_dir = tempdir().unwrap();
3013 let base_path = temp_dir.path();
3014
3015 let content = r#"
3016# Test Document
3017
3018[Contact](user@example.com)
3019[Steering](steering@kubernetes.io)
3020[Support](john.doe+filter@company.co.uk)
3021[User](user_name@sub.domain.com)
3022"#;
3023
3024 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3025 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3026 let result = rule.check(&ctx).unwrap();
3027
3028 assert!(
3030 result.is_empty(),
3031 "Email addresses should be skipped. Got warnings: {result:?}"
3032 );
3033 }
3034
3035 #[test]
3036 fn test_email_addresses_vs_file_paths() {
3037 let temp_dir = tempdir().unwrap();
3040 let base_path = temp_dir.path();
3041
3042 let content = r#"
3043# Test Document
3044
3045[Email](user@example.com) <!-- Should be skipped (email) -->
3046[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
3047[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
3048"#;
3049
3050 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3051 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3052 let result = rule.check(&ctx).unwrap();
3053
3054 assert!(
3056 result.is_empty(),
3057 "All email addresses should be skipped. Got: {result:?}"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_diagnostic_position_accuracy() {
3063 let temp_dir = tempdir().unwrap();
3065 let base_path = temp_dir.path();
3066
3067 let content = "prefix [text](missing.md) suffix";
3070 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3074 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3075 let result = rule.check(&ctx).unwrap();
3076
3077 assert_eq!(result.len(), 1, "Should have exactly one warning");
3078 assert_eq!(result[0].line, 1, "Should be on line 1");
3079 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3080 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3081 }
3082
3083 #[test]
3084 fn test_diagnostic_position_non_ascii_link() {
3085 let temp_dir = tempdir().unwrap();
3088 let base_path = temp_dir.path();
3089
3090 let content = "你好你好[你好](not-exist.md) bar";
3094
3095 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3096 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3097 let result = rule.check(&ctx).unwrap();
3098
3099 assert_eq!(result.len(), 1, "Should have exactly one warning");
3100 assert_eq!(result[0].line, 1, "Should be on line 1");
3101 assert_eq!(
3102 result[0].column, 10,
3103 "Column must be a character offset, not a byte offset"
3104 );
3105 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3106 }
3107
3108 #[test]
3109 fn test_diagnostic_position_angle_brackets() {
3110 let temp_dir = tempdir().unwrap();
3112 let base_path = temp_dir.path();
3113
3114 let content = "[link](<missing.md>)";
3117 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3120 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121 let result = rule.check(&ctx).unwrap();
3122
3123 assert_eq!(result.len(), 1, "Should have exactly one warning");
3124 assert_eq!(result[0].line, 1, "Should be on line 1");
3125 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3126 }
3127
3128 #[test]
3129 fn test_diagnostic_position_multiline() {
3130 let temp_dir = tempdir().unwrap();
3132 let base_path = temp_dir.path();
3133
3134 let content = r#"# Title
3135Some text on line 2
3136[link on line 3](missing1.md)
3137More text
3138[link on line 5](missing2.md)"#;
3139
3140 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3141 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3142 let result = rule.check(&ctx).unwrap();
3143
3144 assert_eq!(result.len(), 2, "Should have two warnings");
3145
3146 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3148 assert!(result[0].message.contains("missing1.md"));
3149
3150 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3152 assert!(result[1].message.contains("missing2.md"));
3153 }
3154
3155 #[test]
3156 fn test_diagnostic_position_with_spaces() {
3157 let temp_dir = tempdir().unwrap();
3159 let base_path = temp_dir.path();
3160
3161 let content = "[link]( missing.md )";
3162 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_eq!(result.len(), 1, "Should have exactly one warning");
3171 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3173 }
3174
3175 #[test]
3176 fn test_diagnostic_position_image() {
3177 let temp_dir = tempdir().unwrap();
3179 let base_path = temp_dir.path();
3180
3181 let content = "";
3182
3183 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3184 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3185 let result = rule.check(&ctx).unwrap();
3186
3187 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3188 assert_eq!(result[0].line, 1);
3189 assert!(result[0].column > 0, "Should have valid column position");
3191 assert!(result[0].message.contains("missing.jpg"));
3192 }
3193
3194 #[test]
3195 fn test_diagnostic_position_non_ascii_image() {
3196 let temp_dir = tempdir().unwrap();
3198 let base_path = temp_dir.path();
3199
3200 let content = "你好你好";
3203
3204 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3205 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3206 let result = rule.check(&ctx).unwrap();
3207
3208 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3209 assert_eq!(result[0].line, 1, "Should be on line 1");
3210 assert_eq!(
3211 result[0].column, 5,
3212 "Column must be a character offset, not a byte offset"
3213 );
3214 assert!(result[0].message.contains("not-exist.png"));
3215 }
3216
3217 #[test]
3218 fn test_diagnostic_position_non_ascii_reference_def() {
3219 let temp_dir = tempdir().unwrap();
3223 let base_path = temp_dir.path();
3224
3225 let content = "[你好]: not-exist.md";
3228
3229 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3230 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3231 let result = rule.check(&ctx).unwrap();
3232
3233 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3234 assert_eq!(result[0].line, 1, "Should be on line 1");
3235 assert_eq!(
3236 result[0].column, 7,
3237 "Column must be a character offset, not a byte offset"
3238 );
3239 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3240 }
3241
3242 #[test]
3243 fn test_wikilinks_skipped() {
3244 let temp_dir = tempdir().unwrap();
3247 let base_path = temp_dir.path();
3248
3249 let content = r#"# Test Document
3250
3251[[Microsoft#Windows OS]]
3252[[SomePage]]
3253[[Page With Spaces]]
3254[[path/to/page#section]]
3255[[page|Display Text]]
3256
3257This is a [real missing link](missing.md) that should be flagged.
3258"#;
3259
3260 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3261 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3262 let result = rule.check(&ctx).unwrap();
3263
3264 assert_eq!(
3266 result.len(),
3267 1,
3268 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3269 );
3270 assert!(
3271 result[0].message.contains("missing.md"),
3272 "Warning should be for missing.md, not wikilinks"
3273 );
3274 }
3275
3276 #[test]
3277 fn test_wiki_embeds_skipped() {
3278 let temp_dir = tempdir().unwrap();
3282 let base_path = temp_dir.path();
3283
3284 let content = r#"# Test Document
3285
3286![[diagram.png]]
3287![[subfolder/diagram.png]]
3288![[diagram.png|300]]
3289![[Some Note]]
3290
3291This is a [real missing link](missing.md) that should be flagged.
3292"#;
3293
3294 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3295 for flavor in [
3296 crate::config::MarkdownFlavor::Obsidian,
3297 crate::config::MarkdownFlavor::Standard,
3298 ] {
3299 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3300 let result = rule.check(&ctx).unwrap();
3301
3302 assert_eq!(
3303 result.len(),
3304 1,
3305 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3306 );
3307 assert!(result[0].message.contains("missing.md"));
3308 }
3309 }
3310
3311 #[test]
3312 fn test_wikilinks_not_added_to_index() {
3313 let temp_dir = tempdir().unwrap();
3315 let base_path = temp_dir.path();
3316
3317 let content = r#"# Test Document
3318
3319[[Microsoft#Windows OS]]
3320[[SomePage#section]]
3321[Regular Link](other.md)
3322"#;
3323
3324 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3325 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3326
3327 let mut file_index = FileIndex::new();
3328 rule.contribute_to_index(&ctx, &mut file_index);
3329
3330 let cross_file_links = &file_index.cross_file_links;
3333 assert_eq!(
3334 cross_file_links.len(),
3335 1,
3336 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3337 );
3338 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3339 }
3340
3341 #[test]
3342 fn test_reference_definition_missing_file() {
3343 let temp_dir = tempdir().unwrap();
3345 let base_path = temp_dir.path();
3346
3347 let content = r#"# Test Document
3348
3349[test]: ./missing.md
3350[example]: ./nonexistent.html
3351
3352Use [test] and [example] here.
3353"#;
3354
3355 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3356 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3357 let result = rule.check(&ctx).unwrap();
3358
3359 assert_eq!(
3361 result.len(),
3362 2,
3363 "Should have warnings for missing reference definition targets. Got: {result:?}"
3364 );
3365 assert!(
3366 result.iter().any(|w| w.message.contains("missing.md")),
3367 "Should warn about missing.md"
3368 );
3369 assert!(
3370 result.iter().any(|w| w.message.contains("nonexistent.html")),
3371 "Should warn about nonexistent.html"
3372 );
3373 }
3374
3375 #[test]
3376 fn test_reference_definition_existing_file() {
3377 let temp_dir = tempdir().unwrap();
3379 let base_path = temp_dir.path();
3380
3381 let exists_path = base_path.join("exists.md");
3383 File::create(&exists_path)
3384 .unwrap()
3385 .write_all(b"# Existing file")
3386 .unwrap();
3387
3388 let content = r#"# Test Document
3389
3390[test]: ./exists.md
3391
3392Use [test] here.
3393"#;
3394
3395 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3396 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3397 let result = rule.check(&ctx).unwrap();
3398
3399 assert!(
3401 result.is_empty(),
3402 "Should not warn about existing file. Got: {result:?}"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_reference_definition_external_url_skipped() {
3408 let temp_dir = tempdir().unwrap();
3410 let base_path = temp_dir.path();
3411
3412 let content = r#"# Test Document
3413
3414[google]: https://google.com
3415[example]: http://example.org
3416[mail]: mailto:test@example.com
3417[ftp]: ftp://files.example.com
3418[local]: ./missing.md
3419
3420Use [google], [example], [mail], [ftp], [local] here.
3421"#;
3422
3423 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3424 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3425 let result = rule.check(&ctx).unwrap();
3426
3427 assert_eq!(
3429 result.len(),
3430 1,
3431 "Should only warn about local missing file. Got: {result:?}"
3432 );
3433 assert!(
3434 result[0].message.contains("missing.md"),
3435 "Warning should be for missing.md"
3436 );
3437 }
3438
3439 #[test]
3440 fn test_reference_definition_fragment_only_skipped() {
3441 let temp_dir = tempdir().unwrap();
3443 let base_path = temp_dir.path();
3444
3445 let content = r#"# Test Document
3446
3447[section]: #my-section
3448
3449Use [section] here.
3450"#;
3451
3452 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3453 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3454 let result = rule.check(&ctx).unwrap();
3455
3456 assert!(
3458 result.is_empty(),
3459 "Should not warn about fragment-only reference. Got: {result:?}"
3460 );
3461 }
3462
3463 #[test]
3464 fn test_reference_definition_column_position() {
3465 let temp_dir = tempdir().unwrap();
3467 let base_path = temp_dir.path();
3468
3469 let content = "[ref]: ./missing.md";
3472 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3476 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3477 let result = rule.check(&ctx).unwrap();
3478
3479 assert_eq!(result.len(), 1, "Should have exactly one warning");
3480 assert_eq!(result[0].line, 1, "Should be on line 1");
3481 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3482 }
3483
3484 #[test]
3485 fn test_reference_definition_html_with_md_source() {
3486 let temp_dir = tempdir().unwrap();
3488 let base_path = temp_dir.path();
3489
3490 let md_file = base_path.join("guide.md");
3492 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3493
3494 let content = r#"# Test Document
3495
3496[guide]: ./guide.html
3497[missing]: ./missing.html
3498
3499Use [guide] and [missing] here.
3500"#;
3501
3502 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3503 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3504 let result = rule.check(&ctx).unwrap();
3505
3506 assert_eq!(
3508 result.len(),
3509 1,
3510 "Should only warn about missing source. Got: {result:?}"
3511 );
3512 assert!(result[0].message.contains("missing.html"));
3513 }
3514
3515 #[test]
3516 fn test_reference_definition_url_encoded() {
3517 let temp_dir = tempdir().unwrap();
3519 let base_path = temp_dir.path();
3520
3521 let file_with_spaces = base_path.join("file with spaces.md");
3523 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3524
3525 let content = r#"# Test Document
3526
3527[spaces]: ./file%20with%20spaces.md
3528[missing]: ./missing%20file.md
3529
3530Use [spaces] and [missing] here.
3531"#;
3532
3533 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3534 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3535 let result = rule.check(&ctx).unwrap();
3536
3537 assert_eq!(
3539 result.len(),
3540 1,
3541 "Should only warn about missing URL-encoded file. Got: {result:?}"
3542 );
3543 assert!(result[0].message.contains("missing%20file.md"));
3544 }
3545
3546 #[test]
3547 fn test_inline_and_reference_both_checked() {
3548 let temp_dir = tempdir().unwrap();
3550 let base_path = temp_dir.path();
3551
3552 let content = r#"# Test Document
3553
3554[inline link](./inline-missing.md)
3555[ref]: ./ref-missing.md
3556
3557Use [ref] here.
3558"#;
3559
3560 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3561 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3562 let result = rule.check(&ctx).unwrap();
3563
3564 assert_eq!(
3566 result.len(),
3567 2,
3568 "Should warn about both inline and reference links. Got: {result:?}"
3569 );
3570 assert!(
3571 result.iter().any(|w| w.message.contains("inline-missing.md")),
3572 "Should warn about inline-missing.md"
3573 );
3574 assert!(
3575 result.iter().any(|w| w.message.contains("ref-missing.md")),
3576 "Should warn about ref-missing.md"
3577 );
3578 }
3579
3580 #[test]
3581 fn test_footnote_definitions_not_flagged() {
3582 let rule = MD057ExistingRelativeLinks::default();
3585
3586 let content = r#"# Title
3587
3588A footnote[^1].
3589
3590[^1]: [link](https://www.google.com).
3591"#;
3592
3593 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3594 let result = rule.check(&ctx).unwrap();
3595
3596 assert!(
3597 result.is_empty(),
3598 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3599 );
3600 }
3601
3602 #[test]
3603 fn test_footnote_with_relative_link_inside() {
3604 let rule = MD057ExistingRelativeLinks::default();
3607
3608 let content = r#"# Title
3609
3610See the footnote[^1].
3611
3612[^1]: Check out [this file](./existing.md) for more info.
3613[^2]: Also see [missing](./does-not-exist.md).
3614"#;
3615
3616 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3617 let result = rule.check(&ctx).unwrap();
3618
3619 for warning in &result {
3624 assert!(
3625 !warning.message.contains("[this file]"),
3626 "Footnote content should not be treated as URL: {warning:?}"
3627 );
3628 assert!(
3629 !warning.message.contains("[missing]"),
3630 "Footnote content should not be treated as URL: {warning:?}"
3631 );
3632 }
3633 }
3634
3635 #[test]
3636 fn test_mixed_footnotes_and_reference_definitions() {
3637 let temp_dir = tempdir().unwrap();
3639 let base_path = temp_dir.path();
3640
3641 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3642
3643 let content = r#"# Title
3644
3645A footnote[^1] and a [ref link][myref].
3646
3647[^1]: This is a footnote with [link](https://example.com).
3648
3649[myref]: ./missing-file.md "This should be checked"
3650"#;
3651
3652 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3653 let result = rule.check(&ctx).unwrap();
3654
3655 assert_eq!(
3657 result.len(),
3658 1,
3659 "Should only warn about the regular reference definition. Got: {result:?}"
3660 );
3661 assert!(
3662 result[0].message.contains("missing-file.md"),
3663 "Should warn about missing-file.md in reference definition"
3664 );
3665 }
3666
3667 #[test]
3668 fn test_absolute_links_ignore_by_default() {
3669 let temp_dir = tempdir().unwrap();
3671 let base_path = temp_dir.path();
3672
3673 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3674
3675 let content = r#"# Links
3676
3677[API docs](/api/v1/users)
3678[Blog post](/blog/2024/release.html)
3679
3680
3681[ref]: /docs/reference.md
3682"#;
3683
3684 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3685 let result = rule.check(&ctx).unwrap();
3686
3687 assert!(
3689 result.is_empty(),
3690 "Absolute links should be ignored by default. Got: {result:?}"
3691 );
3692 }
3693
3694 #[test]
3695 fn test_absolute_links_warn_config() {
3696 let temp_dir = tempdir().unwrap();
3698 let base_path = temp_dir.path();
3699
3700 let config = MD057Config {
3701 absolute_links: AbsoluteLinksOption::Warn,
3702 ..Default::default()
3703 };
3704 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3705
3706 let content = r#"# Links
3707
3708[API docs](/api/v1/users)
3709[Blog post](/blog/2024/release.html)
3710"#;
3711
3712 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3713 let result = rule.check(&ctx).unwrap();
3714
3715 assert_eq!(
3717 result.len(),
3718 2,
3719 "Should warn about both absolute links. Got: {result:?}"
3720 );
3721 assert!(
3722 result[0].message.contains("cannot be validated locally"),
3723 "Warning should explain why: {}",
3724 result[0].message
3725 );
3726 assert!(
3727 result[0].message.contains("/api/v1/users"),
3728 "Warning should include the link path"
3729 );
3730 }
3731
3732 #[test]
3733 fn test_absolute_links_warn_images() {
3734 let temp_dir = tempdir().unwrap();
3736 let base_path = temp_dir.path();
3737
3738 let config = MD057Config {
3739 absolute_links: AbsoluteLinksOption::Warn,
3740 ..Default::default()
3741 };
3742 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3743
3744 let content = r#"# Images
3745
3746
3747"#;
3748
3749 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3750 let result = rule.check(&ctx).unwrap();
3751
3752 assert_eq!(
3753 result.len(),
3754 1,
3755 "Should warn about absolute image path. Got: {result:?}"
3756 );
3757 assert!(
3758 result[0].message.contains("/assets/logo.png"),
3759 "Warning should include the image path"
3760 );
3761 }
3762
3763 #[test]
3764 fn test_absolute_links_warn_reference_definitions() {
3765 let temp_dir = tempdir().unwrap();
3767 let base_path = temp_dir.path();
3768
3769 let config = MD057Config {
3770 absolute_links: AbsoluteLinksOption::Warn,
3771 ..Default::default()
3772 };
3773 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3774
3775 let content = r#"# Reference
3776
3777See the [docs][ref].
3778
3779[ref]: /docs/reference.md
3780"#;
3781
3782 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3783 let result = rule.check(&ctx).unwrap();
3784
3785 assert_eq!(
3786 result.len(),
3787 1,
3788 "Should warn about absolute reference definition. Got: {result:?}"
3789 );
3790 assert!(
3791 result[0].message.contains("/docs/reference.md"),
3792 "Warning should include the reference path"
3793 );
3794 }
3795
3796 #[test]
3797 fn test_search_paths_inline_link() {
3798 let temp_dir = tempdir().unwrap();
3799 let base_path = temp_dir.path();
3800
3801 let assets_dir = base_path.join("assets");
3803 std::fs::create_dir_all(&assets_dir).unwrap();
3804 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3805
3806 let config = MD057Config {
3807 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3808 ..Default::default()
3809 };
3810 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3811
3812 let content = "# Test\n\n[Photo](photo.png)\n";
3813 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3814 let result = rule.check(&ctx).unwrap();
3815
3816 assert!(
3817 result.is_empty(),
3818 "Should find photo.png via search-paths. Got: {result:?}"
3819 );
3820 }
3821
3822 #[test]
3823 fn test_search_paths_image() {
3824 let temp_dir = tempdir().unwrap();
3825 let base_path = temp_dir.path();
3826
3827 let assets_dir = base_path.join("attachments");
3828 std::fs::create_dir_all(&assets_dir).unwrap();
3829 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3830
3831 let config = MD057Config {
3832 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3833 ..Default::default()
3834 };
3835 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3836
3837 let content = "# Test\n\n\n";
3838 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3839 let result = rule.check(&ctx).unwrap();
3840
3841 assert!(
3842 result.is_empty(),
3843 "Should find diagram.svg via search-paths. Got: {result:?}"
3844 );
3845 }
3846
3847 #[test]
3848 fn test_search_paths_reference_definition() {
3849 let temp_dir = tempdir().unwrap();
3850 let base_path = temp_dir.path();
3851
3852 let assets_dir = base_path.join("images");
3853 std::fs::create_dir_all(&assets_dir).unwrap();
3854 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3855
3856 let config = MD057Config {
3857 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3858 ..Default::default()
3859 };
3860 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3861
3862 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3863 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3864 let result = rule.check(&ctx).unwrap();
3865
3866 assert!(
3867 result.is_empty(),
3868 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3869 );
3870 }
3871
3872 #[test]
3873 fn test_search_paths_still_warns_when_truly_missing() {
3874 let temp_dir = tempdir().unwrap();
3875 let base_path = temp_dir.path();
3876
3877 let assets_dir = base_path.join("assets");
3878 std::fs::create_dir_all(&assets_dir).unwrap();
3879
3880 let config = MD057Config {
3881 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3882 ..Default::default()
3883 };
3884 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3885
3886 let content = "# Test\n\n\n";
3887 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3888 let result = rule.check(&ctx).unwrap();
3889
3890 assert_eq!(
3891 result.len(),
3892 1,
3893 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3894 );
3895 }
3896
3897 #[test]
3898 fn test_search_paths_nonexistent_directory() {
3899 let temp_dir = tempdir().unwrap();
3900 let base_path = temp_dir.path();
3901
3902 let config = MD057Config {
3903 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3904 ..Default::default()
3905 };
3906 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3907
3908 let content = "# Test\n\n\n";
3909 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3910 let result = rule.check(&ctx).unwrap();
3911
3912 assert_eq!(
3913 result.len(),
3914 1,
3915 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3916 );
3917 }
3918
3919 #[test]
3920 fn test_obsidian_attachment_folder_named() {
3921 let temp_dir = tempdir().unwrap();
3922 let vault = temp_dir.path().join("vault");
3923 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3924 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3925 std::fs::create_dir_all(vault.join("notes")).unwrap();
3926
3927 std::fs::write(
3928 vault.join(".obsidian/app.json"),
3929 r#"{"attachmentFolderPath": "Attachments"}"#,
3930 )
3931 .unwrap();
3932 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3933
3934 let notes_dir = vault.join("notes");
3935 let source_file = notes_dir.join("test.md");
3936 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3937
3938 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3939
3940 let content = "# Test\n\n\n";
3941 let ctx =
3942 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3943 let result = rule.check(&ctx).unwrap();
3944
3945 assert!(
3946 result.is_empty(),
3947 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3948 );
3949 }
3950
3951 #[test]
3952 fn test_obsidian_attachment_same_folder_as_file() {
3953 let temp_dir = tempdir().unwrap();
3954 let vault = temp_dir.path().join("vault-rf");
3955 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3956 std::fs::create_dir_all(vault.join("notes")).unwrap();
3957
3958 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3959
3960 let notes_dir = vault.join("notes");
3962 let source_file = notes_dir.join("test.md");
3963 std::fs::write(&source_file, "placeholder").unwrap();
3964 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3965
3966 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3967
3968 let content = "# Test\n\n\n";
3969 let ctx =
3970 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3971 let result = rule.check(&ctx).unwrap();
3972
3973 assert!(
3974 result.is_empty(),
3975 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3976 );
3977 }
3978
3979 #[test]
3980 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3981 let temp_dir = tempdir().unwrap();
3982 let vault = temp_dir.path().join("vault-nf");
3983 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3984 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3985 std::fs::create_dir_all(vault.join("notes")).unwrap();
3986
3987 std::fs::write(
3988 vault.join(".obsidian/app.json"),
3989 r#"{"attachmentFolderPath": "Attachments"}"#,
3990 )
3991 .unwrap();
3992 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3993
3994 let notes_dir = vault.join("notes");
3995 let source_file = notes_dir.join("test.md");
3996 std::fs::write(&source_file, "placeholder").unwrap();
3997
3998 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3999
4000 let content = "# Test\n\n\n";
4001 let ctx =
4003 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4004 let result = rule.check(&ctx).unwrap();
4005
4006 assert_eq!(
4007 result.len(),
4008 1,
4009 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4010 );
4011 }
4012
4013 #[test]
4014 fn test_search_paths_combined_with_obsidian() {
4015 let temp_dir = tempdir().unwrap();
4016 let vault = temp_dir.path().join("vault-combo");
4017 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4018 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4019 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4020 std::fs::create_dir_all(vault.join("notes")).unwrap();
4021
4022 std::fs::write(
4023 vault.join(".obsidian/app.json"),
4024 r#"{"attachmentFolderPath": "Attachments"}"#,
4025 )
4026 .unwrap();
4027 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4028 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4029
4030 let notes_dir = vault.join("notes");
4031 let source_file = notes_dir.join("test.md");
4032 std::fs::write(&source_file, "placeholder").unwrap();
4033
4034 let extra_assets_dir = vault.join("extra-assets");
4035 let config = MD057Config {
4036 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4037 ..Default::default()
4038 };
4039 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
4040
4041 let content = "# Test\n\n\n\n\n";
4043 let ctx =
4044 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4045 let result = rule.check(&ctx).unwrap();
4046
4047 assert!(
4048 result.is_empty(),
4049 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4050 );
4051 }
4052
4053 #[test]
4054 fn test_obsidian_attachment_subfolder_under_file() {
4055 let temp_dir = tempdir().unwrap();
4056 let vault = temp_dir.path().join("vault-sub");
4057 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4058 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4059
4060 std::fs::write(
4061 vault.join(".obsidian/app.json"),
4062 r#"{"attachmentFolderPath": "./assets"}"#,
4063 )
4064 .unwrap();
4065 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4066
4067 let notes_dir = vault.join("notes");
4068 let source_file = notes_dir.join("test.md");
4069 std::fs::write(&source_file, "placeholder").unwrap();
4070
4071 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4072
4073 let content = "# Test\n\n\n";
4074 let ctx =
4075 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4076 let result = rule.check(&ctx).unwrap();
4077
4078 assert!(
4079 result.is_empty(),
4080 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4081 );
4082 }
4083
4084 #[test]
4085 fn test_obsidian_attachment_vault_root() {
4086 let temp_dir = tempdir().unwrap();
4087 let vault = temp_dir.path().join("vault-root");
4088 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4089 std::fs::create_dir_all(vault.join("notes")).unwrap();
4090
4091 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4093 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4094
4095 let notes_dir = vault.join("notes");
4096 let source_file = notes_dir.join("test.md");
4097 std::fs::write(&source_file, "placeholder").unwrap();
4098
4099 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4100
4101 let content = "# Test\n\n\n";
4102 let ctx =
4103 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4104 let result = rule.check(&ctx).unwrap();
4105
4106 assert!(
4107 result.is_empty(),
4108 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4109 );
4110 }
4111
4112 #[test]
4113 fn test_search_paths_multiple_directories() {
4114 let temp_dir = tempdir().unwrap();
4115 let base_path = temp_dir.path();
4116
4117 let dir_a = base_path.join("dir-a");
4118 let dir_b = base_path.join("dir-b");
4119 std::fs::create_dir_all(&dir_a).unwrap();
4120 std::fs::create_dir_all(&dir_b).unwrap();
4121 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4122 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4123
4124 let config = MD057Config {
4125 search_paths: vec![
4126 dir_a.to_string_lossy().into_owned(),
4127 dir_b.to_string_lossy().into_owned(),
4128 ],
4129 ..Default::default()
4130 };
4131 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4132
4133 let content = "# Test\n\n\n\n\n";
4134 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4135 let result = rule.check(&ctx).unwrap();
4136
4137 assert!(
4138 result.is_empty(),
4139 "Should find files across multiple search paths. Got: {result:?}"
4140 );
4141 }
4142
4143 #[test]
4152 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4153 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4154
4155 let temp_dir = tempdir().unwrap();
4156 let base_path = temp_dir.path();
4157
4158 let file_path = base_path.join("README.md");
4159 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4160 std::fs::write(&file_path, content).unwrap();
4161
4162 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4163
4164 let ctx = crate::lint_context::LintContext::new(
4165 content,
4166 crate::config::MarkdownFlavor::Standard,
4167 Some(file_path.clone()),
4168 );
4169 let per_file = rule.check(&ctx).unwrap();
4170 assert_eq!(
4171 per_file.len(),
4172 1,
4173 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4174 );
4175
4176 let mut file_index = FileIndex::default();
4177 file_index.cross_file_links.push(CrossFileLinkIndex {
4178 target_path: "missing-guide.md".to_string(),
4179 fragment: String::new(),
4180 line: 3,
4181 column: 1,
4182 origin: LinkOrigin::Body,
4183 });
4184
4185 let result = rule
4186 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4187 .unwrap();
4188
4189 assert!(
4190 result.is_empty(),
4191 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4192 );
4193 }
4194
4195 #[test]
4196 fn test_check_clears_stale_cache() {
4197 let temp_dir = tempdir().unwrap();
4200 let base_path = temp_dir.path();
4201
4202 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4203
4204 let phantom_path = base_path.join("phantom.md");
4206 {
4207 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4208 cache.insert(phantom_path.clone(), true);
4209 }
4210
4211 let content = "[phantom](phantom.md)\n";
4212 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4213 let warnings = rule.check(&ctx).unwrap();
4214
4215 assert_eq!(
4217 warnings.len(),
4218 1,
4219 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4220 );
4221 assert!(warnings[0].message.contains("phantom.md"));
4222 }
4223
4224 #[test]
4225 fn test_check_does_not_carry_over_cache_between_runs() {
4226 let temp_dir = tempdir().unwrap();
4228 let base_path = temp_dir.path();
4229
4230 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4231
4232 let content = "[missing](nonexistent.md)\n";
4233 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4234
4235 let warnings_1 = rule.check(&ctx).unwrap();
4237 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4238
4239 let nonexistent_path = base_path.join("nonexistent.md");
4241 {
4242 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4243 cache.insert(nonexistent_path.clone(), true);
4244 }
4245
4246 let warnings_2 = rule.check(&ctx).unwrap();
4248 assert_eq!(
4249 warnings_2.len(),
4250 1,
4251 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4252 );
4253 }
4254
4255 #[test]
4261 fn test_no_duplicate_warnings_for_broken_relative_link() {
4262 use crate::workspace_index::WorkspaceIndex;
4263
4264 let temp_dir = tempdir().unwrap();
4265 let base_path = temp_dir.path();
4266
4267 let source_file = base_path.join("index.md");
4269 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4270
4271 let content = "[broken](does/not/exist.md)\n";
4272
4273 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4274
4275 let ctx = crate::lint_context::LintContext::new(
4277 content,
4278 crate::config::MarkdownFlavor::Standard,
4279 Some(source_file.clone()),
4280 );
4281 let check_warnings = rule.check(&ctx).unwrap();
4282
4283 let mut file_index = FileIndex::new();
4285 rule.contribute_to_index(&ctx, &mut file_index);
4286 let workspace_index = WorkspaceIndex::new();
4287 let cross_warnings = rule
4288 .cross_file_check(&source_file, &file_index, &workspace_index)
4289 .unwrap();
4290
4291 let total = check_warnings.len() + cross_warnings.len();
4292 assert_eq!(
4293 total, 1,
4294 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4295 check={check_warnings:?}, cross={cross_warnings:?}"
4296 );
4297 }
4298
4299 #[test]
4304 fn test_absolute_dir_link_accepted_relative_to_roots() {
4305 let temp_dir = tempdir().unwrap();
4306 let root = temp_dir.path();
4307
4308 let dir_d = root.join("d");
4310 std::fs::create_dir_all(&dir_d).unwrap();
4311 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4312
4313 let content = "\
4316[absolute dir](/d)\n\
4317[relative dir](d)\n\
4318[absolute file](/d/foo.md)\n\
4319[relative file](d/foo.md)\n";
4320
4321 let config = MD057Config {
4322 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4323 roots: vec![],
4324 ..Default::default()
4325 };
4326 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4327
4328 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4329 let result = rule.check(&ctx).unwrap();
4330
4331 assert!(
4332 result.is_empty(),
4333 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4334 );
4335 }
4336
4337 #[test]
4340 fn test_absolute_trailing_slash_dir_link_requires_index() {
4341 let temp_dir = tempdir().unwrap();
4342 let root = temp_dir.path();
4343
4344 let dir_d = root.join("d");
4346 std::fs::create_dir_all(&dir_d).unwrap();
4347 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4348
4349 let content = "[dir with slash](/d/)\n";
4351
4352 let config = MD057Config {
4353 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4354 roots: vec![],
4355 ..Default::default()
4356 };
4357 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4358
4359 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4360 let result = rule.check(&ctx).unwrap();
4361
4362 assert_eq!(
4363 result.len(),
4364 1,
4365 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
4366 );
4367 }
4368
4369 #[test]
4373 fn test_docs_dir_variant_still_enforces_index_md() {
4374 let temp_dir = tempdir().unwrap();
4375 let root = temp_dir.path();
4376
4377 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4379
4380 let docs_dir = root.join("docs");
4382 std::fs::create_dir_all(&docs_dir).unwrap();
4383 let section_dir = docs_dir.join("section");
4384 std::fs::create_dir_all(§ion_dir).unwrap();
4385 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4386
4387 let source_file = docs_dir.join("index.md");
4389 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4390
4391 let config = MD057Config {
4392 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4393 ..Default::default()
4394 };
4395 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4396
4397 let content = "[sec](/section)\n";
4398 let ctx = crate::lint_context::LintContext::new(
4399 content,
4400 crate::config::MarkdownFlavor::Standard,
4401 Some(source_file.clone()),
4402 );
4403 let result = rule.check(&ctx).unwrap();
4404
4405 assert_eq!(
4407 result.len(),
4408 1,
4409 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4410 );
4411 assert!(
4412 result[0].message.contains("index.md") || result[0].message.contains("section"),
4413 "Message should mention the directory or missing index.md: {}",
4414 result[0].message
4415 );
4416 }
4417
4418 #[test]
4424 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4425 let temp_dir = tempdir().unwrap();
4426 let root = temp_dir.path();
4427
4428 let guide_dir = root.join("guide");
4430 std::fs::create_dir_all(&guide_dir).unwrap();
4431 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4432
4433 let content = "[guide with fragment](/guide/#intro)\n";
4435
4436 let config = MD057Config {
4437 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4438 roots: vec![],
4439 ..Default::default()
4440 };
4441 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4442 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4443 let result = rule.check(&ctx).unwrap();
4444
4445 assert_eq!(
4446 result.len(),
4447 1,
4448 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4449 );
4450 }
4451}
4452
4453#[cfg(test)]
4454mod self_referential_links_tests {
4455 use super::*;
4456 use tempfile::tempdir;
4457
4458 fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4460 let source_file = dir.join(name);
4461 std::fs::write(&source_file, content).unwrap();
4462 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4463 let ctx =
4464 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4465 rule.check(&ctx).unwrap()
4466 }
4467
4468 fn enabled() -> MD057Config {
4469 MD057Config {
4470 self_referential_links: true,
4471 ..Default::default()
4472 }
4473 }
4474
4475 #[test]
4476 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4477 let temp_dir = tempdir().unwrap();
4478 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4479 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4480
4481 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4482 assert_eq!(
4483 result[0].message,
4484 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4485 );
4486 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4487 assert_eq!(fix.replacement, "#level-2-heading");
4488 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4489 }
4490
4491 #[test]
4492 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4493 let temp_dir = tempdir().unwrap();
4494 let content = "# Title\n\nSee [this file](test.md).\n";
4495 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4496
4497 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4498 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4499 assert!(
4500 result[0].fix.is_none(),
4501 "Dropping the link would change the document, so there is no fix"
4502 );
4503 }
4504
4505 #[test]
4506 fn test_the_check_is_off_by_default() {
4507 let temp_dir = tempdir().unwrap();
4508 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4509 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4510
4511 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4512 }
4513
4514 #[test]
4515 fn test_a_link_to_another_file_is_left_alone() {
4516 let temp_dir = tempdir().unwrap();
4517 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4518 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4519 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4520
4521 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4522 }
4523
4524 #[test]
4525 fn test_a_self_link_written_with_traversal_reports_once() {
4526 let temp_dir = tempdir().unwrap();
4527 let sub_dir = temp_dir.path().join("sub");
4528 std::fs::create_dir_all(&sub_dir).unwrap();
4529
4530 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4531 let config = MD057Config {
4532 self_referential_links: true,
4533 compact_paths: true,
4534 ..Default::default()
4535 };
4536 let result = check_as_file(&sub_dir, "test.md", content, config);
4537
4538 assert_eq!(
4539 result.len(),
4540 1,
4541 "A compacted path would still be a link back to this file. Got: {result:?}"
4542 );
4543 assert_eq!(
4544 result[0].message,
4545 "Relative link '../sub/test.md' points to the file it is in"
4546 );
4547 }
4548
4549 #[test]
4550 fn test_compact_paths_still_reports_a_link_to_another_file() {
4551 let temp_dir = tempdir().unwrap();
4552 let sub_dir = temp_dir.path().join("sub");
4553 std::fs::create_dir_all(&sub_dir).unwrap();
4554 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4555
4556 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4557 let config = MD057Config {
4558 self_referential_links: true,
4559 compact_paths: true,
4560 ..Default::default()
4561 };
4562 let result = check_as_file(&sub_dir, "test.md", content, config);
4563
4564 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4565 assert_eq!(
4566 result[0].message,
4567 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4568 );
4569 }
4570
4571 #[test]
4572 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4573 let temp_dir = tempdir().unwrap();
4574 let content = "# Title\n\nSee [this file](test#title).\n";
4575 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4576
4577 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4578 assert_eq!(
4579 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4580 Some("#title"),
4581 "Got: {result:?}"
4582 );
4583 }
4584
4585 #[test]
4586 fn test_a_reference_definition_pointing_at_its_own_file() {
4587 let temp_dir = tempdir().unwrap();
4588 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4589 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4590
4591 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4592 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4593 assert_eq!(fix.replacement, "#level-2-heading");
4594 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4595 }
4596
4597 #[test]
4598 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4599 let temp_dir = tempdir().unwrap();
4600 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4601 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4602
4603 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4604 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4605 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4608 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4609 .fix(&crate::lint_context::LintContext::new(
4610 content,
4611 crate::config::MarkdownFlavor::Standard,
4612 Some(temp_dir.path().join("test.md")),
4613 ))
4614 .unwrap();
4615 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4616 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4617 }
4618
4619 #[test]
4620 fn test_a_self_link_resolved_through_a_search_path() {
4621 let temp_dir = tempdir().unwrap();
4622 let guide_dir = temp_dir.path().join("docs/guide");
4623 std::fs::create_dir_all(&guide_dir).unwrap();
4624 let config = MD057Config {
4625 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4626 ..enabled()
4627 };
4628 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4629 let result = check_as_file(&guide_dir, "test.md", content, config);
4630
4631 assert_eq!(
4632 result.len(),
4633 1,
4634 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4635 );
4636 assert_eq!(
4637 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4638 Some("#title"),
4639 "Got: {result:?}"
4640 );
4641 }
4642
4643 #[test]
4644 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4645 let temp_dir = tempdir().unwrap();
4646 let guide_dir = temp_dir.path().join("docs/guide");
4647 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4648 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4649 let config = MD057Config {
4650 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4651 ..enabled()
4652 };
4653 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4654 let result = check_as_file(&guide_dir, "test.md", content, config);
4655
4656 assert!(
4657 result.is_empty(),
4658 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4659 );
4660 }
4661
4662 #[test]
4663 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4664 let temp_dir = tempdir().unwrap();
4665 let content = "# Title\n\n\n";
4666 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4667
4668 assert!(
4669 result.is_empty(),
4670 "An image is not a link the reader follows. Got: {result:?}"
4671 );
4672 }
4673
4674 #[test]
4675 fn test_a_query_string_is_reported_without_a_suggestion() {
4676 let temp_dir = tempdir().unwrap();
4677 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4678 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4679
4680 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4681 assert!(
4682 result[0].fix.is_none(),
4683 "A query does not survive losing its path. Got: {result:?}"
4684 );
4685 }
4686
4687 #[test]
4688 fn test_fix_rewrites_the_document_and_settles() {
4689 let temp_dir = tempdir().unwrap();
4690 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4691 let source_file = temp_dir.path().join("test.md");
4692 std::fs::write(&source_file, content).unwrap();
4693
4694 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4695 let ctx = crate::lint_context::LintContext::new(
4696 content,
4697 crate::config::MarkdownFlavor::Standard,
4698 Some(source_file.clone()),
4699 );
4700 let fixed = rule.fix(&ctx).unwrap();
4701 assert_eq!(
4702 fixed,
4703 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4704 );
4705
4706 let refixed =
4707 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4708 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4709 }
4710
4711 #[test]
4712 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4713 let unfixable = MD057ExistingRelativeLinks::default();
4714 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4715
4716 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4717 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4718 }
4719
4720 #[test]
4721 fn test_the_option_is_read_from_kebab_and_snake_case() {
4722 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4723 assert!(kebab.self_referential_links);
4724
4725 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4726 assert!(snake.self_referential_links);
4727 }
4728
4729 fn front_matter_checked() -> MD057Config {
4730 MD057Config {
4731 check_frontmatter: true,
4732 ..Default::default()
4733 }
4734 }
4735
4736 #[test]
4737 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4738 let temp_dir = tempdir().unwrap();
4739 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4740 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4741
4742 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4743 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4744 assert_eq!(result[0].line, 2);
4745 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4746 assert_eq!(result[0].end_column, 23);
4747 }
4748
4749 #[test]
4750 fn test_frontmatter_paths_are_not_checked_by_default() {
4751 let temp_dir = tempdir().unwrap();
4752 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4753 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4754
4755 assert!(
4756 result.is_empty(),
4757 "Frontmatter is only checked on request. Got: {result:?}"
4758 );
4759 }
4760
4761 #[test]
4762 fn test_an_existing_frontmatter_path_is_not_reported() {
4763 let temp_dir = tempdir().unwrap();
4764 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4765 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4766 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4767
4768 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4769 assert_eq!(result[0].line, 3);
4770 }
4771
4772 #[test]
4773 fn test_an_ignored_frontmatter_field_is_not_checked() {
4774 let temp_dir = tempdir().unwrap();
4775 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4776 let config = MD057Config {
4777 check_frontmatter: true,
4778 ignore_frontmatter_fields: vec!["Image".to_string()],
4779 ..Default::default()
4780 };
4781 let result = check_as_file(temp_dir.path(), "test.md", content, config);
4782
4783 assert_eq!(
4784 result.len(),
4785 1,
4786 "The ignored field is skipped and the other is not. Got: {result:?}"
4787 );
4788 assert_eq!(result[0].line, 3);
4789 }
4790
4791 #[test]
4792 fn test_an_external_frontmatter_url_is_not_reported() {
4793 let temp_dir = tempdir().unwrap();
4794 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4795 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4796
4797 assert!(
4798 result.is_empty(),
4799 "An external URL has no local target. Got: {result:?}"
4800 );
4801 }
4802
4803 #[test]
4804 fn test_a_frontmatter_fragment_is_left_to_md051() {
4805 let temp_dir = tempdir().unwrap();
4806 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4807 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4808
4809 assert!(
4810 result.is_empty(),
4811 "A fragment names a heading, not a file. Got: {result:?}"
4812 );
4813 }
4814
4815 #[test]
4816 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4817 let temp_dir = tempdir().unwrap();
4818 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4819
4820 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4821 assert!(
4822 ignored.is_empty(),
4823 "Absolute paths are ignored by default. Got: {ignored:?}"
4824 );
4825
4826 let warning_config = MD057Config {
4827 check_frontmatter: true,
4828 absolute_links: AbsoluteLinksOption::Warn,
4829 ..Default::default()
4830 };
4831 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4832 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4833 assert_eq!(
4834 warned[0].message,
4835 "Absolute link '/docs/guide.md' cannot be validated locally"
4836 );
4837 }
4838
4839 #[test]
4840 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4841 let temp_dir = tempdir().unwrap();
4842 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4843 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4844 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4845
4846 assert_eq!(
4847 result.len(),
4848 1,
4849 "A query names no file, so only the missing target is reported. Got: {result:?}"
4850 );
4851 assert_eq!(result[0].line, 2);
4852 assert_eq!(
4853 result[0].message,
4854 "Relative link 'docs/missing.md?raw=true' does not exist"
4855 );
4856 }
4857
4858 #[test]
4859 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4860 let temp_dir = tempdir().unwrap();
4861 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4862 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4863
4864 assert!(
4865 result.is_empty(),
4866 "Only path-shaped values are destinations. Got: {result:?}"
4867 );
4868 }
4869}