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]
274 fn is_fragment_only_link(&self, url: &str) -> bool {
275 url.starts_with('#')
276 }
277
278 #[inline]
281 fn is_absolute_path(url: &str) -> bool {
282 url.starts_with('/')
283 }
284
285 fn url_decode(path: &str) -> String {
289 if !path.contains('%') {
291 return path.to_string();
292 }
293
294 let bytes = path.as_bytes();
295 let mut result = Vec::with_capacity(bytes.len());
296 let mut i = 0;
297
298 while i < bytes.len() {
299 if bytes[i] == b'%' && i + 2 < bytes.len() {
300 let hex1 = bytes[i + 1];
302 let hex2 = bytes[i + 2];
303 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
304 result.push(d1 * 16 + d2);
305 i += 3;
306 continue;
307 }
308 }
309 result.push(bytes[i]);
310 i += 1;
311 }
312
313 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
315 }
316
317 fn strip_query_and_fragment(url: &str) -> &str {
325 let query_pos = url.find('?');
328 let fragment_pos = url.find('#');
329
330 match (query_pos, fragment_pos) {
331 (Some(q), Some(f)) => {
332 &url[..q.min(f)]
334 }
335 (Some(q), None) => &url[..q],
336 (None, Some(f)) => &url[..f],
337 (None, None) => url,
338 }
339 }
340
341 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
343 base_path.join(link)
344 }
345
346 fn compute_search_paths(
351 &self,
352 flavor: crate::config::MarkdownFlavor,
353 source_file: Option<&Path>,
354 base_path: &Path,
355 project_root: &Path,
356 ) -> Vec<PathBuf> {
357 let mut paths = Vec::new();
358
359 if flavor == crate::config::MarkdownFlavor::Obsidian
361 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
362 && attachment_dir != *base_path
363 {
364 paths.push(attachment_dir);
365 }
366
367 for search_path in &self.config.search_paths {
371 let resolved = Self::resolve_against_project_root(search_path, project_root);
372 if resolved != *base_path && !paths.contains(&resolved) {
373 paths.push(resolved);
374 }
375 }
376
377 paths
378 }
379
380 fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
385 if !ctx.links().is_empty() {
386 let lines = ctx.raw_lines();
387 let mut processed_lines = HashSet::new();
388
389 for link in ctx.links() {
390 let line_index = link.line - 1;
391 if line_index >= lines.len()
392 || ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block)
393 || !processed_lines.insert(line_index)
394 {
395 continue;
396 }
397 let line = lines[line_index];
398 if !line.contains("](") {
399 continue;
400 }
401
402 let line_start_byte = ctx.line_start_byte(link.line).unwrap_or(0);
403 for link_match in LINK_START_REGEX.find_iter(line) {
404 if link_match.as_str().starts_with('!') {
405 let escapes = line[..link_match.start()]
406 .bytes()
407 .rev()
408 .take_while(|&byte| byte == b'\\')
409 .count();
410 if escapes % 2 == 0 {
411 continue;
412 }
413 }
414
415 let absolute_start = line_start_byte + link_match.start();
416 if ctx.is_in_code_span_byte(absolute_start)
417 || ctx.is_in_math_span(absolute_start)
418 || ctx.is_in_shortcode(absolute_start)
419 {
420 continue;
421 }
422 let expected_start = link_match.end() - 1;
423 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, expected_start)
424 .and_then(|caps| caps.get(1).map(|url| (caps, url)))
425 .or_else(|| {
426 extract_url_at(&URL_EXTRACT_REGEX, line, expected_start)
427 .and_then(|caps| caps.get(1).map(|url| (caps, url)))
428 });
429 let Some((_, url_match)) = caps_and_url else {
430 continue;
431 };
432 let url = url_match.as_str().trim();
433 if url.is_empty()
434 || (url.starts_with('`') && url.ends_with('`'))
435 || self.is_external_url(url)
436 || self.is_fragment_only_link(url)
437 {
438 continue;
439 }
440 index.add_md057_link_target(Md057LinkTarget {
441 target: url.to_string(),
442 origin: LinkOrigin::Body,
443 });
444 }
445 }
446 }
447
448 for image in ctx.images() {
449 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block)
450 || matches!(image.link_type, LinkType::WikiLink { .. })
451 || ctx.is_in_shortcode(image.byte_offset)
452 {
453 continue;
454 }
455 let url = image.url.as_ref();
456 if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
457 continue;
458 }
459 index.add_md057_link_target(Md057LinkTarget {
460 target: url.to_string(),
461 origin: LinkOrigin::Body,
462 });
463 }
464
465 for reference in ctx.reference_definitions() {
466 let url = reference.url.as_str();
467 if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
468 continue;
469 }
470 index.add_md057_link_target(Md057LinkTarget {
471 target: url.to_string(),
472 origin: LinkOrigin::Body,
473 });
474 }
475
476 for link in frontmatter_values::link_destinations(ctx) {
477 let line = ctx.lines[link.line - 1].content(ctx.content);
478 let url = &line[link.range];
479 if self.is_external_url(url) || self.is_fragment_only_link(url) {
480 continue;
481 }
482 index.add_md057_link_target(Md057LinkTarget {
483 target: url.to_string(),
484 origin: LinkOrigin::FrontMatter { field: link.field },
485 });
486 }
487 }
488
489 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
491 search_paths.iter().any(|dir| {
492 let candidate = dir.join(decoded_path);
493 file_exists_or_markdown_extension(&candidate)
494 })
495 }
496
497 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
503 if !self.config.compact_paths {
504 return None;
505 }
506
507 let path_end = url
509 .find('?')
510 .unwrap_or(url.len())
511 .min(url.find('#').unwrap_or(url.len()));
512 let path_part = &url[..path_end];
513 let suffix = &url[path_end..];
514
515 let decoded_path = Self::url_decode(path_part);
517
518 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
519 }
520
521 fn self_referential_link(
533 &self,
534 url: &str,
535 base_path: &Path,
536 search_paths: &[PathBuf],
537 source_file: Option<&Path>,
538 ) -> Option<SelfReferentialLink> {
539 if !self.config.self_referential_links {
540 return None;
541 }
542 let source_file = source_file?;
543
544 let path_part = Self::strip_query_and_fragment(url);
545 if path_part.is_empty() {
546 return None;
547 }
548 let suffix = &url[path_part.len()..];
549
550 let decoded_path = Self::url_decode(path_part);
551 let resolved = std::iter::once(base_path)
555 .chain(search_paths.iter().map(PathBuf::as_path))
556 .find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
557 if !Self::is_same_file(&resolved, source_file) {
558 return None;
559 }
560
561 match suffix.strip_prefix('#') {
565 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
566 _ => Some(SelfReferentialLink::WholeFile),
567 }
568 }
569
570 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
577 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
578 let label_end = Self::label_end(def)?;
579 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
580 title.saturating_sub(ref_def.byte_offset).min(def.len())
581 });
582 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
583 let start = ref_def.byte_offset + offset;
584 Some(start..start + ref_def.url.len())
585 }
586
587 fn label_end(def: &str) -> Option<usize> {
592 let bytes = def.as_bytes();
593 let mut i = 0;
594 while i < bytes.len() {
595 match bytes[i] {
596 b'\\' => i += 2,
597 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
598 _ => i += 1,
599 }
600 }
601 None
602 }
603
604 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
608 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
609 }
610
611 fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
614 match self.config.absolute_links {
615 AbsoluteLinksOption::Ignore => None,
616 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
617 AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
618 AbsoluteLinksOption::RelativeToRoots => {
619 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
620 }
621 }
622 }
623
624 fn check_front_matter(
631 &self,
632 ctx: &crate::lint_context::LintContext,
633 base_path: &Path,
634 search_paths: &[PathBuf],
635 project_root: &Path,
636 warnings: &mut Vec<LintWarning>,
637 ) {
638 if !self.config.check_frontmatter {
639 return;
640 }
641
642 let ignored: HashSet<String> = self
643 .config
644 .ignore_frontmatter_fields
645 .iter()
646 .map(|field| field.to_lowercase())
647 .collect();
648
649 for link in frontmatter_values::link_destinations(ctx) {
650 if link.field_is_in(&ignored) {
651 continue;
652 }
653
654 let line = ctx.lines[link.line - 1].content(ctx.content);
655 let url = &line[link.range.clone()];
656
657 if self.is_external_url(url) || self.is_fragment_only_link(url) {
660 continue;
661 }
662
663 let column = byte_to_char_count(line, link.range.start);
664 let end_column = column + url.chars().count();
665
666 if Self::is_absolute_path(url) {
667 if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
668 warnings.push(LintWarning {
669 rule_name: Some(self.name().to_string()),
670 line: link.line,
671 column,
672 end_line: link.line,
673 end_column,
674 message,
675 severity: Severity::Warning,
676 fix: None,
677 });
678 }
679 continue;
680 }
681
682 if Self::relative_target_exists(url, base_path, search_paths) {
683 continue;
684 }
685
686 warnings.push(LintWarning {
687 rule_name: Some(self.name().to_string()),
688 line: link.line,
689 column,
690 end_line: link.line,
691 end_column,
692 message: format!("Relative link '{url}' does not exist"),
693 severity: Severity::Error,
694 fix: None,
695 });
696 }
697 }
698
699 fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
706 let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
707 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
708
709 if file_exists_or_markdown_extension(&resolved_path) {
711 return true;
712 }
713
714 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
715 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
716 && let (Some(stem), Some(parent)) = (
717 resolved_path.file_stem().and_then(|s| s.to_str()),
718 resolved_path.parent(),
719 )
720 && MARKDOWN_EXTENSIONS
721 .iter()
722 .any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
723 {
724 return true;
725 }
726
727 Self::exists_in_search_paths(&decoded_path, search_paths)
728 }
729
730 fn produces_fixes(&self) -> bool {
734 self.config.compact_paths || self.config.self_referential_links
735 }
736
737 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
739 match self_link {
740 SelfReferentialLink::Fragment(fragment) => {
741 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
742 }
743 SelfReferentialLink::WholeFile => {
744 format!("Relative link '{url}' points to the file it is in")
745 }
746 }
747 }
748
749 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
755 if resolved.file_name() != source_file.file_name() {
757 return false;
758 }
759 match (resolved.canonicalize(), source_file.canonicalize()) {
760 (Ok(link), Ok(source)) => link == source,
761 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
762 }
763 }
764
765 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
771 let Some(docs_dir) = resolve_docs_dir(source_path) else {
772 return Some(format!(
773 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
774 ));
775 };
776
777 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
778
779 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
782 Resolution::Found => None,
783 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
784 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
785 resolved.display()
786 )),
787 Resolution::NotFound { resolved } => Some(format!(
788 "Absolute link '{url}' resolves to '{}' which does not exist",
789 resolved.display()
790 )),
791 }
792 }
793
794 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
803 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
804
805 for root in roots {
806 let root_path = Self::resolve_against_project_root(root, project_root);
807 if matches!(
810 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
811 Resolution::Found
812 ) {
813 return None;
814 }
815 }
816
817 if matches!(
818 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
820 Resolution::Found
821 ) {
822 return None;
823 }
824
825 let msg = if roots.is_empty() {
826 format!("Absolute link '{url}' was not found under the project root")
827 } else {
828 format!("Absolute link '{url}' was not found under any configured root or the project root")
829 };
830 Some(msg)
831 }
832
833 fn prepare_absolute_url(url: &str) -> (String, bool) {
837 let relative_url = url.trim_start_matches('/');
838 let file_path = Self::strip_query_and_fragment(relative_url);
839 let decoded = Self::url_decode(file_path);
840 let is_directory_link = url.ends_with('/') || decoded.is_empty();
841 (decoded, is_directory_link)
842 }
843
844 fn resolve_under_root_with_opts(
866 root_path: &Path,
867 decoded: &str,
868 is_directory_link: bool,
869 require_index_for_dirs: bool,
870 ) -> Resolution {
871 let resolved = root_path.join(decoded);
872
873 let is_dir = resolved.is_dir();
874
875 if is_directory_link || (require_index_for_dirs && is_dir) {
880 let index_path = resolved.join("index.md");
881 if file_exists_with_cache(&index_path) {
882 return Resolution::Found;
883 }
884 if is_dir {
885 return Resolution::DirectoryWithoutIndex { resolved };
886 }
887 }
888
889 let decoded_has_trailing_slash = decoded.ends_with('/');
895 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
896 return Resolution::Found;
897 }
898
899 if file_exists_or_markdown_extension(&resolved) {
900 return Resolution::Found;
901 }
902
903 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
906 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
907 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
908 {
909 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
910 let source_path = parent.join(format!("{stem}{md_ext}"));
911 file_exists_with_cache(&source_path)
912 });
913 if has_md_source {
914 return Resolution::Found;
915 }
916 }
917
918 Resolution::NotFound { resolved }
919 }
920}
921
922#[cfg(feature = "blake3")]
926impl MD057ExistingRelativeLinks {
927 pub fn cache_dependency_fingerprint(
933 &self,
934 source_file: &Path,
935 flavor: crate::config::MarkdownFlavor,
936 file_index: &FileIndex,
937 ) -> String {
938 let mut hasher = blake3::Hasher::new();
939 hasher.update(b"rumdl-md057-dependencies-v1");
940 if file_index.md057_link_targets.is_empty() {
941 return hasher.finalize().to_hex().to_string();
942 }
943
944 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
945 let project_root = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
946 let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
947 let base_path = explicit_base.unwrap_or_else(|| {
948 resolved_source
949 .parent()
950 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
951 });
952 let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
953 let ignored_frontmatter_fields: HashSet<String> = self
954 .config
955 .ignore_frontmatter_fields
956 .iter()
957 .map(|field| field.to_lowercase())
958 .collect();
959
960 for dependency in &file_index.md057_link_targets {
961 if let LinkOrigin::FrontMatter { field } = &dependency.origin
962 && (!self.config.check_frontmatter
963 || field
964 .as_ref()
965 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
966 {
967 continue;
968 }
969
970 let url = dependency.target.as_str();
971 if self.is_external_url(url) || self.is_fragment_only_link(url) {
972 continue;
973 }
974
975 Self::hash_bytes(&mut hasher, url.as_bytes());
976 if Self::is_absolute_path(url) {
977 match self.config.absolute_links {
978 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
979 AbsoluteLinksOption::RelativeToDocs => {
980 hasher.update(b"docs");
981 if let Some(docs_dir) = resolve_docs_dir(source_file) {
982 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
983 } else {
984 hasher.update(b"no-docs-dir");
985 }
986 }
987 AbsoluteLinksOption::RelativeToRoots => {
988 hasher.update(b"roots");
989 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
990 let mut found = false;
991 for root in &self.config.roots {
992 let root_path = Self::resolve_against_project_root(root, &project_root);
993 if Self::observe_under_root(&mut hasher, &root_path, &decoded, is_directory_link, false) {
994 found = true;
995 break;
996 }
997 }
998 if !found {
999 Self::observe_under_root(&mut hasher, &project_root, &decoded, is_directory_link, false);
1000 }
1001 }
1002 }
1003 } else {
1004 hasher.update(b"relative");
1005 if self.config.self_referential_links
1006 && Self::observe_self_referential_resolution(
1007 &mut hasher,
1008 url,
1009 &base_path,
1010 &search_paths,
1011 &resolved_source,
1012 )
1013 {
1014 continue;
1015 }
1016 Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
1017 }
1018 }
1019
1020 hasher.finalize().to_hex().to_string()
1021 }
1022
1023 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1024 hasher.update(&(bytes.len() as u64).to_le_bytes());
1025 hasher.update(bytes);
1026 }
1027
1028 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1029 #[cfg(unix)]
1030 {
1031 use std::os::unix::ffi::OsStrExt;
1032 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1033 }
1034 #[cfg(windows)]
1035 {
1036 use std::os::windows::ffi::OsStrExt;
1037 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1038 Self::hash_bytes(hasher, &encoded);
1039 }
1040 #[cfg(not(any(unix, windows)))]
1041 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1042 }
1043
1044 fn observe_path(hasher: &mut blake3::Hasher, path: &Path) -> DependencyPathState {
1045 Self::hash_path(hasher, path);
1046 let state = match std::fs::metadata(path) {
1047 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1048 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1049 Ok(_) => DependencyPathState::Other,
1050 Err(_) => DependencyPathState::Missing,
1051 };
1052 hasher.update(&[match state {
1053 DependencyPathState::Missing => 0,
1054 DependencyPathState::File => 1,
1055 DependencyPathState::Directory => 2,
1056 DependencyPathState::Other => 3,
1057 }]);
1058 state
1059 }
1060
1061 fn observe_existing_target(hasher: &mut blake3::Hasher, path: &Path) -> Option<PathBuf> {
1062 if Self::observe_path(hasher, path) != DependencyPathState::Missing {
1063 return Some(path.to_path_buf());
1064 }
1065 if path.extension().is_none() {
1066 for extension in MARKDOWN_EXTENSIONS {
1067 let candidate = path.with_extension(&extension[1..]);
1068 if Self::observe_path(hasher, &candidate) != DependencyPathState::Missing {
1069 return Some(candidate);
1070 }
1071 }
1072 }
1073 None
1074 }
1075
1076 fn observe_self_referential_resolution(
1077 hasher: &mut blake3::Hasher,
1078 url: &str,
1079 base_path: &Path,
1080 search_paths: &[PathBuf],
1081 source_file: &Path,
1082 ) -> bool {
1083 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1084 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1085 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1086 if let Some(resolved) = Self::observe_existing_target(hasher, &candidate) {
1087 let canonical = resolved.canonicalize().unwrap_or(resolved);
1088 hasher.update(b"resolved-identity");
1089 Self::hash_path(hasher, &canonical);
1090 return Self::is_same_file(&canonical, source_file);
1091 }
1092 }
1093 false
1094 }
1095
1096 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1097 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1098 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1099 if Self::observe_existing_target(hasher, &resolved).is_some() {
1100 return;
1101 }
1102
1103 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1104 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1105 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1106 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1107 Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1108 })
1109 {
1110 return;
1111 }
1112
1113 for search_path in search_paths {
1114 if Self::observe_existing_target(hasher, &search_path.join(&decoded)).is_some() {
1115 return;
1116 }
1117 }
1118 }
1119
1120 fn observe_absolute_resolution(
1121 hasher: &mut blake3::Hasher,
1122 root: &Path,
1123 url: &str,
1124 require_index_for_dirs: bool,
1125 ) -> bool {
1126 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1127 Self::observe_under_root(hasher, root, &decoded, is_directory_link, require_index_for_dirs)
1128 }
1129
1130 fn observe_under_root(
1131 hasher: &mut blake3::Hasher,
1132 root: &Path,
1133 decoded: &str,
1134 is_directory_link: bool,
1135 require_index_for_dirs: bool,
1136 ) -> bool {
1137 let resolved = root.join(decoded);
1138 let resolved_state = Self::observe_path(hasher, &resolved);
1139 let is_dir = resolved_state == DependencyPathState::Directory;
1140
1141 if is_directory_link || (require_index_for_dirs && is_dir) {
1142 if Self::observe_path(hasher, &resolved.join("index.md")) != DependencyPathState::Missing {
1143 return true;
1144 }
1145 if is_dir {
1146 return false;
1147 }
1148 }
1149
1150 if !require_index_for_dirs && !is_directory_link && !decoded.ends_with('/') && is_dir {
1151 return true;
1152 }
1153 if resolved_state != DependencyPathState::Missing {
1154 return true;
1155 }
1156 if resolved.extension().is_none()
1157 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1158 Self::observe_path(hasher, &resolved.with_extension(&extension[1..])) != DependencyPathState::Missing
1159 })
1160 {
1161 return true;
1162 }
1163
1164 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1165 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1166 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1167 {
1168 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1169 Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1170 });
1171 }
1172
1173 false
1174 }
1175}
1176
1177enum Resolution {
1181 Found,
1182 DirectoryWithoutIndex { resolved: PathBuf },
1183 NotFound { resolved: PathBuf },
1184}
1185
1186fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1197 let caps = re.captures_at(line, expected_start)?;
1198 if caps.get(0)?.start() != expected_start {
1199 return None;
1200 }
1201 Some(caps)
1202}
1203
1204impl Rule for MD057ExistingRelativeLinks {
1205 fn name(&self) -> &'static str {
1206 "MD057"
1207 }
1208
1209 fn description(&self) -> &'static str {
1210 "Relative links should point to existing files"
1211 }
1212
1213 fn category(&self) -> RuleCategory {
1214 RuleCategory::Link
1215 }
1216
1217 fn skippable_by_category(&self) -> bool {
1218 !self.config.check_frontmatter
1221 }
1222
1223 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1224 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1225 }
1226
1227 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1228 let content = ctx.content;
1229
1230 if content.is_empty() {
1231 return Ok(Vec::new());
1232 }
1233
1234 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1238 if !has_body_links && !self.checks_front_matter_of(ctx) {
1239 return Ok(Vec::new());
1240 }
1241
1242 reset_file_existence_cache();
1244
1245 let mut warnings = Vec::new();
1246
1247 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1251
1252 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
1256
1257 let self_path: Option<PathBuf> = ctx
1260 .source_file()
1261 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
1262
1263 let base_path: Option<PathBuf> = {
1267 if explicit_base.is_some() {
1268 explicit_base
1269 } else if let Some(ref resolved_file) = self_path {
1270 resolved_file
1274 .parent()
1275 .map(std::path::Path::to_path_buf)
1276 .or_else(|| Some(CURRENT_DIR.clone()))
1277 } else {
1278 None
1280 }
1281 };
1282
1283 let Some(base_path) = base_path else {
1285 return Ok(warnings);
1286 };
1287
1288 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
1290
1291 if !ctx.links().is_empty() {
1293 let lines = ctx.raw_lines();
1297
1298 let mut processed_lines = std::collections::HashSet::new();
1301
1302 for link in ctx.links() {
1303 let line_idx = link.line - 1;
1304 if line_idx >= lines.len() {
1305 continue;
1306 }
1307
1308 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1310 continue;
1311 }
1312
1313 if !processed_lines.insert(line_idx) {
1315 continue;
1316 }
1317
1318 let line = lines[line_idx];
1319
1320 if !line.contains("](") {
1322 continue;
1323 }
1324
1325 for link_match in LINK_START_REGEX.find_iter(line) {
1327 if link_match.as_str().starts_with('!') {
1334 let escapes = line[..link_match.start()]
1335 .bytes()
1336 .rev()
1337 .take_while(|&b| b == b'\\')
1338 .count();
1339 if escapes % 2 == 0 {
1340 continue;
1341 }
1342 }
1343
1344 let start_pos = link_match.start();
1345 let end_pos = link_match.end();
1346
1347 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
1349 let absolute_start_pos = line_start_byte + start_pos;
1350
1351 if ctx.is_in_code_span_byte(absolute_start_pos) {
1353 continue;
1354 }
1355
1356 if ctx.is_in_math_span(absolute_start_pos) {
1358 continue;
1359 }
1360
1361 if ctx.is_in_shortcode(absolute_start_pos) {
1366 continue;
1367 }
1368
1369 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1376 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1377 .or_else(|| {
1378 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1379 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1380 });
1381
1382 if let Some((caps, url_group)) = caps_and_url {
1383 let url = url_group.as_str().trim();
1384
1385 if url.is_empty() {
1387 continue;
1388 }
1389
1390 if url.starts_with('`') && url.ends_with('`') {
1394 continue;
1395 }
1396
1397 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1399 continue;
1400 }
1401
1402 if Self::is_absolute_path(url) {
1404 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1405 warnings.push(LintWarning {
1406 rule_name: Some(self.name().to_string()),
1407 line: link.line,
1408 column: byte_to_char_count(line, url_group.start()),
1409 end_line: link.line,
1410 end_column: byte_to_char_count(line, url_group.end()),
1411 message,
1412 severity: Severity::Warning,
1413 fix: None,
1414 });
1415 }
1416 continue;
1417 }
1418
1419 let full_url_for_compact = if let Some(frag) = caps.get(2) {
1423 format!("{url}{}", frag.as_str())
1424 } else {
1425 url.to_string()
1426 };
1427 if let Some(self_link) = self.self_referential_link(
1432 &full_url_for_compact,
1433 &base_path,
1434 &extra_search_paths,
1435 self_path.as_deref(),
1436 ) {
1437 let url_start = url_group.start();
1438 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1439 let fix_byte_start = line_start_byte + url_start;
1440 let fix_byte_end = line_start_byte + url_end;
1441 warnings.push(LintWarning {
1442 rule_name: Some(self.name().to_string()),
1443 line: link.line,
1444 column: byte_to_char_count(line, url_start),
1445 end_line: link.line,
1446 end_column: byte_to_char_count(line, url_end),
1447 message: Self::self_referential_message(&full_url_for_compact, &self_link),
1448 severity: Severity::Warning,
1449 fix: match &self_link {
1450 SelfReferentialLink::Fragment(fragment) => {
1451 Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1452 }
1453 SelfReferentialLink::WholeFile => None,
1454 },
1455 });
1456 continue;
1457 }
1458
1459 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1460 let url_start = url_group.start();
1461 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1462 let fix_byte_start = line_start_byte + url_start;
1463 let fix_byte_end = line_start_byte + url_end;
1464 warnings.push(LintWarning {
1465 rule_name: Some(self.name().to_string()),
1466 line: link.line,
1467 column: byte_to_char_count(line, url_start),
1468 end_line: link.line,
1469 end_column: byte_to_char_count(line, url_end),
1470 message: format!(
1471 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1472 ),
1473 severity: Severity::Warning,
1474 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1475 });
1476 }
1477
1478 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1479 continue;
1480 }
1481
1482 let url_start = url_group.start();
1486 let url_end = url_group.end();
1487
1488 warnings.push(LintWarning {
1489 rule_name: Some(self.name().to_string()),
1490 line: link.line,
1491 column: byte_to_char_count(line, url_start),
1492 end_line: link.line,
1493 end_column: byte_to_char_count(line, url_end),
1494 message: format!("Relative link '{url}' does not exist"),
1495 severity: Severity::Error,
1496 fix: None,
1497 });
1498 }
1499 }
1500 }
1501 }
1502
1503 for image in ctx.images() {
1505 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1507 continue;
1508 }
1509
1510 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1514 continue;
1515 }
1516
1517 if ctx.is_in_shortcode(image.byte_offset) {
1520 continue;
1521 }
1522
1523 let url = image.url.as_ref();
1524
1525 if url.is_empty() {
1527 continue;
1528 }
1529
1530 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1532 continue;
1533 }
1534
1535 if Self::is_absolute_path(url) {
1537 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1538 warnings.push(LintWarning {
1539 rule_name: Some(self.name().to_string()),
1540 line: image.line,
1541 column: image.start_col + 1,
1542 end_line: image.line,
1543 end_column: image.start_col + 1 + url.chars().count(),
1544 message,
1545 severity: Severity::Warning,
1546 fix: None,
1547 });
1548 }
1549 continue;
1550 }
1551
1552 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1554 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1557 let fix_byte_start = image.byte_offset + url_offset;
1558 let fix_byte_end = fix_byte_start + url.len();
1559 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1560 });
1561
1562 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1563 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1564 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1567 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1568 });
1569 warnings.push(LintWarning {
1570 rule_name: Some(self.name().to_string()),
1571 line: image.line,
1572 column: url_col,
1573 end_line: image.line,
1574 end_column: url_col + url.chars().count(),
1575 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1576 severity: Severity::Warning,
1577 fix,
1578 });
1579 }
1580
1581 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1582 continue;
1583 }
1584
1585 warnings.push(LintWarning {
1588 rule_name: Some(self.name().to_string()),
1589 line: image.line,
1590 column: image.start_col + 1,
1591 end_line: image.line,
1592 end_column: image.start_col + 1 + url.chars().count(),
1593 message: format!("Relative link '{url}' does not exist"),
1594 severity: Severity::Error,
1595 fix: None,
1596 });
1597 }
1598
1599 for ref_def in ctx.reference_definitions() {
1601 let url = &ref_def.url;
1602
1603 if url.is_empty() {
1605 continue;
1606 }
1607
1608 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1610 continue;
1611 }
1612
1613 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1617 let (line, col) = url_range
1618 .as_ref()
1619 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1620 let end_col = col + url.chars().count();
1621
1622 if Self::is_absolute_path(url) {
1624 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1625 warnings.push(LintWarning {
1626 rule_name: Some(self.name().to_string()),
1627 line,
1628 column: col,
1629 end_line: line,
1630 end_column: end_col,
1631 message,
1632 severity: Severity::Warning,
1633 fix: None,
1634 });
1635 }
1636 continue;
1637 }
1638
1639 if let Some(self_link) =
1641 self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
1642 {
1643 warnings.push(LintWarning {
1644 rule_name: Some(self.name().to_string()),
1645 line,
1646 column: col,
1647 end_line: line,
1648 end_column: end_col,
1649 message: Self::self_referential_message(url, &self_link),
1650 severity: Severity::Warning,
1651 fix: match (&self_link, &url_range) {
1652 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1653 Some(Fix::new(range.clone(), fragment.clone()))
1654 }
1655 _ => None,
1656 },
1657 });
1658 continue;
1659 }
1660
1661 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1663 warnings.push(LintWarning {
1664 rule_name: Some(self.name().to_string()),
1665 line,
1666 column: col,
1667 end_line: line,
1668 end_column: end_col,
1669 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1670 severity: Severity::Warning,
1671 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1672 });
1673 }
1674
1675 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1676 continue;
1677 }
1678
1679 warnings.push(LintWarning {
1681 rule_name: Some(self.name().to_string()),
1682 line,
1683 column: col,
1684 end_line: line,
1685 end_column: end_col,
1686 message: format!("Relative link '{url}' does not exist"),
1687 severity: Severity::Error,
1688 fix: None,
1689 });
1690 }
1691
1692 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1693
1694 Ok(warnings)
1695 }
1696
1697 fn fix_capability(&self) -> FixCapability {
1698 if self.produces_fixes() {
1699 FixCapability::ConditionallyFixable
1700 } else {
1701 FixCapability::Unfixable
1702 }
1703 }
1704
1705 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1706 if !self.produces_fixes() {
1707 return Ok(ctx.content.to_string());
1708 }
1709
1710 let warnings = self.check(ctx)?;
1711 let warnings =
1712 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1713 let mut content = ctx.content.to_string();
1714
1715 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1717 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1718
1719 let mut last_applied_start: Option<usize> = None;
1725 for fix in fixes {
1726 if let Some(prev_start) = last_applied_start
1727 && fix.range.end > prev_start
1728 {
1729 continue;
1730 }
1731 if fix.range.end <= content.len() {
1732 content.replace_range(fix.range.clone(), &fix.replacement);
1733 last_applied_start = Some(fix.range.start);
1734 }
1735 }
1736
1737 Ok(content)
1738 }
1739
1740 fn as_any(&self) -> &dyn std::any::Any {
1741 self
1742 }
1743
1744 crate::impl_rule_config_sections!(MD057Config);
1745
1746 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1747 where
1748 Self: Sized,
1749 {
1750 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1751 Box::new(Self::from_config_struct(rule_config))
1755 }
1756
1757 fn cross_file_scope(&self) -> CrossFileScope {
1758 CrossFileScope::Workspace
1759 }
1760
1761 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1762 self.contribute_dependency_targets(ctx, index);
1763
1764 let links = extract_cross_file_links(ctx);
1767 for link in links.relative {
1768 index.add_cross_file_link(link);
1769 }
1770 for link in links.root_relative {
1773 index.add_root_relative_link(link);
1774 }
1775 }
1776
1777 fn cross_file_check(
1778 &self,
1779 _file_path: &Path,
1780 _file_index: &FileIndex,
1781 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1782 ) -> LintResult {
1783 Ok(Vec::new())
1793 }
1794}
1795
1796fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1801 let from_components: Vec<_> = from_dir.components().collect();
1802 let to_components: Vec<_> = to_path.components().collect();
1803
1804 let common_len = from_components
1806 .iter()
1807 .zip(to_components.iter())
1808 .take_while(|(a, b)| a == b)
1809 .count();
1810
1811 let mut result = PathBuf::new();
1812
1813 for _ in common_len..from_components.len() {
1815 result.push("..");
1816 }
1817
1818 for component in &to_components[common_len..] {
1820 result.push(component);
1821 }
1822
1823 result
1824}
1825
1826fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1832 let link_path = Path::new(raw_link_path);
1833
1834 let has_traversal = link_path
1836 .components()
1837 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1838
1839 if !has_traversal {
1840 return None;
1841 }
1842
1843 let combined = source_dir.join(link_path);
1845 let normalized_target = normalize_relative_path(&combined);
1846
1847 let normalized_source = normalize_relative_path(source_dir);
1849 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1850
1851 if shortest != link_path {
1853 let compact = shortest.to_string_lossy().to_string();
1854 if compact.is_empty() {
1856 return None;
1857 }
1858 Some(compact.replace('\\', "/"))
1860 } else {
1861 None
1862 }
1863}
1864
1865#[cfg(test)]
1866mod tests {
1867 use super::*;
1868 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1869 use std::fs::File;
1870 use std::io::Write;
1871 use tempfile::tempdir;
1872
1873 #[test]
1874 fn test_strip_query_and_fragment() {
1875 assert_eq!(
1877 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1878 "file.png"
1879 );
1880 assert_eq!(
1881 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1882 "file.png"
1883 );
1884 assert_eq!(
1885 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1886 "file.png"
1887 );
1888
1889 assert_eq!(
1891 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1892 "file.md"
1893 );
1894 assert_eq!(
1895 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1896 "file.md"
1897 );
1898
1899 assert_eq!(
1901 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1902 "file.md"
1903 );
1904
1905 assert_eq!(
1907 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1908 "file.png"
1909 );
1910
1911 assert_eq!(
1913 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1914 "path/to/image.png"
1915 );
1916 assert_eq!(
1917 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1918 "path/to/image.png"
1919 );
1920
1921 assert_eq!(
1923 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1924 "file.md"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_url_decode() {
1930 assert_eq!(
1932 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1933 "penguin with space.jpg"
1934 );
1935
1936 assert_eq!(
1938 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1939 "assets/my file name.png"
1940 );
1941
1942 assert_eq!(
1944 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1945 "hello world!.md"
1946 );
1947
1948 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1950
1951 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1953
1954 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1956
1957 assert_eq!(
1959 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1960 "normal-file.md"
1961 );
1962
1963 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1965
1966 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1968
1969 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1971
1972 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1974
1975 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1977
1978 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1980
1981 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1983
1984 assert_eq!(
1986 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1987 "path/to/file.md"
1988 );
1989
1990 assert_eq!(
1992 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1993 "hello world/foo bar.md"
1994 );
1995
1996 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1998
1999 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
2001 }
2002
2003 #[test]
2004 fn test_url_encoded_filenames() {
2005 let temp_dir = tempdir().unwrap();
2007 let base_path = temp_dir.path();
2008
2009 let file_with_spaces = base_path.join("penguin with space.jpg");
2011 File::create(&file_with_spaces)
2012 .unwrap()
2013 .write_all(b"image data")
2014 .unwrap();
2015
2016 let subdir = base_path.join("my images");
2018 std::fs::create_dir(&subdir).unwrap();
2019 let nested_file = subdir.join("photo 1.png");
2020 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2021
2022 let content = r#"
2024# Test Document with URL-Encoded Links
2025
2026
2027
2028
2029"#;
2030
2031 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2032
2033 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let result = rule.check(&ctx).unwrap();
2035
2036 assert_eq!(
2038 result.len(),
2039 1,
2040 "Should only warn about missing%20file.jpg. Got: {result:?}"
2041 );
2042 assert!(
2043 result[0].message.contains("missing%20file.jpg"),
2044 "Warning should mention the URL-encoded filename"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_external_urls() {
2050 let rule = MD057ExistingRelativeLinks::new();
2051
2052 assert!(rule.is_external_url("https://example.com"));
2054 assert!(rule.is_external_url("http://example.com"));
2055 assert!(rule.is_external_url("ftp://example.com"));
2056 assert!(rule.is_external_url("www.example.com"));
2057 assert!(rule.is_external_url("example.com"));
2058
2059 assert!(rule.is_external_url("file:///path/to/file"));
2061 assert!(rule.is_external_url("smb://server/share"));
2062 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
2063 assert!(rule.is_external_url("mailto:user@example.com"));
2064 assert!(rule.is_external_url("tel:+1234567890"));
2065 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
2066 assert!(rule.is_external_url("javascript:void(0)"));
2067 assert!(rule.is_external_url("ssh://git@github.com/repo"));
2068 assert!(rule.is_external_url("git://github.com/repo.git"));
2069
2070 assert!(rule.is_external_url("user@example.com"));
2073 assert!(rule.is_external_url("steering@kubernetes.io"));
2074 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
2075 assert!(rule.is_external_url("user_name@sub.domain.com"));
2076 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2077
2078 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"));
2089 assert!(!rule.is_external_url("/blog/2024/release.html"));
2090 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
2091 assert!(!rule.is_external_url("/pkg/runtime"));
2092 assert!(!rule.is_external_url("/doc/go1compat"));
2093 assert!(!rule.is_external_url("/index.html"));
2094 assert!(!rule.is_external_url("/assets/logo.png"));
2095
2096 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2098 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2099 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2100 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2101 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2102
2103 assert!(rule.is_external_url("~/assets/image.png"));
2106 assert!(rule.is_external_url("~/components/Button.vue"));
2107 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
2111 assert!(rule.is_external_url("@images/photo.jpg"));
2112 assert!(rule.is_external_url("@assets/styles.css"));
2113
2114 assert!(!rule.is_external_url("./relative/path.md"));
2116 assert!(!rule.is_external_url("relative/path.md"));
2117 assert!(!rule.is_external_url("../parent/path.md"));
2118 }
2119
2120 #[test]
2121 fn test_dot_com_only_skips_bare_domains() {
2122 let rule = MD057ExistingRelativeLinks::new();
2123
2124 assert!(rule.is_external_url("example.com"));
2126 assert!(rule.is_external_url("sub.example.com"));
2127
2128 assert!(!rule.is_external_url("../../vendor.com"));
2132 assert!(!rule.is_external_url("./vendor.com"));
2133 assert!(!rule.is_external_url("docs/vendor.com"));
2134 }
2135
2136 #[test]
2137 fn test_framework_path_aliases() {
2138 let temp_dir = tempdir().unwrap();
2140 let base_path = temp_dir.path();
2141
2142 let content = r#"
2144# Framework Path Aliases
2145
2146
2147
2148
2149
2150[Link](@/pages/about.md)
2151
2152This is a [real missing link](missing.md) that should be flagged.
2153"#;
2154
2155 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2156
2157 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158 let result = rule.check(&ctx).unwrap();
2159
2160 assert_eq!(
2162 result.len(),
2163 1,
2164 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2165 );
2166 assert!(
2167 result[0].message.contains("missing.md"),
2168 "Warning should be for missing.md"
2169 );
2170 }
2171
2172 #[test]
2173 fn test_url_decode_security_path_traversal() {
2174 let temp_dir = tempdir().unwrap();
2177 let base_path = temp_dir.path();
2178
2179 let file_in_base = base_path.join("safe.md");
2181 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2182
2183 let content = r#"
2188[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2189[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2190[Safe link](safe.md)
2191"#;
2192
2193 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2194
2195 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2196 let result = rule.check(&ctx).unwrap();
2197
2198 assert_eq!(
2201 result.len(),
2202 2,
2203 "Should have warnings for traversal attempts. Got: {result:?}"
2204 );
2205 }
2206
2207 #[test]
2208 fn test_url_encoded_utf8_filenames() {
2209 let temp_dir = tempdir().unwrap();
2211 let base_path = temp_dir.path();
2212
2213 let cafe_file = base_path.join("café.md");
2215 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2216
2217 let content = r#"
2218[Café link](caf%C3%A9.md)
2219[Missing unicode](r%C3%A9sum%C3%A9.md)
2220"#;
2221
2222 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2223
2224 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2225 let result = rule.check(&ctx).unwrap();
2226
2227 assert_eq!(
2229 result.len(),
2230 1,
2231 "Should only warn about missing résumé.md. Got: {result:?}"
2232 );
2233 assert!(
2234 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2235 "Warning should mention the URL-encoded filename"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_url_encoded_emoji_filenames() {
2241 let temp_dir = tempdir().unwrap();
2244 let base_path = temp_dir.path();
2245
2246 let emoji_dir = base_path.join("👤 Personal");
2248 std::fs::create_dir(&emoji_dir).unwrap();
2249
2250 let file_path = emoji_dir.join("TV Shows.md");
2252 File::create(&file_path)
2253 .unwrap()
2254 .write_all(b"# TV Shows\n\nContent here.")
2255 .unwrap();
2256
2257 let content = r#"
2260# Test Document
2261
2262[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2263[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2264"#;
2265
2266 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2267
2268 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269 let result = rule.check(&ctx).unwrap();
2270
2271 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2273 assert!(
2274 result[0].message.contains("Missing.md"),
2275 "Warning should be for Missing.md, got: {}",
2276 result[0].message
2277 );
2278 }
2279
2280 #[test]
2281 fn test_no_warnings_without_base_path() {
2282 let rule = MD057ExistingRelativeLinks::new();
2283 let content = "[Link](missing.md)";
2284
2285 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286 let result = rule.check(&ctx).unwrap();
2287 assert!(result.is_empty(), "Should have no warnings without base path");
2288 }
2289
2290 #[test]
2291 fn test_existing_and_missing_links() {
2292 let temp_dir = tempdir().unwrap();
2294 let base_path = temp_dir.path();
2295
2296 let exists_path = base_path.join("exists.md");
2298 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2299
2300 assert!(exists_path.exists(), "exists.md should exist for this test");
2302
2303 let content = r#"
2305# Test Document
2306
2307[Valid Link](exists.md)
2308[Invalid Link](missing.md)
2309[External Link](https://example.com)
2310[Media Link](image.jpg)
2311 "#;
2312
2313 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2315
2316 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2318 let result = rule.check(&ctx).unwrap();
2319
2320 assert_eq!(result.len(), 2);
2322 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2323 assert!(messages.iter().any(|m| m.contains("missing.md")));
2324 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2325 }
2326
2327 #[test]
2328 fn test_angle_bracket_links() {
2329 let temp_dir = tempdir().unwrap();
2331 let base_path = temp_dir.path();
2332
2333 let exists_path = base_path.join("exists.md");
2335 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2336
2337 let content = r#"
2339# Test Document
2340
2341[Valid Link](<exists.md>)
2342[Invalid Link](<missing.md>)
2343[External Link](<https://example.com>)
2344 "#;
2345
2346 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2348
2349 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350 let result = rule.check(&ctx).unwrap();
2351
2352 assert_eq!(result.len(), 1, "Should have exactly one warning");
2354 assert!(
2355 result[0].message.contains("missing.md"),
2356 "Warning should mention missing.md"
2357 );
2358 }
2359
2360 #[test]
2361 fn test_angle_bracket_links_with_parens() {
2362 let temp_dir = tempdir().unwrap();
2364 let base_path = temp_dir.path();
2365
2366 let app_dir = base_path.join("app");
2368 std::fs::create_dir(&app_dir).unwrap();
2369 let upload_dir = app_dir.join("(upload)");
2370 std::fs::create_dir(&upload_dir).unwrap();
2371 let page_file = upload_dir.join("page.tsx");
2372 File::create(&page_file)
2373 .unwrap()
2374 .write_all(b"export default function Page() {}")
2375 .unwrap();
2376
2377 let content = r#"
2379# Test Document with Paths Containing Parens
2380
2381[Upload Page](<app/(upload)/page.tsx>)
2382[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2383[Missing](<app/(missing)/file.md>)
2384"#;
2385
2386 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2387
2388 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389 let result = rule.check(&ctx).unwrap();
2390
2391 assert_eq!(
2393 result.len(),
2394 1,
2395 "Should have exactly one warning for missing file. Got: {result:?}"
2396 );
2397 assert!(
2398 result[0].message.contains("app/(missing)/file.md"),
2399 "Warning should mention app/(missing)/file.md"
2400 );
2401 }
2402
2403 #[test]
2404 fn test_all_file_types_checked() {
2405 let temp_dir = tempdir().unwrap();
2407 let base_path = temp_dir.path();
2408
2409 let content = r#"
2411[Image Link](image.jpg)
2412[Video Link](video.mp4)
2413[Markdown Link](document.md)
2414[PDF Link](file.pdf)
2415"#;
2416
2417 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2418
2419 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420 let result = rule.check(&ctx).unwrap();
2421
2422 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2424 }
2425
2426 #[test]
2427 fn test_code_span_detection() {
2428 let rule = MD057ExistingRelativeLinks::new();
2429
2430 let temp_dir = tempdir().unwrap();
2432 let base_path = temp_dir.path();
2433
2434 let rule = rule.with_path(base_path);
2435
2436 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2438
2439 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2440 let result = rule.check(&ctx).unwrap();
2441
2442 assert_eq!(result.len(), 1, "Should only flag the real link");
2444 assert!(result[0].message.contains("nonexistent.md"));
2445 }
2446
2447 #[test]
2448 fn test_inline_code_spans() {
2449 let temp_dir = tempdir().unwrap();
2451 let base_path = temp_dir.path();
2452
2453 let content = r#"
2455# Test Document
2456
2457This is a normal link: [Link](missing.md)
2458
2459This is a code span with a link: `[Link](another-missing.md)`
2460
2461Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2462
2463 "#;
2464
2465 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2467
2468 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2470 let result = rule.check(&ctx).unwrap();
2471
2472 assert_eq!(result.len(), 1, "Should have exactly one warning");
2474 assert!(
2475 result[0].message.contains("missing.md"),
2476 "Warning should be for missing.md"
2477 );
2478 assert!(
2479 !result.iter().any(|w| w.message.contains("another-missing.md")),
2480 "Should not warn about link in code span"
2481 );
2482 assert!(
2483 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2484 "Should not warn about link in inline code"
2485 );
2486 }
2487
2488 #[test]
2489 fn test_extensionless_link_resolution() {
2490 let temp_dir = tempdir().unwrap();
2492 let base_path = temp_dir.path();
2493
2494 let page_path = base_path.join("page.md");
2496 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2497
2498 let content = r#"
2500# Test Document
2501
2502[Link without extension](page)
2503[Link with extension](page.md)
2504[Missing link](nonexistent)
2505"#;
2506
2507 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2508
2509 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2510 let result = rule.check(&ctx).unwrap();
2511
2512 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2515 assert!(
2516 result[0].message.contains("nonexistent"),
2517 "Warning should be for 'nonexistent' not 'page'"
2518 );
2519 }
2520
2521 #[test]
2523 fn test_cross_file_scope() {
2524 let rule = MD057ExistingRelativeLinks::new();
2525 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2526 }
2527
2528 #[test]
2529 fn test_contribute_to_index_extracts_markdown_links() {
2530 let rule = MD057ExistingRelativeLinks::new();
2531 let content = r#"
2532# Document
2533
2534[Link to docs](./docs/guide.md)
2535[Link with fragment](./other.md#section)
2536[External link](https://example.com)
2537[Image link](image.png)
2538[Media file](video.mp4)
2539"#;
2540
2541 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2542 let mut index = FileIndex::new();
2543 rule.contribute_to_index(&ctx, &mut index);
2544
2545 assert_eq!(index.cross_file_links.len(), 2);
2547
2548 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2550 assert_eq!(index.cross_file_links[0].fragment, "");
2551
2552 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2554 assert_eq!(index.cross_file_links[1].fragment, "section");
2555 }
2556
2557 #[test]
2558 fn test_contribute_to_index_skips_external_and_anchors() {
2559 let rule = MD057ExistingRelativeLinks::new();
2560 let content = r#"
2561# Document
2562
2563[External](https://example.com)
2564[Another external](http://example.org)
2565[Fragment only](#section)
2566[FTP link](ftp://files.example.com)
2567[Mail link](mailto:test@example.com)
2568[WWW link](www.example.com)
2569"#;
2570
2571 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2572 let mut index = FileIndex::new();
2573 rule.contribute_to_index(&ctx, &mut index);
2574
2575 assert_eq!(index.cross_file_links.len(), 0);
2577 }
2578
2579 #[test]
2580 fn test_cross_file_check_valid_link() {
2581 use crate::workspace_index::WorkspaceIndex;
2582
2583 let rule = MD057ExistingRelativeLinks::new();
2584
2585 let mut workspace_index = WorkspaceIndex::new();
2587 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2588
2589 let mut file_index = FileIndex::new();
2591 file_index.add_cross_file_link(CrossFileLinkIndex {
2592 target_path: "guide.md".to_string(),
2593 fragment: "".to_string(),
2594 line: 5,
2595 column: 1,
2596 origin: LinkOrigin::Body,
2597 });
2598
2599 let warnings = rule
2601 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2602 .unwrap();
2603
2604 assert!(warnings.is_empty());
2606 }
2607
2608 #[test]
2609 fn test_cross_file_check_missing_link() {
2610 use crate::workspace_index::WorkspaceIndex;
2613
2614 let rule = MD057ExistingRelativeLinks::new();
2615 let workspace_index = WorkspaceIndex::new();
2616
2617 let mut file_index = FileIndex::new();
2618 file_index.add_cross_file_link(CrossFileLinkIndex {
2619 target_path: "missing.md".to_string(),
2620 fragment: "".to_string(),
2621 line: 5,
2622 column: 1,
2623 origin: LinkOrigin::Body,
2624 });
2625
2626 let warnings = rule
2627 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2628 .unwrap();
2629
2630 assert!(
2632 warnings.is_empty(),
2633 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2634 );
2635 }
2636
2637 #[test]
2638 fn test_cross_file_check_parent_path() {
2639 use crate::workspace_index::WorkspaceIndex;
2640
2641 let rule = MD057ExistingRelativeLinks::new();
2642
2643 let mut workspace_index = WorkspaceIndex::new();
2645 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2646
2647 let mut file_index = FileIndex::new();
2649 file_index.add_cross_file_link(CrossFileLinkIndex {
2650 target_path: "../readme.md".to_string(),
2651 fragment: "".to_string(),
2652 line: 5,
2653 column: 1,
2654 origin: LinkOrigin::Body,
2655 });
2656
2657 let warnings = rule
2659 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2660 .unwrap();
2661
2662 assert!(warnings.is_empty());
2664 }
2665
2666 #[test]
2667 fn test_cross_file_check_html_link_with_md_source() {
2668 use crate::workspace_index::WorkspaceIndex;
2671
2672 let rule = MD057ExistingRelativeLinks::new();
2673
2674 let mut workspace_index = WorkspaceIndex::new();
2676 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2677
2678 let mut file_index = FileIndex::new();
2680 file_index.add_cross_file_link(CrossFileLinkIndex {
2681 target_path: "guide.html".to_string(),
2682 fragment: "section".to_string(),
2683 line: 10,
2684 column: 5,
2685 origin: LinkOrigin::Body,
2686 });
2687
2688 let warnings = rule
2690 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2691 .unwrap();
2692
2693 assert!(
2695 warnings.is_empty(),
2696 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2697 );
2698 }
2699
2700 #[test]
2701 fn test_cross_file_check_html_link_without_source() {
2702 use crate::workspace_index::WorkspaceIndex;
2706
2707 let rule = MD057ExistingRelativeLinks::new();
2708 let workspace_index = WorkspaceIndex::new();
2709
2710 let mut file_index = FileIndex::new();
2711 file_index.add_cross_file_link(CrossFileLinkIndex {
2712 target_path: "missing.html".to_string(),
2713 fragment: "".to_string(),
2714 line: 10,
2715 column: 5,
2716 origin: LinkOrigin::Body,
2717 });
2718
2719 let warnings = rule
2720 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2721 .unwrap();
2722
2723 assert!(
2725 warnings.is_empty(),
2726 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2727 );
2728 }
2729
2730 #[test]
2731 fn test_normalize_path_function() {
2732 assert_eq!(
2734 normalize_relative_path(Path::new("docs/guide.md")),
2735 PathBuf::from("docs/guide.md")
2736 );
2737
2738 assert_eq!(
2740 normalize_relative_path(Path::new("./docs/guide.md")),
2741 PathBuf::from("docs/guide.md")
2742 );
2743
2744 assert_eq!(
2746 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2747 PathBuf::from("docs/guide.md")
2748 );
2749
2750 assert_eq!(
2752 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2753 PathBuf::from("a/d.md")
2754 );
2755 }
2756
2757 #[test]
2758 fn test_html_link_with_md_source() {
2759 let temp_dir = tempdir().unwrap();
2761 let base_path = temp_dir.path();
2762
2763 let md_file = base_path.join("guide.md");
2765 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2766
2767 let content = r#"
2768[Read the guide](guide.html)
2769[Also here](getting-started.html)
2770"#;
2771
2772 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2773 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2774 let result = rule.check(&ctx).unwrap();
2775
2776 assert_eq!(
2778 result.len(),
2779 1,
2780 "Should only warn about missing source. Got: {result:?}"
2781 );
2782 assert!(result[0].message.contains("getting-started.html"));
2783 }
2784
2785 #[test]
2786 fn test_htm_link_with_md_source() {
2787 let temp_dir = tempdir().unwrap();
2789 let base_path = temp_dir.path();
2790
2791 let md_file = base_path.join("page.md");
2792 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2793
2794 let content = "[Page](page.htm)";
2795
2796 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2797 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2798 let result = rule.check(&ctx).unwrap();
2799
2800 assert!(
2801 result.is_empty(),
2802 "Should not warn when .md source exists for .htm link"
2803 );
2804 }
2805
2806 #[test]
2807 fn test_html_link_finds_various_markdown_extensions() {
2808 let temp_dir = tempdir().unwrap();
2810 let base_path = temp_dir.path();
2811
2812 File::create(base_path.join("doc.md")).unwrap();
2813 File::create(base_path.join("tutorial.mdx")).unwrap();
2814 File::create(base_path.join("guide.markdown")).unwrap();
2815
2816 let content = r#"
2817[Doc](doc.html)
2818[Tutorial](tutorial.html)
2819[Guide](guide.html)
2820"#;
2821
2822 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2823 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824 let result = rule.check(&ctx).unwrap();
2825
2826 assert!(
2827 result.is_empty(),
2828 "Should find all markdown variants as source files. Got: {result:?}"
2829 );
2830 }
2831
2832 #[test]
2833 fn test_html_link_in_subdirectory() {
2834 let temp_dir = tempdir().unwrap();
2836 let base_path = temp_dir.path();
2837
2838 let docs_dir = base_path.join("docs");
2839 std::fs::create_dir(&docs_dir).unwrap();
2840 File::create(docs_dir.join("guide.md"))
2841 .unwrap()
2842 .write_all(b"# Guide")
2843 .unwrap();
2844
2845 let content = "[Guide](docs/guide.html)";
2846
2847 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2848 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2849 let result = rule.check(&ctx).unwrap();
2850
2851 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2852 }
2853
2854 #[test]
2855 fn test_absolute_path_skipped_in_check() {
2856 let temp_dir = tempdir().unwrap();
2859 let base_path = temp_dir.path();
2860
2861 let content = r#"
2862# Test Document
2863
2864[Go Runtime](/pkg/runtime)
2865[Go Runtime with Fragment](/pkg/runtime#section)
2866[API Docs](/api/v1/users)
2867[Blog Post](/blog/2024/release.html)
2868[React Hook](/react/hooks/use-state.html)
2869"#;
2870
2871 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2872 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2873 let result = rule.check(&ctx).unwrap();
2874
2875 assert!(
2877 result.is_empty(),
2878 "Absolute paths should be skipped. Got warnings: {result:?}"
2879 );
2880 }
2881
2882 #[test]
2883 fn test_absolute_path_skipped_in_cross_file_check() {
2884 use crate::workspace_index::WorkspaceIndex;
2886
2887 let rule = MD057ExistingRelativeLinks::new();
2888
2889 let workspace_index = WorkspaceIndex::new();
2891
2892 let mut file_index = FileIndex::new();
2894 file_index.add_cross_file_link(CrossFileLinkIndex {
2895 target_path: "/pkg/runtime.md".to_string(),
2896 fragment: "".to_string(),
2897 line: 5,
2898 column: 1,
2899 origin: LinkOrigin::Body,
2900 });
2901 file_index.add_cross_file_link(CrossFileLinkIndex {
2902 target_path: "/api/v1/users.md".to_string(),
2903 fragment: "section".to_string(),
2904 line: 10,
2905 column: 1,
2906 origin: LinkOrigin::Body,
2907 });
2908
2909 let warnings = rule
2911 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2912 .unwrap();
2913
2914 assert!(
2916 warnings.is_empty(),
2917 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2918 );
2919 }
2920
2921 #[test]
2922 fn test_protocol_relative_url_not_skipped() {
2923 let temp_dir = tempdir().unwrap();
2926 let base_path = temp_dir.path();
2927
2928 let content = r#"
2929# Test Document
2930
2931[External](//example.com/page)
2932[Another](//cdn.example.com/asset.js)
2933"#;
2934
2935 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2936 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2937 let result = rule.check(&ctx).unwrap();
2938
2939 assert!(
2941 result.is_empty(),
2942 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2943 );
2944 }
2945
2946 #[test]
2947 fn test_email_addresses_skipped() {
2948 let temp_dir = tempdir().unwrap();
2951 let base_path = temp_dir.path();
2952
2953 let content = r#"
2954# Test Document
2955
2956[Contact](user@example.com)
2957[Steering](steering@kubernetes.io)
2958[Support](john.doe+filter@company.co.uk)
2959[User](user_name@sub.domain.com)
2960"#;
2961
2962 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2963 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2964 let result = rule.check(&ctx).unwrap();
2965
2966 assert!(
2968 result.is_empty(),
2969 "Email addresses should be skipped. Got warnings: {result:?}"
2970 );
2971 }
2972
2973 #[test]
2974 fn test_email_addresses_vs_file_paths() {
2975 let temp_dir = tempdir().unwrap();
2978 let base_path = temp_dir.path();
2979
2980 let content = r#"
2981# Test Document
2982
2983[Email](user@example.com) <!-- Should be skipped (email) -->
2984[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2985[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2986"#;
2987
2988 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2989 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2990 let result = rule.check(&ctx).unwrap();
2991
2992 assert!(
2994 result.is_empty(),
2995 "All email addresses should be skipped. Got: {result:?}"
2996 );
2997 }
2998
2999 #[test]
3000 fn test_diagnostic_position_accuracy() {
3001 let temp_dir = tempdir().unwrap();
3003 let base_path = temp_dir.path();
3004
3005 let content = "prefix [text](missing.md) suffix";
3008 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3012 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3013 let result = rule.check(&ctx).unwrap();
3014
3015 assert_eq!(result.len(), 1, "Should have exactly one warning");
3016 assert_eq!(result[0].line, 1, "Should be on line 1");
3017 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3018 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3019 }
3020
3021 #[test]
3022 fn test_diagnostic_position_non_ascii_link() {
3023 let temp_dir = tempdir().unwrap();
3026 let base_path = temp_dir.path();
3027
3028 let content = "你好你好[你好](not-exist.md) bar";
3032
3033 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3034 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3035 let result = rule.check(&ctx).unwrap();
3036
3037 assert_eq!(result.len(), 1, "Should have exactly one warning");
3038 assert_eq!(result[0].line, 1, "Should be on line 1");
3039 assert_eq!(
3040 result[0].column, 10,
3041 "Column must be a character offset, not a byte offset"
3042 );
3043 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3044 }
3045
3046 #[test]
3047 fn test_diagnostic_position_angle_brackets() {
3048 let temp_dir = tempdir().unwrap();
3050 let base_path = temp_dir.path();
3051
3052 let content = "[link](<missing.md>)";
3055 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3058 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3059 let result = rule.check(&ctx).unwrap();
3060
3061 assert_eq!(result.len(), 1, "Should have exactly one warning");
3062 assert_eq!(result[0].line, 1, "Should be on line 1");
3063 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3064 }
3065
3066 #[test]
3067 fn test_diagnostic_position_multiline() {
3068 let temp_dir = tempdir().unwrap();
3070 let base_path = temp_dir.path();
3071
3072 let content = r#"# Title
3073Some text on line 2
3074[link on line 3](missing1.md)
3075More text
3076[link on line 5](missing2.md)"#;
3077
3078 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3079 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3080 let result = rule.check(&ctx).unwrap();
3081
3082 assert_eq!(result.len(), 2, "Should have two warnings");
3083
3084 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3086 assert!(result[0].message.contains("missing1.md"));
3087
3088 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3090 assert!(result[1].message.contains("missing2.md"));
3091 }
3092
3093 #[test]
3094 fn test_diagnostic_position_with_spaces() {
3095 let temp_dir = tempdir().unwrap();
3097 let base_path = temp_dir.path();
3098
3099 let content = "[link]( missing.md )";
3100 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3105 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3106 let result = rule.check(&ctx).unwrap();
3107
3108 assert_eq!(result.len(), 1, "Should have exactly one warning");
3109 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3111 }
3112
3113 #[test]
3114 fn test_diagnostic_position_image() {
3115 let temp_dir = tempdir().unwrap();
3117 let base_path = temp_dir.path();
3118
3119 let content = "";
3120
3121 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3122 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3123 let result = rule.check(&ctx).unwrap();
3124
3125 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3126 assert_eq!(result[0].line, 1);
3127 assert!(result[0].column > 0, "Should have valid column position");
3129 assert!(result[0].message.contains("missing.jpg"));
3130 }
3131
3132 #[test]
3133 fn test_diagnostic_position_non_ascii_image() {
3134 let temp_dir = tempdir().unwrap();
3136 let base_path = temp_dir.path();
3137
3138 let content = "你好你好";
3141
3142 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3143 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3144 let result = rule.check(&ctx).unwrap();
3145
3146 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3147 assert_eq!(result[0].line, 1, "Should be on line 1");
3148 assert_eq!(
3149 result[0].column, 5,
3150 "Column must be a character offset, not a byte offset"
3151 );
3152 assert!(result[0].message.contains("not-exist.png"));
3153 }
3154
3155 #[test]
3156 fn test_diagnostic_position_non_ascii_reference_def() {
3157 let temp_dir = tempdir().unwrap();
3161 let base_path = temp_dir.path();
3162
3163 let content = "[你好]: not-exist.md";
3166
3167 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3168 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3169 let result = rule.check(&ctx).unwrap();
3170
3171 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3172 assert_eq!(result[0].line, 1, "Should be on line 1");
3173 assert_eq!(
3174 result[0].column, 7,
3175 "Column must be a character offset, not a byte offset"
3176 );
3177 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3178 }
3179
3180 #[test]
3181 fn test_wikilinks_skipped() {
3182 let temp_dir = tempdir().unwrap();
3185 let base_path = temp_dir.path();
3186
3187 let content = r#"# Test Document
3188
3189[[Microsoft#Windows OS]]
3190[[SomePage]]
3191[[Page With Spaces]]
3192[[path/to/page#section]]
3193[[page|Display Text]]
3194
3195This is a [real missing link](missing.md) that should be flagged.
3196"#;
3197
3198 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3199 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3200 let result = rule.check(&ctx).unwrap();
3201
3202 assert_eq!(
3204 result.len(),
3205 1,
3206 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3207 );
3208 assert!(
3209 result[0].message.contains("missing.md"),
3210 "Warning should be for missing.md, not wikilinks"
3211 );
3212 }
3213
3214 #[test]
3215 fn test_wiki_embeds_skipped() {
3216 let temp_dir = tempdir().unwrap();
3220 let base_path = temp_dir.path();
3221
3222 let content = r#"# Test Document
3223
3224![[diagram.png]]
3225![[subfolder/diagram.png]]
3226![[diagram.png|300]]
3227![[Some Note]]
3228
3229This is a [real missing link](missing.md) that should be flagged.
3230"#;
3231
3232 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3233 for flavor in [
3234 crate::config::MarkdownFlavor::Obsidian,
3235 crate::config::MarkdownFlavor::Standard,
3236 ] {
3237 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3238 let result = rule.check(&ctx).unwrap();
3239
3240 assert_eq!(
3241 result.len(),
3242 1,
3243 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3244 );
3245 assert!(result[0].message.contains("missing.md"));
3246 }
3247 }
3248
3249 #[test]
3250 fn test_wikilinks_not_added_to_index() {
3251 let temp_dir = tempdir().unwrap();
3253 let base_path = temp_dir.path();
3254
3255 let content = r#"# Test Document
3256
3257[[Microsoft#Windows OS]]
3258[[SomePage#section]]
3259[Regular Link](other.md)
3260"#;
3261
3262 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3263 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3264
3265 let mut file_index = FileIndex::new();
3266 rule.contribute_to_index(&ctx, &mut file_index);
3267
3268 let cross_file_links = &file_index.cross_file_links;
3271 assert_eq!(
3272 cross_file_links.len(),
3273 1,
3274 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3275 );
3276 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3277 }
3278
3279 #[test]
3280 fn test_reference_definition_missing_file() {
3281 let temp_dir = tempdir().unwrap();
3283 let base_path = temp_dir.path();
3284
3285 let content = r#"# Test Document
3286
3287[test]: ./missing.md
3288[example]: ./nonexistent.html
3289
3290Use [test] and [example] here.
3291"#;
3292
3293 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3294 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3295 let result = rule.check(&ctx).unwrap();
3296
3297 assert_eq!(
3299 result.len(),
3300 2,
3301 "Should have warnings for missing reference definition targets. Got: {result:?}"
3302 );
3303 assert!(
3304 result.iter().any(|w| w.message.contains("missing.md")),
3305 "Should warn about missing.md"
3306 );
3307 assert!(
3308 result.iter().any(|w| w.message.contains("nonexistent.html")),
3309 "Should warn about nonexistent.html"
3310 );
3311 }
3312
3313 #[test]
3314 fn test_reference_definition_existing_file() {
3315 let temp_dir = tempdir().unwrap();
3317 let base_path = temp_dir.path();
3318
3319 let exists_path = base_path.join("exists.md");
3321 File::create(&exists_path)
3322 .unwrap()
3323 .write_all(b"# Existing file")
3324 .unwrap();
3325
3326 let content = r#"# Test Document
3327
3328[test]: ./exists.md
3329
3330Use [test] here.
3331"#;
3332
3333 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3334 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3335 let result = rule.check(&ctx).unwrap();
3336
3337 assert!(
3339 result.is_empty(),
3340 "Should not warn about existing file. Got: {result:?}"
3341 );
3342 }
3343
3344 #[test]
3345 fn test_reference_definition_external_url_skipped() {
3346 let temp_dir = tempdir().unwrap();
3348 let base_path = temp_dir.path();
3349
3350 let content = r#"# Test Document
3351
3352[google]: https://google.com
3353[example]: http://example.org
3354[mail]: mailto:test@example.com
3355[ftp]: ftp://files.example.com
3356[local]: ./missing.md
3357
3358Use [google], [example], [mail], [ftp], [local] here.
3359"#;
3360
3361 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3362 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3363 let result = rule.check(&ctx).unwrap();
3364
3365 assert_eq!(
3367 result.len(),
3368 1,
3369 "Should only warn about local missing file. Got: {result:?}"
3370 );
3371 assert!(
3372 result[0].message.contains("missing.md"),
3373 "Warning should be for missing.md"
3374 );
3375 }
3376
3377 #[test]
3378 fn test_reference_definition_fragment_only_skipped() {
3379 let temp_dir = tempdir().unwrap();
3381 let base_path = temp_dir.path();
3382
3383 let content = r#"# Test Document
3384
3385[section]: #my-section
3386
3387Use [section] here.
3388"#;
3389
3390 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3391 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3392 let result = rule.check(&ctx).unwrap();
3393
3394 assert!(
3396 result.is_empty(),
3397 "Should not warn about fragment-only reference. Got: {result:?}"
3398 );
3399 }
3400
3401 #[test]
3402 fn test_reference_definition_column_position() {
3403 let temp_dir = tempdir().unwrap();
3405 let base_path = temp_dir.path();
3406
3407 let content = "[ref]: ./missing.md";
3410 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3414 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3415 let result = rule.check(&ctx).unwrap();
3416
3417 assert_eq!(result.len(), 1, "Should have exactly one warning");
3418 assert_eq!(result[0].line, 1, "Should be on line 1");
3419 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3420 }
3421
3422 #[test]
3423 fn test_reference_definition_html_with_md_source() {
3424 let temp_dir = tempdir().unwrap();
3426 let base_path = temp_dir.path();
3427
3428 let md_file = base_path.join("guide.md");
3430 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3431
3432 let content = r#"# Test Document
3433
3434[guide]: ./guide.html
3435[missing]: ./missing.html
3436
3437Use [guide] and [missing] here.
3438"#;
3439
3440 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3441 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3442 let result = rule.check(&ctx).unwrap();
3443
3444 assert_eq!(
3446 result.len(),
3447 1,
3448 "Should only warn about missing source. Got: {result:?}"
3449 );
3450 assert!(result[0].message.contains("missing.html"));
3451 }
3452
3453 #[test]
3454 fn test_reference_definition_url_encoded() {
3455 let temp_dir = tempdir().unwrap();
3457 let base_path = temp_dir.path();
3458
3459 let file_with_spaces = base_path.join("file with spaces.md");
3461 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3462
3463 let content = r#"# Test Document
3464
3465[spaces]: ./file%20with%20spaces.md
3466[missing]: ./missing%20file.md
3467
3468Use [spaces] and [missing] here.
3469"#;
3470
3471 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3472 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3473 let result = rule.check(&ctx).unwrap();
3474
3475 assert_eq!(
3477 result.len(),
3478 1,
3479 "Should only warn about missing URL-encoded file. Got: {result:?}"
3480 );
3481 assert!(result[0].message.contains("missing%20file.md"));
3482 }
3483
3484 #[test]
3485 fn test_inline_and_reference_both_checked() {
3486 let temp_dir = tempdir().unwrap();
3488 let base_path = temp_dir.path();
3489
3490 let content = r#"# Test Document
3491
3492[inline link](./inline-missing.md)
3493[ref]: ./ref-missing.md
3494
3495Use [ref] here.
3496"#;
3497
3498 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3499 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3500 let result = rule.check(&ctx).unwrap();
3501
3502 assert_eq!(
3504 result.len(),
3505 2,
3506 "Should warn about both inline and reference links. Got: {result:?}"
3507 );
3508 assert!(
3509 result.iter().any(|w| w.message.contains("inline-missing.md")),
3510 "Should warn about inline-missing.md"
3511 );
3512 assert!(
3513 result.iter().any(|w| w.message.contains("ref-missing.md")),
3514 "Should warn about ref-missing.md"
3515 );
3516 }
3517
3518 #[test]
3519 fn test_footnote_definitions_not_flagged() {
3520 let rule = MD057ExistingRelativeLinks::default();
3523
3524 let content = r#"# Title
3525
3526A footnote[^1].
3527
3528[^1]: [link](https://www.google.com).
3529"#;
3530
3531 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3532 let result = rule.check(&ctx).unwrap();
3533
3534 assert!(
3535 result.is_empty(),
3536 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3537 );
3538 }
3539
3540 #[test]
3541 fn test_footnote_with_relative_link_inside() {
3542 let rule = MD057ExistingRelativeLinks::default();
3545
3546 let content = r#"# Title
3547
3548See the footnote[^1].
3549
3550[^1]: Check out [this file](./existing.md) for more info.
3551[^2]: Also see [missing](./does-not-exist.md).
3552"#;
3553
3554 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3555 let result = rule.check(&ctx).unwrap();
3556
3557 for warning in &result {
3562 assert!(
3563 !warning.message.contains("[this file]"),
3564 "Footnote content should not be treated as URL: {warning:?}"
3565 );
3566 assert!(
3567 !warning.message.contains("[missing]"),
3568 "Footnote content should not be treated as URL: {warning:?}"
3569 );
3570 }
3571 }
3572
3573 #[test]
3574 fn test_mixed_footnotes_and_reference_definitions() {
3575 let temp_dir = tempdir().unwrap();
3577 let base_path = temp_dir.path();
3578
3579 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3580
3581 let content = r#"# Title
3582
3583A footnote[^1] and a [ref link][myref].
3584
3585[^1]: This is a footnote with [link](https://example.com).
3586
3587[myref]: ./missing-file.md "This should be checked"
3588"#;
3589
3590 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3591 let result = rule.check(&ctx).unwrap();
3592
3593 assert_eq!(
3595 result.len(),
3596 1,
3597 "Should only warn about the regular reference definition. Got: {result:?}"
3598 );
3599 assert!(
3600 result[0].message.contains("missing-file.md"),
3601 "Should warn about missing-file.md in reference definition"
3602 );
3603 }
3604
3605 #[test]
3606 fn test_absolute_links_ignore_by_default() {
3607 let temp_dir = tempdir().unwrap();
3609 let base_path = temp_dir.path();
3610
3611 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3612
3613 let content = r#"# Links
3614
3615[API docs](/api/v1/users)
3616[Blog post](/blog/2024/release.html)
3617
3618
3619[ref]: /docs/reference.md
3620"#;
3621
3622 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3623 let result = rule.check(&ctx).unwrap();
3624
3625 assert!(
3627 result.is_empty(),
3628 "Absolute links should be ignored by default. Got: {result:?}"
3629 );
3630 }
3631
3632 #[test]
3633 fn test_absolute_links_warn_config() {
3634 let temp_dir = tempdir().unwrap();
3636 let base_path = temp_dir.path();
3637
3638 let config = MD057Config {
3639 absolute_links: AbsoluteLinksOption::Warn,
3640 ..Default::default()
3641 };
3642 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3643
3644 let content = r#"# Links
3645
3646[API docs](/api/v1/users)
3647[Blog post](/blog/2024/release.html)
3648"#;
3649
3650 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3651 let result = rule.check(&ctx).unwrap();
3652
3653 assert_eq!(
3655 result.len(),
3656 2,
3657 "Should warn about both absolute links. Got: {result:?}"
3658 );
3659 assert!(
3660 result[0].message.contains("cannot be validated locally"),
3661 "Warning should explain why: {}",
3662 result[0].message
3663 );
3664 assert!(
3665 result[0].message.contains("/api/v1/users"),
3666 "Warning should include the link path"
3667 );
3668 }
3669
3670 #[test]
3671 fn test_absolute_links_warn_images() {
3672 let temp_dir = tempdir().unwrap();
3674 let base_path = temp_dir.path();
3675
3676 let config = MD057Config {
3677 absolute_links: AbsoluteLinksOption::Warn,
3678 ..Default::default()
3679 };
3680 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3681
3682 let content = r#"# Images
3683
3684
3685"#;
3686
3687 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3688 let result = rule.check(&ctx).unwrap();
3689
3690 assert_eq!(
3691 result.len(),
3692 1,
3693 "Should warn about absolute image path. Got: {result:?}"
3694 );
3695 assert!(
3696 result[0].message.contains("/assets/logo.png"),
3697 "Warning should include the image path"
3698 );
3699 }
3700
3701 #[test]
3702 fn test_absolute_links_warn_reference_definitions() {
3703 let temp_dir = tempdir().unwrap();
3705 let base_path = temp_dir.path();
3706
3707 let config = MD057Config {
3708 absolute_links: AbsoluteLinksOption::Warn,
3709 ..Default::default()
3710 };
3711 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3712
3713 let content = r#"# Reference
3714
3715See the [docs][ref].
3716
3717[ref]: /docs/reference.md
3718"#;
3719
3720 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3721 let result = rule.check(&ctx).unwrap();
3722
3723 assert_eq!(
3724 result.len(),
3725 1,
3726 "Should warn about absolute reference definition. Got: {result:?}"
3727 );
3728 assert!(
3729 result[0].message.contains("/docs/reference.md"),
3730 "Warning should include the reference path"
3731 );
3732 }
3733
3734 #[test]
3735 fn test_search_paths_inline_link() {
3736 let temp_dir = tempdir().unwrap();
3737 let base_path = temp_dir.path();
3738
3739 let assets_dir = base_path.join("assets");
3741 std::fs::create_dir_all(&assets_dir).unwrap();
3742 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3743
3744 let config = MD057Config {
3745 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3746 ..Default::default()
3747 };
3748 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3749
3750 let content = "# Test\n\n[Photo](photo.png)\n";
3751 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3752 let result = rule.check(&ctx).unwrap();
3753
3754 assert!(
3755 result.is_empty(),
3756 "Should find photo.png via search-paths. Got: {result:?}"
3757 );
3758 }
3759
3760 #[test]
3761 fn test_search_paths_image() {
3762 let temp_dir = tempdir().unwrap();
3763 let base_path = temp_dir.path();
3764
3765 let assets_dir = base_path.join("attachments");
3766 std::fs::create_dir_all(&assets_dir).unwrap();
3767 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3768
3769 let config = MD057Config {
3770 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3771 ..Default::default()
3772 };
3773 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3774
3775 let content = "# Test\n\n\n";
3776 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3777 let result = rule.check(&ctx).unwrap();
3778
3779 assert!(
3780 result.is_empty(),
3781 "Should find diagram.svg via search-paths. Got: {result:?}"
3782 );
3783 }
3784
3785 #[test]
3786 fn test_search_paths_reference_definition() {
3787 let temp_dir = tempdir().unwrap();
3788 let base_path = temp_dir.path();
3789
3790 let assets_dir = base_path.join("images");
3791 std::fs::create_dir_all(&assets_dir).unwrap();
3792 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3793
3794 let config = MD057Config {
3795 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3796 ..Default::default()
3797 };
3798 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3799
3800 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3801 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3802 let result = rule.check(&ctx).unwrap();
3803
3804 assert!(
3805 result.is_empty(),
3806 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3807 );
3808 }
3809
3810 #[test]
3811 fn test_search_paths_still_warns_when_truly_missing() {
3812 let temp_dir = tempdir().unwrap();
3813 let base_path = temp_dir.path();
3814
3815 let assets_dir = base_path.join("assets");
3816 std::fs::create_dir_all(&assets_dir).unwrap();
3817
3818 let config = MD057Config {
3819 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3820 ..Default::default()
3821 };
3822 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3823
3824 let content = "# Test\n\n\n";
3825 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3826 let result = rule.check(&ctx).unwrap();
3827
3828 assert_eq!(
3829 result.len(),
3830 1,
3831 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3832 );
3833 }
3834
3835 #[test]
3836 fn test_search_paths_nonexistent_directory() {
3837 let temp_dir = tempdir().unwrap();
3838 let base_path = temp_dir.path();
3839
3840 let config = MD057Config {
3841 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3842 ..Default::default()
3843 };
3844 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3845
3846 let content = "# Test\n\n\n";
3847 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3848 let result = rule.check(&ctx).unwrap();
3849
3850 assert_eq!(
3851 result.len(),
3852 1,
3853 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3854 );
3855 }
3856
3857 #[test]
3858 fn test_obsidian_attachment_folder_named() {
3859 let temp_dir = tempdir().unwrap();
3860 let vault = temp_dir.path().join("vault");
3861 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3862 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3863 std::fs::create_dir_all(vault.join("notes")).unwrap();
3864
3865 std::fs::write(
3866 vault.join(".obsidian/app.json"),
3867 r#"{"attachmentFolderPath": "Attachments"}"#,
3868 )
3869 .unwrap();
3870 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3871
3872 let notes_dir = vault.join("notes");
3873 let source_file = notes_dir.join("test.md");
3874 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3875
3876 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3877
3878 let content = "# Test\n\n\n";
3879 let ctx =
3880 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3881 let result = rule.check(&ctx).unwrap();
3882
3883 assert!(
3884 result.is_empty(),
3885 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3886 );
3887 }
3888
3889 #[test]
3890 fn test_obsidian_attachment_same_folder_as_file() {
3891 let temp_dir = tempdir().unwrap();
3892 let vault = temp_dir.path().join("vault-rf");
3893 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3894 std::fs::create_dir_all(vault.join("notes")).unwrap();
3895
3896 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3897
3898 let notes_dir = vault.join("notes");
3900 let source_file = notes_dir.join("test.md");
3901 std::fs::write(&source_file, "placeholder").unwrap();
3902 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3903
3904 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3905
3906 let content = "# Test\n\n\n";
3907 let ctx =
3908 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3909 let result = rule.check(&ctx).unwrap();
3910
3911 assert!(
3912 result.is_empty(),
3913 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3914 );
3915 }
3916
3917 #[test]
3918 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3919 let temp_dir = tempdir().unwrap();
3920 let vault = temp_dir.path().join("vault-nf");
3921 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3922 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3923 std::fs::create_dir_all(vault.join("notes")).unwrap();
3924
3925 std::fs::write(
3926 vault.join(".obsidian/app.json"),
3927 r#"{"attachmentFolderPath": "Attachments"}"#,
3928 )
3929 .unwrap();
3930 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3931
3932 let notes_dir = vault.join("notes");
3933 let source_file = notes_dir.join("test.md");
3934 std::fs::write(&source_file, "placeholder").unwrap();
3935
3936 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3937
3938 let content = "# Test\n\n\n";
3939 let ctx =
3941 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3942 let result = rule.check(&ctx).unwrap();
3943
3944 assert_eq!(
3945 result.len(),
3946 1,
3947 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3948 );
3949 }
3950
3951 #[test]
3952 fn test_search_paths_combined_with_obsidian() {
3953 let temp_dir = tempdir().unwrap();
3954 let vault = temp_dir.path().join("vault-combo");
3955 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3956 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3957 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3958 std::fs::create_dir_all(vault.join("notes")).unwrap();
3959
3960 std::fs::write(
3961 vault.join(".obsidian/app.json"),
3962 r#"{"attachmentFolderPath": "Attachments"}"#,
3963 )
3964 .unwrap();
3965 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3966 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3967
3968 let notes_dir = vault.join("notes");
3969 let source_file = notes_dir.join("test.md");
3970 std::fs::write(&source_file, "placeholder").unwrap();
3971
3972 let extra_assets_dir = vault.join("extra-assets");
3973 let config = MD057Config {
3974 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3975 ..Default::default()
3976 };
3977 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3978
3979 let content = "# Test\n\n\n\n\n";
3981 let ctx =
3982 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3983 let result = rule.check(&ctx).unwrap();
3984
3985 assert!(
3986 result.is_empty(),
3987 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3988 );
3989 }
3990
3991 #[test]
3992 fn test_obsidian_attachment_subfolder_under_file() {
3993 let temp_dir = tempdir().unwrap();
3994 let vault = temp_dir.path().join("vault-sub");
3995 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3996 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3997
3998 std::fs::write(
3999 vault.join(".obsidian/app.json"),
4000 r#"{"attachmentFolderPath": "./assets"}"#,
4001 )
4002 .unwrap();
4003 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4004
4005 let notes_dir = vault.join("notes");
4006 let source_file = notes_dir.join("test.md");
4007 std::fs::write(&source_file, "placeholder").unwrap();
4008
4009 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4010
4011 let content = "# Test\n\n\n";
4012 let ctx =
4013 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4014 let result = rule.check(&ctx).unwrap();
4015
4016 assert!(
4017 result.is_empty(),
4018 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4019 );
4020 }
4021
4022 #[test]
4023 fn test_obsidian_attachment_vault_root() {
4024 let temp_dir = tempdir().unwrap();
4025 let vault = temp_dir.path().join("vault-root");
4026 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4027 std::fs::create_dir_all(vault.join("notes")).unwrap();
4028
4029 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4031 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4032
4033 let notes_dir = vault.join("notes");
4034 let source_file = notes_dir.join("test.md");
4035 std::fs::write(&source_file, "placeholder").unwrap();
4036
4037 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4038
4039 let content = "# Test\n\n\n";
4040 let ctx =
4041 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4042 let result = rule.check(&ctx).unwrap();
4043
4044 assert!(
4045 result.is_empty(),
4046 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4047 );
4048 }
4049
4050 #[test]
4051 fn test_search_paths_multiple_directories() {
4052 let temp_dir = tempdir().unwrap();
4053 let base_path = temp_dir.path();
4054
4055 let dir_a = base_path.join("dir-a");
4056 let dir_b = base_path.join("dir-b");
4057 std::fs::create_dir_all(&dir_a).unwrap();
4058 std::fs::create_dir_all(&dir_b).unwrap();
4059 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4060 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4061
4062 let config = MD057Config {
4063 search_paths: vec![
4064 dir_a.to_string_lossy().into_owned(),
4065 dir_b.to_string_lossy().into_owned(),
4066 ],
4067 ..Default::default()
4068 };
4069 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4070
4071 let content = "# Test\n\n\n\n\n";
4072 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4073 let result = rule.check(&ctx).unwrap();
4074
4075 assert!(
4076 result.is_empty(),
4077 "Should find files across multiple search paths. Got: {result:?}"
4078 );
4079 }
4080
4081 #[test]
4090 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4091 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4092
4093 let temp_dir = tempdir().unwrap();
4094 let base_path = temp_dir.path();
4095
4096 let file_path = base_path.join("README.md");
4097 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4098 std::fs::write(&file_path, content).unwrap();
4099
4100 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4101
4102 let ctx = crate::lint_context::LintContext::new(
4103 content,
4104 crate::config::MarkdownFlavor::Standard,
4105 Some(file_path.clone()),
4106 );
4107 let per_file = rule.check(&ctx).unwrap();
4108 assert_eq!(
4109 per_file.len(),
4110 1,
4111 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4112 );
4113
4114 let mut file_index = FileIndex::default();
4115 file_index.cross_file_links.push(CrossFileLinkIndex {
4116 target_path: "missing-guide.md".to_string(),
4117 fragment: String::new(),
4118 line: 3,
4119 column: 1,
4120 origin: LinkOrigin::Body,
4121 });
4122
4123 let result = rule
4124 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4125 .unwrap();
4126
4127 assert!(
4128 result.is_empty(),
4129 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4130 );
4131 }
4132
4133 #[test]
4134 fn test_check_clears_stale_cache() {
4135 let temp_dir = tempdir().unwrap();
4138 let base_path = temp_dir.path();
4139
4140 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4141
4142 let phantom_path = base_path.join("phantom.md");
4144 {
4145 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4146 cache.insert(phantom_path.clone(), true);
4147 }
4148
4149 let content = "[phantom](phantom.md)\n";
4150 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4151 let warnings = rule.check(&ctx).unwrap();
4152
4153 assert_eq!(
4155 warnings.len(),
4156 1,
4157 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4158 );
4159 assert!(warnings[0].message.contains("phantom.md"));
4160 }
4161
4162 #[test]
4163 fn test_check_does_not_carry_over_cache_between_runs() {
4164 let temp_dir = tempdir().unwrap();
4166 let base_path = temp_dir.path();
4167
4168 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4169
4170 let content = "[missing](nonexistent.md)\n";
4171 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4172
4173 let warnings_1 = rule.check(&ctx).unwrap();
4175 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4176
4177 let nonexistent_path = base_path.join("nonexistent.md");
4179 {
4180 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4181 cache.insert(nonexistent_path.clone(), true);
4182 }
4183
4184 let warnings_2 = rule.check(&ctx).unwrap();
4186 assert_eq!(
4187 warnings_2.len(),
4188 1,
4189 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4190 );
4191 }
4192
4193 #[test]
4199 fn test_no_duplicate_warnings_for_broken_relative_link() {
4200 use crate::workspace_index::WorkspaceIndex;
4201
4202 let temp_dir = tempdir().unwrap();
4203 let base_path = temp_dir.path();
4204
4205 let source_file = base_path.join("index.md");
4207 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4208
4209 let content = "[broken](does/not/exist.md)\n";
4210
4211 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4212
4213 let ctx = crate::lint_context::LintContext::new(
4215 content,
4216 crate::config::MarkdownFlavor::Standard,
4217 Some(source_file.clone()),
4218 );
4219 let check_warnings = rule.check(&ctx).unwrap();
4220
4221 let mut file_index = FileIndex::new();
4223 rule.contribute_to_index(&ctx, &mut file_index);
4224 let workspace_index = WorkspaceIndex::new();
4225 let cross_warnings = rule
4226 .cross_file_check(&source_file, &file_index, &workspace_index)
4227 .unwrap();
4228
4229 let total = check_warnings.len() + cross_warnings.len();
4230 assert_eq!(
4231 total, 1,
4232 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4233 check={check_warnings:?}, cross={cross_warnings:?}"
4234 );
4235 }
4236
4237 #[test]
4242 fn test_absolute_dir_link_accepted_relative_to_roots() {
4243 let temp_dir = tempdir().unwrap();
4244 let root = temp_dir.path();
4245
4246 let dir_d = root.join("d");
4248 std::fs::create_dir_all(&dir_d).unwrap();
4249 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4250
4251 let content = "\
4254[absolute dir](/d)\n\
4255[relative dir](d)\n\
4256[absolute file](/d/foo.md)\n\
4257[relative file](d/foo.md)\n";
4258
4259 let config = MD057Config {
4260 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4261 roots: vec![],
4262 ..Default::default()
4263 };
4264 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4265
4266 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4267 let result = rule.check(&ctx).unwrap();
4268
4269 assert!(
4270 result.is_empty(),
4271 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4272 );
4273 }
4274
4275 #[test]
4278 fn test_absolute_trailing_slash_dir_link_requires_index() {
4279 let temp_dir = tempdir().unwrap();
4280 let root = temp_dir.path();
4281
4282 let dir_d = root.join("d");
4284 std::fs::create_dir_all(&dir_d).unwrap();
4285 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4286
4287 let content = "[dir with slash](/d/)\n";
4289
4290 let config = MD057Config {
4291 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4292 roots: vec![],
4293 ..Default::default()
4294 };
4295 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4296
4297 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4298 let result = rule.check(&ctx).unwrap();
4299
4300 assert_eq!(
4301 result.len(),
4302 1,
4303 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
4304 );
4305 }
4306
4307 #[test]
4311 fn test_docs_dir_variant_still_enforces_index_md() {
4312 let temp_dir = tempdir().unwrap();
4313 let root = temp_dir.path();
4314
4315 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4317
4318 let docs_dir = root.join("docs");
4320 std::fs::create_dir_all(&docs_dir).unwrap();
4321 let section_dir = docs_dir.join("section");
4322 std::fs::create_dir_all(§ion_dir).unwrap();
4323 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4324
4325 let source_file = docs_dir.join("index.md");
4327 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4328
4329 let config = MD057Config {
4330 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4331 ..Default::default()
4332 };
4333 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4334
4335 let content = "[sec](/section)\n";
4336 let ctx = crate::lint_context::LintContext::new(
4337 content,
4338 crate::config::MarkdownFlavor::Standard,
4339 Some(source_file.clone()),
4340 );
4341 let result = rule.check(&ctx).unwrap();
4342
4343 assert_eq!(
4345 result.len(),
4346 1,
4347 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4348 );
4349 assert!(
4350 result[0].message.contains("index.md") || result[0].message.contains("section"),
4351 "Message should mention the directory or missing index.md: {}",
4352 result[0].message
4353 );
4354 }
4355
4356 #[test]
4362 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4363 let temp_dir = tempdir().unwrap();
4364 let root = temp_dir.path();
4365
4366 let guide_dir = root.join("guide");
4368 std::fs::create_dir_all(&guide_dir).unwrap();
4369 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4370
4371 let content = "[guide with fragment](/guide/#intro)\n";
4373
4374 let config = MD057Config {
4375 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4376 roots: vec![],
4377 ..Default::default()
4378 };
4379 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4380 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4381 let result = rule.check(&ctx).unwrap();
4382
4383 assert_eq!(
4384 result.len(),
4385 1,
4386 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4387 );
4388 }
4389}
4390
4391#[cfg(test)]
4392mod self_referential_links_tests {
4393 use super::*;
4394 use tempfile::tempdir;
4395
4396 fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4398 let source_file = dir.join(name);
4399 std::fs::write(&source_file, content).unwrap();
4400 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4401 let ctx =
4402 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4403 rule.check(&ctx).unwrap()
4404 }
4405
4406 fn enabled() -> MD057Config {
4407 MD057Config {
4408 self_referential_links: true,
4409 ..Default::default()
4410 }
4411 }
4412
4413 #[test]
4414 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4415 let temp_dir = tempdir().unwrap();
4416 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4417 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4418
4419 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4420 assert_eq!(
4421 result[0].message,
4422 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4423 );
4424 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4425 assert_eq!(fix.replacement, "#level-2-heading");
4426 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4427 }
4428
4429 #[test]
4430 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4431 let temp_dir = tempdir().unwrap();
4432 let content = "# Title\n\nSee [this file](test.md).\n";
4433 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4434
4435 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4436 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4437 assert!(
4438 result[0].fix.is_none(),
4439 "Dropping the link would change the document, so there is no fix"
4440 );
4441 }
4442
4443 #[test]
4444 fn test_the_check_is_off_by_default() {
4445 let temp_dir = tempdir().unwrap();
4446 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4447 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4448
4449 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4450 }
4451
4452 #[test]
4453 fn test_a_link_to_another_file_is_left_alone() {
4454 let temp_dir = tempdir().unwrap();
4455 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4456 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4457 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4458
4459 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4460 }
4461
4462 #[test]
4463 fn test_a_self_link_written_with_traversal_reports_once() {
4464 let temp_dir = tempdir().unwrap();
4465 let sub_dir = temp_dir.path().join("sub");
4466 std::fs::create_dir_all(&sub_dir).unwrap();
4467
4468 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4469 let config = MD057Config {
4470 self_referential_links: true,
4471 compact_paths: true,
4472 ..Default::default()
4473 };
4474 let result = check_as_file(&sub_dir, "test.md", content, config);
4475
4476 assert_eq!(
4477 result.len(),
4478 1,
4479 "A compacted path would still be a link back to this file. Got: {result:?}"
4480 );
4481 assert_eq!(
4482 result[0].message,
4483 "Relative link '../sub/test.md' points to the file it is in"
4484 );
4485 }
4486
4487 #[test]
4488 fn test_compact_paths_still_reports_a_link_to_another_file() {
4489 let temp_dir = tempdir().unwrap();
4490 let sub_dir = temp_dir.path().join("sub");
4491 std::fs::create_dir_all(&sub_dir).unwrap();
4492 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4493
4494 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4495 let config = MD057Config {
4496 self_referential_links: true,
4497 compact_paths: true,
4498 ..Default::default()
4499 };
4500 let result = check_as_file(&sub_dir, "test.md", content, config);
4501
4502 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4503 assert_eq!(
4504 result[0].message,
4505 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4506 );
4507 }
4508
4509 #[test]
4510 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4511 let temp_dir = tempdir().unwrap();
4512 let content = "# Title\n\nSee [this file](test#title).\n";
4513 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4514
4515 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4516 assert_eq!(
4517 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4518 Some("#title"),
4519 "Got: {result:?}"
4520 );
4521 }
4522
4523 #[test]
4524 fn test_a_reference_definition_pointing_at_its_own_file() {
4525 let temp_dir = tempdir().unwrap();
4526 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4527 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4528
4529 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4530 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4531 assert_eq!(fix.replacement, "#level-2-heading");
4532 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4533 }
4534
4535 #[test]
4536 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4537 let temp_dir = tempdir().unwrap();
4538 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4539 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4540
4541 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4542 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4543 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4546 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4547 .fix(&crate::lint_context::LintContext::new(
4548 content,
4549 crate::config::MarkdownFlavor::Standard,
4550 Some(temp_dir.path().join("test.md")),
4551 ))
4552 .unwrap();
4553 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4554 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4555 }
4556
4557 #[test]
4558 fn test_a_self_link_resolved_through_a_search_path() {
4559 let temp_dir = tempdir().unwrap();
4560 let guide_dir = temp_dir.path().join("docs/guide");
4561 std::fs::create_dir_all(&guide_dir).unwrap();
4562 let config = MD057Config {
4563 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4564 ..enabled()
4565 };
4566 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4567 let result = check_as_file(&guide_dir, "test.md", content, config);
4568
4569 assert_eq!(
4570 result.len(),
4571 1,
4572 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4573 );
4574 assert_eq!(
4575 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4576 Some("#title"),
4577 "Got: {result:?}"
4578 );
4579 }
4580
4581 #[test]
4582 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4583 let temp_dir = tempdir().unwrap();
4584 let guide_dir = temp_dir.path().join("docs/guide");
4585 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4586 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4587 let config = MD057Config {
4588 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4589 ..enabled()
4590 };
4591 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4592 let result = check_as_file(&guide_dir, "test.md", content, config);
4593
4594 assert!(
4595 result.is_empty(),
4596 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4597 );
4598 }
4599
4600 #[test]
4601 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4602 let temp_dir = tempdir().unwrap();
4603 let content = "# Title\n\n\n";
4604 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4605
4606 assert!(
4607 result.is_empty(),
4608 "An image is not a link the reader follows. Got: {result:?}"
4609 );
4610 }
4611
4612 #[test]
4613 fn test_a_query_string_is_reported_without_a_suggestion() {
4614 let temp_dir = tempdir().unwrap();
4615 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4616 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4617
4618 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4619 assert!(
4620 result[0].fix.is_none(),
4621 "A query does not survive losing its path. Got: {result:?}"
4622 );
4623 }
4624
4625 #[test]
4626 fn test_fix_rewrites_the_document_and_settles() {
4627 let temp_dir = tempdir().unwrap();
4628 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4629 let source_file = temp_dir.path().join("test.md");
4630 std::fs::write(&source_file, content).unwrap();
4631
4632 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4633 let ctx = crate::lint_context::LintContext::new(
4634 content,
4635 crate::config::MarkdownFlavor::Standard,
4636 Some(source_file.clone()),
4637 );
4638 let fixed = rule.fix(&ctx).unwrap();
4639 assert_eq!(
4640 fixed,
4641 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4642 );
4643
4644 let refixed =
4645 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4646 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4647 }
4648
4649 #[test]
4650 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4651 let unfixable = MD057ExistingRelativeLinks::default();
4652 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4653
4654 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4655 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4656 }
4657
4658 #[test]
4659 fn test_the_option_is_read_from_kebab_and_snake_case() {
4660 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4661 assert!(kebab.self_referential_links);
4662
4663 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4664 assert!(snake.self_referential_links);
4665 }
4666
4667 fn front_matter_checked() -> MD057Config {
4668 MD057Config {
4669 check_frontmatter: true,
4670 ..Default::default()
4671 }
4672 }
4673
4674 #[test]
4675 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4676 let temp_dir = tempdir().unwrap();
4677 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4678 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4679
4680 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4681 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4682 assert_eq!(result[0].line, 2);
4683 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4684 assert_eq!(result[0].end_column, 23);
4685 }
4686
4687 #[test]
4688 fn test_frontmatter_paths_are_not_checked_by_default() {
4689 let temp_dir = tempdir().unwrap();
4690 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4691 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4692
4693 assert!(
4694 result.is_empty(),
4695 "Frontmatter is only checked on request. Got: {result:?}"
4696 );
4697 }
4698
4699 #[test]
4700 fn test_an_existing_frontmatter_path_is_not_reported() {
4701 let temp_dir = tempdir().unwrap();
4702 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4703 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4704 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4705
4706 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4707 assert_eq!(result[0].line, 3);
4708 }
4709
4710 #[test]
4711 fn test_an_ignored_frontmatter_field_is_not_checked() {
4712 let temp_dir = tempdir().unwrap();
4713 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4714 let config = MD057Config {
4715 check_frontmatter: true,
4716 ignore_frontmatter_fields: vec!["Image".to_string()],
4717 ..Default::default()
4718 };
4719 let result = check_as_file(temp_dir.path(), "test.md", content, config);
4720
4721 assert_eq!(
4722 result.len(),
4723 1,
4724 "The ignored field is skipped and the other is not. Got: {result:?}"
4725 );
4726 assert_eq!(result[0].line, 3);
4727 }
4728
4729 #[test]
4730 fn test_an_external_frontmatter_url_is_not_reported() {
4731 let temp_dir = tempdir().unwrap();
4732 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4733 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4734
4735 assert!(
4736 result.is_empty(),
4737 "An external URL has no local target. Got: {result:?}"
4738 );
4739 }
4740
4741 #[test]
4742 fn test_a_frontmatter_fragment_is_left_to_md051() {
4743 let temp_dir = tempdir().unwrap();
4744 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4745 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4746
4747 assert!(
4748 result.is_empty(),
4749 "A fragment names a heading, not a file. Got: {result:?}"
4750 );
4751 }
4752
4753 #[test]
4754 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4755 let temp_dir = tempdir().unwrap();
4756 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4757
4758 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4759 assert!(
4760 ignored.is_empty(),
4761 "Absolute paths are ignored by default. Got: {ignored:?}"
4762 );
4763
4764 let warning_config = MD057Config {
4765 check_frontmatter: true,
4766 absolute_links: AbsoluteLinksOption::Warn,
4767 ..Default::default()
4768 };
4769 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4770 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4771 assert_eq!(
4772 warned[0].message,
4773 "Absolute link '/docs/guide.md' cannot be validated locally"
4774 );
4775 }
4776
4777 #[test]
4778 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4779 let temp_dir = tempdir().unwrap();
4780 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4781 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4782 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4783
4784 assert_eq!(
4785 result.len(),
4786 1,
4787 "A query names no file, so only the missing target is reported. Got: {result:?}"
4788 );
4789 assert_eq!(result[0].line, 2);
4790 assert_eq!(
4791 result[0].message,
4792 "Relative link 'docs/missing.md?raw=true' does not exist"
4793 );
4794 }
4795
4796 #[test]
4797 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4798 let temp_dir = tempdir().unwrap();
4799 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4800 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4801
4802 assert!(
4803 result.is_empty(),
4804 "Only path-shaped values are destinations. Got: {result:?}"
4805 );
4806 }
4807}