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::{FileIndex, extract_cross_file_links, normalize_relative_path};
12use pulldown_cmark::LinkType;
13use regex::Regex;
14use std::collections::{HashMap, HashSet};
15use std::env;
16use std::path::{Path, PathBuf};
17use std::sync::LazyLock;
18use std::sync::{Arc, Mutex};
19
20mod md057_config;
21use crate::utils::mkdocs_config::resolve_docs_dir;
22use crate::utils::obsidian_config::resolve_attachment_folder;
23use crate::utils::project_root::discover_project_root_from;
24pub use md057_config::{AbsoluteLinksOption, MD057Config};
25
26static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
28 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
29
30fn reset_file_existence_cache() {
32 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
33 cache.clear();
34 }
35}
36
37fn file_exists_with_cache(path: &Path) -> bool {
39 match FILE_EXISTENCE_CACHE.lock() {
40 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
41 Err(_) => path.exists(), }
43}
44
45fn file_exists_or_markdown_extension(path: &Path) -> bool {
48 resolve_existing_target(path).is_some()
49}
50
51fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
58 if file_exists_with_cache(path) {
60 return Some(path.to_path_buf());
61 }
62
63 if path.extension().is_none() {
65 for ext in MARKDOWN_EXTENSIONS {
66 let path_with_ext = path.with_extension(&ext[1..]);
68 if file_exists_with_cache(&path_with_ext) {
69 return Some(path_with_ext);
70 }
71 }
72 }
73
74 None
75}
76
77static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
79
80static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
84 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
85
86static URL_EXTRACT_REGEX: LazyLock<Regex> =
89 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
90
91static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
95 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
96
97static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
99
100static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
106
107#[inline]
110fn hex_digit_to_value(byte: u8) -> Option<u8> {
111 match byte {
112 b'0'..=b'9' => Some(byte - b'0'),
113 b'a'..=b'f' => Some(byte - b'a' + 10),
114 b'A'..=b'F' => Some(byte - b'A' + 10),
115 _ => None,
116 }
117}
118
119const MARKDOWN_EXTENSIONS: &[&str] = &[
121 ".md",
122 ".markdown",
123 ".mdx",
124 ".mkd",
125 ".mkdn",
126 ".mdown",
127 ".mdwn",
128 ".qmd",
129 ".rmd",
130];
131
132#[derive(Debug, PartialEq, Eq)]
134enum SelfReferentialLink {
135 WholeFile,
138 Fragment(String),
141}
142
143#[derive(Debug, Clone)]
145pub struct MD057ExistingRelativeLinks {
146 base_path: Arc<Mutex<Option<PathBuf>>>,
151 config: MD057Config,
153}
154
155impl Default for MD057ExistingRelativeLinks {
156 fn default() -> Self {
157 Self {
158 base_path: Arc::new(Mutex::new(None)),
159 config: MD057Config::default(),
160 }
161 }
162}
163
164impl MD057ExistingRelativeLinks {
165 pub fn new() -> Self {
167 Self::default()
168 }
169
170 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
172 let path = path.as_ref();
173 let dir_path = if path.is_file() {
174 path.parent().map(std::path::Path::to_path_buf)
175 } else {
176 Some(path.to_path_buf())
177 };
178
179 if let Ok(mut guard) = self.base_path.lock() {
180 *guard = dir_path;
181 }
182 self
183 }
184
185 pub fn from_config_struct(config: MD057Config) -> Self {
186 Self {
187 base_path: Arc::new(Mutex::new(None)),
188 config,
189 }
190 }
191
192 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
196 if Path::new(path_str).is_absolute() {
197 PathBuf::from(path_str)
198 } else {
199 project_root.join(path_str)
200 }
201 }
202
203 #[inline]
215 fn is_external_url(&self, url: &str) -> bool {
216 if url.is_empty() {
217 return false;
218 }
219
220 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
222 return true;
223 }
224
225 if url.starts_with("{{") || url.starts_with("{%") {
228 return true;
229 }
230
231 if url.contains('@') {
234 return true; }
236
237 if !url.contains('/') && url.ends_with(".com") {
247 return true;
248 }
249
250 if url.starts_with('~') || url.starts_with('@') {
254 return true;
255 }
256
257 false
259 }
260
261 #[inline]
263 fn is_fragment_only_link(&self, url: &str) -> bool {
264 url.starts_with('#')
265 }
266
267 #[inline]
270 fn is_absolute_path(url: &str) -> bool {
271 url.starts_with('/')
272 }
273
274 fn url_decode(path: &str) -> String {
278 if !path.contains('%') {
280 return path.to_string();
281 }
282
283 let bytes = path.as_bytes();
284 let mut result = Vec::with_capacity(bytes.len());
285 let mut i = 0;
286
287 while i < bytes.len() {
288 if bytes[i] == b'%' && i + 2 < bytes.len() {
289 let hex1 = bytes[i + 1];
291 let hex2 = bytes[i + 2];
292 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
293 result.push(d1 * 16 + d2);
294 i += 3;
295 continue;
296 }
297 }
298 result.push(bytes[i]);
299 i += 1;
300 }
301
302 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
304 }
305
306 fn strip_query_and_fragment(url: &str) -> &str {
314 let query_pos = url.find('?');
317 let fragment_pos = url.find('#');
318
319 match (query_pos, fragment_pos) {
320 (Some(q), Some(f)) => {
321 &url[..q.min(f)]
323 }
324 (Some(q), None) => &url[..q],
325 (None, Some(f)) => &url[..f],
326 (None, None) => url,
327 }
328 }
329
330 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
332 base_path.join(link)
333 }
334
335 fn compute_search_paths(
340 &self,
341 flavor: crate::config::MarkdownFlavor,
342 source_file: Option<&Path>,
343 base_path: &Path,
344 project_root: &Path,
345 ) -> Vec<PathBuf> {
346 let mut paths = Vec::new();
347
348 if flavor == crate::config::MarkdownFlavor::Obsidian
350 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
351 && attachment_dir != *base_path
352 {
353 paths.push(attachment_dir);
354 }
355
356 for search_path in &self.config.search_paths {
360 let resolved = Self::resolve_against_project_root(search_path, project_root);
361 if resolved != *base_path && !paths.contains(&resolved) {
362 paths.push(resolved);
363 }
364 }
365
366 paths
367 }
368
369 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
371 search_paths.iter().any(|dir| {
372 let candidate = dir.join(decoded_path);
373 file_exists_or_markdown_extension(&candidate)
374 })
375 }
376
377 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
383 if !self.config.compact_paths {
384 return None;
385 }
386
387 let path_end = url
389 .find('?')
390 .unwrap_or(url.len())
391 .min(url.find('#').unwrap_or(url.len()));
392 let path_part = &url[..path_end];
393 let suffix = &url[path_end..];
394
395 let decoded_path = Self::url_decode(path_part);
397
398 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
399 }
400
401 fn self_referential_link(
413 &self,
414 url: &str,
415 base_path: &Path,
416 search_paths: &[PathBuf],
417 source_file: Option<&Path>,
418 ) -> Option<SelfReferentialLink> {
419 if !self.config.self_referential_links {
420 return None;
421 }
422 let source_file = source_file?;
423
424 let path_part = Self::strip_query_and_fragment(url);
425 if path_part.is_empty() {
426 return None;
427 }
428 let suffix = &url[path_part.len()..];
429
430 let decoded_path = Self::url_decode(path_part);
431 let resolved = std::iter::once(base_path)
435 .chain(search_paths.iter().map(PathBuf::as_path))
436 .find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
437 if !Self::is_same_file(&resolved, source_file) {
438 return None;
439 }
440
441 match suffix.strip_prefix('#') {
445 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
446 _ => Some(SelfReferentialLink::WholeFile),
447 }
448 }
449
450 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
457 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
458 let label_end = Self::label_end(def)?;
459 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
460 title.saturating_sub(ref_def.byte_offset).min(def.len())
461 });
462 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
463 let start = ref_def.byte_offset + offset;
464 Some(start..start + ref_def.url.len())
465 }
466
467 fn label_end(def: &str) -> Option<usize> {
472 let bytes = def.as_bytes();
473 let mut i = 0;
474 while i < bytes.len() {
475 match bytes[i] {
476 b'\\' => i += 2,
477 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
478 _ => i += 1,
479 }
480 }
481 None
482 }
483
484 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
488 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
489 }
490
491 fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
494 match self.config.absolute_links {
495 AbsoluteLinksOption::Ignore => None,
496 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
497 AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
498 AbsoluteLinksOption::RelativeToRoots => {
499 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
500 }
501 }
502 }
503
504 fn check_front_matter(
511 &self,
512 ctx: &crate::lint_context::LintContext,
513 base_path: &Path,
514 search_paths: &[PathBuf],
515 project_root: &Path,
516 warnings: &mut Vec<LintWarning>,
517 ) {
518 if !self.config.check_frontmatter {
519 return;
520 }
521
522 let ignored: HashSet<String> = self
523 .config
524 .ignore_frontmatter_fields
525 .iter()
526 .map(|field| field.to_lowercase())
527 .collect();
528
529 for link in frontmatter_values::link_destinations(ctx) {
530 if link.field_is_in(&ignored) {
531 continue;
532 }
533
534 let line = ctx.lines[link.line - 1].content(ctx.content);
535 let url = &line[link.range.clone()];
536
537 if self.is_external_url(url) || self.is_fragment_only_link(url) {
540 continue;
541 }
542
543 let column = byte_to_char_count(line, link.range.start);
544 let end_column = column + url.chars().count();
545
546 if Self::is_absolute_path(url) {
547 if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
548 warnings.push(LintWarning {
549 rule_name: Some(self.name().to_string()),
550 line: link.line,
551 column,
552 end_line: link.line,
553 end_column,
554 message,
555 severity: Severity::Warning,
556 fix: None,
557 });
558 }
559 continue;
560 }
561
562 if Self::relative_target_exists(url, base_path, search_paths) {
563 continue;
564 }
565
566 warnings.push(LintWarning {
567 rule_name: Some(self.name().to_string()),
568 line: link.line,
569 column,
570 end_line: link.line,
571 end_column,
572 message: format!("Relative link '{url}' does not exist"),
573 severity: Severity::Error,
574 fix: None,
575 });
576 }
577 }
578
579 fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
586 let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
587 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
588
589 if file_exists_or_markdown_extension(&resolved_path) {
591 return true;
592 }
593
594 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
595 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
596 && let (Some(stem), Some(parent)) = (
597 resolved_path.file_stem().and_then(|s| s.to_str()),
598 resolved_path.parent(),
599 )
600 && MARKDOWN_EXTENSIONS
601 .iter()
602 .any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
603 {
604 return true;
605 }
606
607 Self::exists_in_search_paths(&decoded_path, search_paths)
608 }
609
610 fn produces_fixes(&self) -> bool {
614 self.config.compact_paths || self.config.self_referential_links
615 }
616
617 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
619 match self_link {
620 SelfReferentialLink::Fragment(fragment) => {
621 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
622 }
623 SelfReferentialLink::WholeFile => {
624 format!("Relative link '{url}' points to the file it is in")
625 }
626 }
627 }
628
629 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
635 if resolved.file_name() != source_file.file_name() {
637 return false;
638 }
639 match (resolved.canonicalize(), source_file.canonicalize()) {
640 (Ok(link), Ok(source)) => link == source,
641 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
642 }
643 }
644
645 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
651 let Some(docs_dir) = resolve_docs_dir(source_path) else {
652 return Some(format!(
653 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
654 ));
655 };
656
657 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
658
659 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
662 Resolution::Found => None,
663 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
664 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
665 resolved.display()
666 )),
667 Resolution::NotFound { resolved } => Some(format!(
668 "Absolute link '{url}' resolves to '{}' which does not exist",
669 resolved.display()
670 )),
671 }
672 }
673
674 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
683 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
684
685 for root in roots {
686 let root_path = Self::resolve_against_project_root(root, project_root);
687 if matches!(
690 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
691 Resolution::Found
692 ) {
693 return None;
694 }
695 }
696
697 if matches!(
698 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
700 Resolution::Found
701 ) {
702 return None;
703 }
704
705 let msg = if roots.is_empty() {
706 format!("Absolute link '{url}' was not found under the project root")
707 } else {
708 format!("Absolute link '{url}' was not found under any configured root or the project root")
709 };
710 Some(msg)
711 }
712
713 fn prepare_absolute_url(url: &str) -> (String, bool) {
717 let relative_url = url.trim_start_matches('/');
718 let file_path = Self::strip_query_and_fragment(relative_url);
719 let decoded = Self::url_decode(file_path);
720 let is_directory_link = url.ends_with('/') || decoded.is_empty();
721 (decoded, is_directory_link)
722 }
723
724 fn resolve_under_root_with_opts(
746 root_path: &Path,
747 decoded: &str,
748 is_directory_link: bool,
749 require_index_for_dirs: bool,
750 ) -> Resolution {
751 let resolved = root_path.join(decoded);
752
753 let is_dir = resolved.is_dir();
754
755 if is_directory_link || (require_index_for_dirs && is_dir) {
760 let index_path = resolved.join("index.md");
761 if file_exists_with_cache(&index_path) {
762 return Resolution::Found;
763 }
764 if is_dir {
765 return Resolution::DirectoryWithoutIndex { resolved };
766 }
767 }
768
769 let decoded_has_trailing_slash = decoded.ends_with('/');
775 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
776 return Resolution::Found;
777 }
778
779 if file_exists_or_markdown_extension(&resolved) {
780 return Resolution::Found;
781 }
782
783 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
786 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
787 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
788 {
789 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
790 let source_path = parent.join(format!("{stem}{md_ext}"));
791 file_exists_with_cache(&source_path)
792 });
793 if has_md_source {
794 return Resolution::Found;
795 }
796 }
797
798 Resolution::NotFound { resolved }
799 }
800}
801
802enum Resolution {
806 Found,
807 DirectoryWithoutIndex { resolved: PathBuf },
808 NotFound { resolved: PathBuf },
809}
810
811fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
822 let caps = re.captures_at(line, expected_start)?;
823 if caps.get(0)?.start() != expected_start {
824 return None;
825 }
826 Some(caps)
827}
828
829impl Rule for MD057ExistingRelativeLinks {
830 fn name(&self) -> &'static str {
831 "MD057"
832 }
833
834 fn description(&self) -> &'static str {
835 "Relative links should point to existing files"
836 }
837
838 fn category(&self) -> RuleCategory {
839 RuleCategory::Link
840 }
841
842 fn skippable_by_category(&self) -> bool {
843 !self.config.check_frontmatter
846 }
847
848 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
849 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
850 }
851
852 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
853 let content = ctx.content;
854
855 if content.is_empty() {
856 return Ok(Vec::new());
857 }
858
859 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
863 if !has_body_links && !self.checks_front_matter_of(ctx) {
864 return Ok(Vec::new());
865 }
866
867 reset_file_existence_cache();
869
870 let mut warnings = Vec::new();
871
872 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
876
877 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
881
882 let self_path: Option<PathBuf> = ctx
885 .source_file
886 .as_ref()
887 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.clone()));
888
889 let base_path: Option<PathBuf> = {
893 if explicit_base.is_some() {
894 explicit_base
895 } else if let Some(ref resolved_file) = self_path {
896 resolved_file
900 .parent()
901 .map(std::path::Path::to_path_buf)
902 .or_else(|| Some(CURRENT_DIR.clone()))
903 } else {
904 None
906 }
907 };
908
909 let Some(base_path) = base_path else {
911 return Ok(warnings);
912 };
913
914 let extra_search_paths =
916 self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
917
918 if !ctx.links.is_empty() {
920 let line_index = &ctx.line_index;
922
923 let lines = ctx.raw_lines();
925
926 let mut processed_lines = std::collections::HashSet::new();
929
930 for link in &ctx.links {
931 let line_idx = link.line - 1;
932 if line_idx >= lines.len() {
933 continue;
934 }
935
936 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
938 continue;
939 }
940
941 if !processed_lines.insert(line_idx) {
943 continue;
944 }
945
946 let line = lines[line_idx];
947
948 if !line.contains("](") {
950 continue;
951 }
952
953 for link_match in LINK_START_REGEX.find_iter(line) {
955 if link_match.as_str().starts_with('!') {
962 let escapes = line[..link_match.start()]
963 .bytes()
964 .rev()
965 .take_while(|&b| b == b'\\')
966 .count();
967 if escapes % 2 == 0 {
968 continue;
969 }
970 }
971
972 let start_pos = link_match.start();
973 let end_pos = link_match.end();
974
975 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
977 let absolute_start_pos = line_start_byte + start_pos;
978
979 if ctx.is_in_code_span_byte(absolute_start_pos) {
981 continue;
982 }
983
984 if ctx.is_in_math_span(absolute_start_pos) {
986 continue;
987 }
988
989 if ctx.is_in_shortcode(absolute_start_pos) {
994 continue;
995 }
996
997 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1004 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1005 .or_else(|| {
1006 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1007 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1008 });
1009
1010 if let Some((caps, url_group)) = caps_and_url {
1011 let url = url_group.as_str().trim();
1012
1013 if url.is_empty() {
1015 continue;
1016 }
1017
1018 if url.starts_with('`') && url.ends_with('`') {
1022 continue;
1023 }
1024
1025 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1027 continue;
1028 }
1029
1030 if Self::is_absolute_path(url) {
1032 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1033 warnings.push(LintWarning {
1034 rule_name: Some(self.name().to_string()),
1035 line: link.line,
1036 column: byte_to_char_count(line, url_group.start()),
1037 end_line: link.line,
1038 end_column: byte_to_char_count(line, url_group.end()),
1039 message,
1040 severity: Severity::Warning,
1041 fix: None,
1042 });
1043 }
1044 continue;
1045 }
1046
1047 let full_url_for_compact = if let Some(frag) = caps.get(2) {
1051 format!("{url}{}", frag.as_str())
1052 } else {
1053 url.to_string()
1054 };
1055 if let Some(self_link) = self.self_referential_link(
1060 &full_url_for_compact,
1061 &base_path,
1062 &extra_search_paths,
1063 self_path.as_deref(),
1064 ) {
1065 let url_start = url_group.start();
1066 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1067 let fix_byte_start = line_start_byte + url_start;
1068 let fix_byte_end = line_start_byte + url_end;
1069 warnings.push(LintWarning {
1070 rule_name: Some(self.name().to_string()),
1071 line: link.line,
1072 column: byte_to_char_count(line, url_start),
1073 end_line: link.line,
1074 end_column: byte_to_char_count(line, url_end),
1075 message: Self::self_referential_message(&full_url_for_compact, &self_link),
1076 severity: Severity::Warning,
1077 fix: match &self_link {
1078 SelfReferentialLink::Fragment(fragment) => {
1079 Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1080 }
1081 SelfReferentialLink::WholeFile => None,
1082 },
1083 });
1084 continue;
1085 }
1086
1087 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1088 let url_start = url_group.start();
1089 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1090 let fix_byte_start = line_start_byte + url_start;
1091 let fix_byte_end = line_start_byte + url_end;
1092 warnings.push(LintWarning {
1093 rule_name: Some(self.name().to_string()),
1094 line: link.line,
1095 column: byte_to_char_count(line, url_start),
1096 end_line: link.line,
1097 end_column: byte_to_char_count(line, url_end),
1098 message: format!(
1099 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1100 ),
1101 severity: Severity::Warning,
1102 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1103 });
1104 }
1105
1106 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1107 continue;
1108 }
1109
1110 let url_start = url_group.start();
1114 let url_end = url_group.end();
1115
1116 warnings.push(LintWarning {
1117 rule_name: Some(self.name().to_string()),
1118 line: link.line,
1119 column: byte_to_char_count(line, url_start),
1120 end_line: link.line,
1121 end_column: byte_to_char_count(line, url_end),
1122 message: format!("Relative link '{url}' does not exist"),
1123 severity: Severity::Error,
1124 fix: None,
1125 });
1126 }
1127 }
1128 }
1129 }
1130
1131 for image in &ctx.images {
1133 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1135 continue;
1136 }
1137
1138 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1142 continue;
1143 }
1144
1145 if ctx.is_in_shortcode(image.byte_offset) {
1148 continue;
1149 }
1150
1151 let url = image.url.as_ref();
1152
1153 if url.is_empty() {
1155 continue;
1156 }
1157
1158 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1160 continue;
1161 }
1162
1163 if Self::is_absolute_path(url) {
1165 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1166 warnings.push(LintWarning {
1167 rule_name: Some(self.name().to_string()),
1168 line: image.line,
1169 column: image.start_col + 1,
1170 end_line: image.line,
1171 end_column: image.start_col + 1 + url.chars().count(),
1172 message,
1173 severity: Severity::Warning,
1174 fix: None,
1175 });
1176 }
1177 continue;
1178 }
1179
1180 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1182 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1185 let fix_byte_start = image.byte_offset + url_offset;
1186 let fix_byte_end = fix_byte_start + url.len();
1187 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1188 });
1189
1190 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1191 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
1192 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1195 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1196 });
1197 warnings.push(LintWarning {
1198 rule_name: Some(self.name().to_string()),
1199 line: image.line,
1200 column: url_col,
1201 end_line: image.line,
1202 end_column: url_col + url.chars().count(),
1203 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1204 severity: Severity::Warning,
1205 fix,
1206 });
1207 }
1208
1209 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1210 continue;
1211 }
1212
1213 warnings.push(LintWarning {
1216 rule_name: Some(self.name().to_string()),
1217 line: image.line,
1218 column: image.start_col + 1,
1219 end_line: image.line,
1220 end_column: image.start_col + 1 + url.chars().count(),
1221 message: format!("Relative link '{url}' does not exist"),
1222 severity: Severity::Error,
1223 fix: None,
1224 });
1225 }
1226
1227 for ref_def in &ctx.reference_defs {
1229 let url = &ref_def.url;
1230
1231 if url.is_empty() {
1233 continue;
1234 }
1235
1236 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1238 continue;
1239 }
1240
1241 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1245 let (line, col) = url_range
1246 .as_ref()
1247 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1248 let end_col = col + url.chars().count();
1249
1250 if Self::is_absolute_path(url) {
1252 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1253 warnings.push(LintWarning {
1254 rule_name: Some(self.name().to_string()),
1255 line,
1256 column: col,
1257 end_line: line,
1258 end_column: end_col,
1259 message,
1260 severity: Severity::Warning,
1261 fix: None,
1262 });
1263 }
1264 continue;
1265 }
1266
1267 if let Some(self_link) =
1269 self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
1270 {
1271 warnings.push(LintWarning {
1272 rule_name: Some(self.name().to_string()),
1273 line,
1274 column: col,
1275 end_line: line,
1276 end_column: end_col,
1277 message: Self::self_referential_message(url, &self_link),
1278 severity: Severity::Warning,
1279 fix: match (&self_link, &url_range) {
1280 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1281 Some(Fix::new(range.clone(), fragment.clone()))
1282 }
1283 _ => None,
1284 },
1285 });
1286 continue;
1287 }
1288
1289 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1291 warnings.push(LintWarning {
1292 rule_name: Some(self.name().to_string()),
1293 line,
1294 column: col,
1295 end_line: line,
1296 end_column: end_col,
1297 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1298 severity: Severity::Warning,
1299 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1300 });
1301 }
1302
1303 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1304 continue;
1305 }
1306
1307 warnings.push(LintWarning {
1309 rule_name: Some(self.name().to_string()),
1310 line,
1311 column: col,
1312 end_line: line,
1313 end_column: end_col,
1314 message: format!("Relative link '{url}' does not exist"),
1315 severity: Severity::Error,
1316 fix: None,
1317 });
1318 }
1319
1320 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1321
1322 Ok(warnings)
1323 }
1324
1325 fn fix_capability(&self) -> FixCapability {
1326 if self.produces_fixes() {
1327 FixCapability::ConditionallyFixable
1328 } else {
1329 FixCapability::Unfixable
1330 }
1331 }
1332
1333 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1334 if !self.produces_fixes() {
1335 return Ok(ctx.content.to_string());
1336 }
1337
1338 let warnings = self.check(ctx)?;
1339 let warnings =
1340 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1341 let mut content = ctx.content.to_string();
1342
1343 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1345 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1346
1347 let mut last_applied_start: Option<usize> = None;
1353 for fix in fixes {
1354 if let Some(prev_start) = last_applied_start
1355 && fix.range.end > prev_start
1356 {
1357 continue;
1358 }
1359 if fix.range.end <= content.len() {
1360 content.replace_range(fix.range.clone(), &fix.replacement);
1361 last_applied_start = Some(fix.range.start);
1362 }
1363 }
1364
1365 Ok(content)
1366 }
1367
1368 fn as_any(&self) -> &dyn std::any::Any {
1369 self
1370 }
1371
1372 crate::impl_rule_config_sections!(MD057Config);
1373
1374 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1375 where
1376 Self: Sized,
1377 {
1378 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1379 Box::new(Self::from_config_struct(rule_config))
1383 }
1384
1385 fn cross_file_scope(&self) -> CrossFileScope {
1386 CrossFileScope::Workspace
1387 }
1388
1389 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1390 let links = extract_cross_file_links(ctx);
1393 for link in links.relative {
1394 index.add_cross_file_link(link);
1395 }
1396 for link in links.root_relative {
1399 index.add_root_relative_link(link);
1400 }
1401 }
1402
1403 fn cross_file_check(
1404 &self,
1405 _file_path: &Path,
1406 _file_index: &FileIndex,
1407 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1408 ) -> LintResult {
1409 Ok(Vec::new())
1419 }
1420}
1421
1422fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1427 let from_components: Vec<_> = from_dir.components().collect();
1428 let to_components: Vec<_> = to_path.components().collect();
1429
1430 let common_len = from_components
1432 .iter()
1433 .zip(to_components.iter())
1434 .take_while(|(a, b)| a == b)
1435 .count();
1436
1437 let mut result = PathBuf::new();
1438
1439 for _ in common_len..from_components.len() {
1441 result.push("..");
1442 }
1443
1444 for component in &to_components[common_len..] {
1446 result.push(component);
1447 }
1448
1449 result
1450}
1451
1452fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1458 let link_path = Path::new(raw_link_path);
1459
1460 let has_traversal = link_path
1462 .components()
1463 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1464
1465 if !has_traversal {
1466 return None;
1467 }
1468
1469 let combined = source_dir.join(link_path);
1471 let normalized_target = normalize_relative_path(&combined);
1472
1473 let normalized_source = normalize_relative_path(source_dir);
1475 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1476
1477 if shortest != link_path {
1479 let compact = shortest.to_string_lossy().to_string();
1480 if compact.is_empty() {
1482 return None;
1483 }
1484 Some(compact.replace('\\', "/"))
1486 } else {
1487 None
1488 }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::*;
1494 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1495 use std::fs::File;
1496 use std::io::Write;
1497 use tempfile::tempdir;
1498
1499 #[test]
1500 fn test_strip_query_and_fragment() {
1501 assert_eq!(
1503 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1504 "file.png"
1505 );
1506 assert_eq!(
1507 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1508 "file.png"
1509 );
1510 assert_eq!(
1511 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1512 "file.png"
1513 );
1514
1515 assert_eq!(
1517 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1518 "file.md"
1519 );
1520 assert_eq!(
1521 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1522 "file.md"
1523 );
1524
1525 assert_eq!(
1527 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1528 "file.md"
1529 );
1530
1531 assert_eq!(
1533 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1534 "file.png"
1535 );
1536
1537 assert_eq!(
1539 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1540 "path/to/image.png"
1541 );
1542 assert_eq!(
1543 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1544 "path/to/image.png"
1545 );
1546
1547 assert_eq!(
1549 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1550 "file.md"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_url_decode() {
1556 assert_eq!(
1558 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1559 "penguin with space.jpg"
1560 );
1561
1562 assert_eq!(
1564 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1565 "assets/my file name.png"
1566 );
1567
1568 assert_eq!(
1570 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1571 "hello world!.md"
1572 );
1573
1574 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1576
1577 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1579
1580 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1582
1583 assert_eq!(
1585 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1586 "normal-file.md"
1587 );
1588
1589 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1591
1592 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1594
1595 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1597
1598 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1600
1601 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1603
1604 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1606
1607 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1609
1610 assert_eq!(
1612 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1613 "path/to/file.md"
1614 );
1615
1616 assert_eq!(
1618 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1619 "hello world/foo bar.md"
1620 );
1621
1622 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1624
1625 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1627 }
1628
1629 #[test]
1630 fn test_url_encoded_filenames() {
1631 let temp_dir = tempdir().unwrap();
1633 let base_path = temp_dir.path();
1634
1635 let file_with_spaces = base_path.join("penguin with space.jpg");
1637 File::create(&file_with_spaces)
1638 .unwrap()
1639 .write_all(b"image data")
1640 .unwrap();
1641
1642 let subdir = base_path.join("my images");
1644 std::fs::create_dir(&subdir).unwrap();
1645 let nested_file = subdir.join("photo 1.png");
1646 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1647
1648 let content = r#"
1650# Test Document with URL-Encoded Links
1651
1652
1653
1654
1655"#;
1656
1657 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1658
1659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1660 let result = rule.check(&ctx).unwrap();
1661
1662 assert_eq!(
1664 result.len(),
1665 1,
1666 "Should only warn about missing%20file.jpg. Got: {result:?}"
1667 );
1668 assert!(
1669 result[0].message.contains("missing%20file.jpg"),
1670 "Warning should mention the URL-encoded filename"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_external_urls() {
1676 let rule = MD057ExistingRelativeLinks::new();
1677
1678 assert!(rule.is_external_url("https://example.com"));
1680 assert!(rule.is_external_url("http://example.com"));
1681 assert!(rule.is_external_url("ftp://example.com"));
1682 assert!(rule.is_external_url("www.example.com"));
1683 assert!(rule.is_external_url("example.com"));
1684
1685 assert!(rule.is_external_url("file:///path/to/file"));
1687 assert!(rule.is_external_url("smb://server/share"));
1688 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1689 assert!(rule.is_external_url("mailto:user@example.com"));
1690 assert!(rule.is_external_url("tel:+1234567890"));
1691 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1692 assert!(rule.is_external_url("javascript:void(0)"));
1693 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1694 assert!(rule.is_external_url("git://github.com/repo.git"));
1695
1696 assert!(rule.is_external_url("user@example.com"));
1699 assert!(rule.is_external_url("steering@kubernetes.io"));
1700 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1701 assert!(rule.is_external_url("user_name@sub.domain.com"));
1702 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1703
1704 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"));
1715 assert!(!rule.is_external_url("/blog/2024/release.html"));
1716 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1717 assert!(!rule.is_external_url("/pkg/runtime"));
1718 assert!(!rule.is_external_url("/doc/go1compat"));
1719 assert!(!rule.is_external_url("/index.html"));
1720 assert!(!rule.is_external_url("/assets/logo.png"));
1721
1722 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1724 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1725 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1726 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1727 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1728
1729 assert!(rule.is_external_url("~/assets/image.png"));
1732 assert!(rule.is_external_url("~/components/Button.vue"));
1733 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1737 assert!(rule.is_external_url("@images/photo.jpg"));
1738 assert!(rule.is_external_url("@assets/styles.css"));
1739
1740 assert!(!rule.is_external_url("./relative/path.md"));
1742 assert!(!rule.is_external_url("relative/path.md"));
1743 assert!(!rule.is_external_url("../parent/path.md"));
1744 }
1745
1746 #[test]
1747 fn test_dot_com_only_skips_bare_domains() {
1748 let rule = MD057ExistingRelativeLinks::new();
1749
1750 assert!(rule.is_external_url("example.com"));
1752 assert!(rule.is_external_url("sub.example.com"));
1753
1754 assert!(!rule.is_external_url("../../vendor.com"));
1758 assert!(!rule.is_external_url("./vendor.com"));
1759 assert!(!rule.is_external_url("docs/vendor.com"));
1760 }
1761
1762 #[test]
1763 fn test_framework_path_aliases() {
1764 let temp_dir = tempdir().unwrap();
1766 let base_path = temp_dir.path();
1767
1768 let content = r#"
1770# Framework Path Aliases
1771
1772
1773
1774
1775
1776[Link](@/pages/about.md)
1777
1778This is a [real missing link](missing.md) that should be flagged.
1779"#;
1780
1781 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1782
1783 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784 let result = rule.check(&ctx).unwrap();
1785
1786 assert_eq!(
1788 result.len(),
1789 1,
1790 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1791 );
1792 assert!(
1793 result[0].message.contains("missing.md"),
1794 "Warning should be for missing.md"
1795 );
1796 }
1797
1798 #[test]
1799 fn test_url_decode_security_path_traversal() {
1800 let temp_dir = tempdir().unwrap();
1803 let base_path = temp_dir.path();
1804
1805 let file_in_base = base_path.join("safe.md");
1807 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1808
1809 let content = r#"
1814[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1815[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1816[Safe link](safe.md)
1817"#;
1818
1819 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1820
1821 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822 let result = rule.check(&ctx).unwrap();
1823
1824 assert_eq!(
1827 result.len(),
1828 2,
1829 "Should have warnings for traversal attempts. Got: {result:?}"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_url_encoded_utf8_filenames() {
1835 let temp_dir = tempdir().unwrap();
1837 let base_path = temp_dir.path();
1838
1839 let cafe_file = base_path.join("café.md");
1841 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1842
1843 let content = r#"
1844[Café link](caf%C3%A9.md)
1845[Missing unicode](r%C3%A9sum%C3%A9.md)
1846"#;
1847
1848 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1849
1850 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851 let result = rule.check(&ctx).unwrap();
1852
1853 assert_eq!(
1855 result.len(),
1856 1,
1857 "Should only warn about missing résumé.md. Got: {result:?}"
1858 );
1859 assert!(
1860 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1861 "Warning should mention the URL-encoded filename"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_url_encoded_emoji_filenames() {
1867 let temp_dir = tempdir().unwrap();
1870 let base_path = temp_dir.path();
1871
1872 let emoji_dir = base_path.join("👤 Personal");
1874 std::fs::create_dir(&emoji_dir).unwrap();
1875
1876 let file_path = emoji_dir.join("TV Shows.md");
1878 File::create(&file_path)
1879 .unwrap()
1880 .write_all(b"# TV Shows\n\nContent here.")
1881 .unwrap();
1882
1883 let content = r#"
1886# Test Document
1887
1888[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1889[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1890"#;
1891
1892 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1893
1894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896
1897 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1899 assert!(
1900 result[0].message.contains("Missing.md"),
1901 "Warning should be for Missing.md, got: {}",
1902 result[0].message
1903 );
1904 }
1905
1906 #[test]
1907 fn test_no_warnings_without_base_path() {
1908 let rule = MD057ExistingRelativeLinks::new();
1909 let content = "[Link](missing.md)";
1910
1911 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let result = rule.check(&ctx).unwrap();
1913 assert!(result.is_empty(), "Should have no warnings without base path");
1914 }
1915
1916 #[test]
1917 fn test_existing_and_missing_links() {
1918 let temp_dir = tempdir().unwrap();
1920 let base_path = temp_dir.path();
1921
1922 let exists_path = base_path.join("exists.md");
1924 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1925
1926 assert!(exists_path.exists(), "exists.md should exist for this test");
1928
1929 let content = r#"
1931# Test Document
1932
1933[Valid Link](exists.md)
1934[Invalid Link](missing.md)
1935[External Link](https://example.com)
1936[Media Link](image.jpg)
1937 "#;
1938
1939 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1941
1942 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1944 let result = rule.check(&ctx).unwrap();
1945
1946 assert_eq!(result.len(), 2);
1948 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1949 assert!(messages.iter().any(|m| m.contains("missing.md")));
1950 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1951 }
1952
1953 #[test]
1954 fn test_angle_bracket_links() {
1955 let temp_dir = tempdir().unwrap();
1957 let base_path = temp_dir.path();
1958
1959 let exists_path = base_path.join("exists.md");
1961 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1962
1963 let content = r#"
1965# Test Document
1966
1967[Valid Link](<exists.md>)
1968[Invalid Link](<missing.md>)
1969[External Link](<https://example.com>)
1970 "#;
1971
1972 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1974
1975 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976 let result = rule.check(&ctx).unwrap();
1977
1978 assert_eq!(result.len(), 1, "Should have exactly one warning");
1980 assert!(
1981 result[0].message.contains("missing.md"),
1982 "Warning should mention missing.md"
1983 );
1984 }
1985
1986 #[test]
1987 fn test_angle_bracket_links_with_parens() {
1988 let temp_dir = tempdir().unwrap();
1990 let base_path = temp_dir.path();
1991
1992 let app_dir = base_path.join("app");
1994 std::fs::create_dir(&app_dir).unwrap();
1995 let upload_dir = app_dir.join("(upload)");
1996 std::fs::create_dir(&upload_dir).unwrap();
1997 let page_file = upload_dir.join("page.tsx");
1998 File::create(&page_file)
1999 .unwrap()
2000 .write_all(b"export default function Page() {}")
2001 .unwrap();
2002
2003 let content = r#"
2005# Test Document with Paths Containing Parens
2006
2007[Upload Page](<app/(upload)/page.tsx>)
2008[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2009[Missing](<app/(missing)/file.md>)
2010"#;
2011
2012 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2013
2014 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let result = rule.check(&ctx).unwrap();
2016
2017 assert_eq!(
2019 result.len(),
2020 1,
2021 "Should have exactly one warning for missing file. Got: {result:?}"
2022 );
2023 assert!(
2024 result[0].message.contains("app/(missing)/file.md"),
2025 "Warning should mention app/(missing)/file.md"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_all_file_types_checked() {
2031 let temp_dir = tempdir().unwrap();
2033 let base_path = temp_dir.path();
2034
2035 let content = r#"
2037[Image Link](image.jpg)
2038[Video Link](video.mp4)
2039[Markdown Link](document.md)
2040[PDF Link](file.pdf)
2041"#;
2042
2043 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2044
2045 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046 let result = rule.check(&ctx).unwrap();
2047
2048 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2050 }
2051
2052 #[test]
2053 fn test_code_span_detection() {
2054 let rule = MD057ExistingRelativeLinks::new();
2055
2056 let temp_dir = tempdir().unwrap();
2058 let base_path = temp_dir.path();
2059
2060 let rule = rule.with_path(base_path);
2061
2062 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2064
2065 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2066 let result = rule.check(&ctx).unwrap();
2067
2068 assert_eq!(result.len(), 1, "Should only flag the real link");
2070 assert!(result[0].message.contains("nonexistent.md"));
2071 }
2072
2073 #[test]
2074 fn test_inline_code_spans() {
2075 let temp_dir = tempdir().unwrap();
2077 let base_path = temp_dir.path();
2078
2079 let content = r#"
2081# Test Document
2082
2083This is a normal link: [Link](missing.md)
2084
2085This is a code span with a link: `[Link](another-missing.md)`
2086
2087Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2088
2089 "#;
2090
2091 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2093
2094 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096 let result = rule.check(&ctx).unwrap();
2097
2098 assert_eq!(result.len(), 1, "Should have exactly one warning");
2100 assert!(
2101 result[0].message.contains("missing.md"),
2102 "Warning should be for missing.md"
2103 );
2104 assert!(
2105 !result.iter().any(|w| w.message.contains("another-missing.md")),
2106 "Should not warn about link in code span"
2107 );
2108 assert!(
2109 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2110 "Should not warn about link in inline code"
2111 );
2112 }
2113
2114 #[test]
2115 fn test_extensionless_link_resolution() {
2116 let temp_dir = tempdir().unwrap();
2118 let base_path = temp_dir.path();
2119
2120 let page_path = base_path.join("page.md");
2122 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2123
2124 let content = r#"
2126# Test Document
2127
2128[Link without extension](page)
2129[Link with extension](page.md)
2130[Missing link](nonexistent)
2131"#;
2132
2133 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2134
2135 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2141 assert!(
2142 result[0].message.contains("nonexistent"),
2143 "Warning should be for 'nonexistent' not 'page'"
2144 );
2145 }
2146
2147 #[test]
2149 fn test_cross_file_scope() {
2150 let rule = MD057ExistingRelativeLinks::new();
2151 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2152 }
2153
2154 #[test]
2155 fn test_contribute_to_index_extracts_markdown_links() {
2156 let rule = MD057ExistingRelativeLinks::new();
2157 let content = r#"
2158# Document
2159
2160[Link to docs](./docs/guide.md)
2161[Link with fragment](./other.md#section)
2162[External link](https://example.com)
2163[Image link](image.png)
2164[Media file](video.mp4)
2165"#;
2166
2167 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2168 let mut index = FileIndex::new();
2169 rule.contribute_to_index(&ctx, &mut index);
2170
2171 assert_eq!(index.cross_file_links.len(), 2);
2173
2174 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2176 assert_eq!(index.cross_file_links[0].fragment, "");
2177
2178 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2180 assert_eq!(index.cross_file_links[1].fragment, "section");
2181 }
2182
2183 #[test]
2184 fn test_contribute_to_index_skips_external_and_anchors() {
2185 let rule = MD057ExistingRelativeLinks::new();
2186 let content = r#"
2187# Document
2188
2189[External](https://example.com)
2190[Another external](http://example.org)
2191[Fragment only](#section)
2192[FTP link](ftp://files.example.com)
2193[Mail link](mailto:test@example.com)
2194[WWW link](www.example.com)
2195"#;
2196
2197 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198 let mut index = FileIndex::new();
2199 rule.contribute_to_index(&ctx, &mut index);
2200
2201 assert_eq!(index.cross_file_links.len(), 0);
2203 }
2204
2205 #[test]
2206 fn test_cross_file_check_valid_link() {
2207 use crate::workspace_index::WorkspaceIndex;
2208
2209 let rule = MD057ExistingRelativeLinks::new();
2210
2211 let mut workspace_index = WorkspaceIndex::new();
2213 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2214
2215 let mut file_index = FileIndex::new();
2217 file_index.add_cross_file_link(CrossFileLinkIndex {
2218 target_path: "guide.md".to_string(),
2219 fragment: "".to_string(),
2220 line: 5,
2221 column: 1,
2222 origin: LinkOrigin::Body,
2223 });
2224
2225 let warnings = rule
2227 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2228 .unwrap();
2229
2230 assert!(warnings.is_empty());
2232 }
2233
2234 #[test]
2235 fn test_cross_file_check_missing_link() {
2236 use crate::workspace_index::WorkspaceIndex;
2239
2240 let rule = MD057ExistingRelativeLinks::new();
2241 let workspace_index = WorkspaceIndex::new();
2242
2243 let mut file_index = FileIndex::new();
2244 file_index.add_cross_file_link(CrossFileLinkIndex {
2245 target_path: "missing.md".to_string(),
2246 fragment: "".to_string(),
2247 line: 5,
2248 column: 1,
2249 origin: LinkOrigin::Body,
2250 });
2251
2252 let warnings = rule
2253 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2254 .unwrap();
2255
2256 assert!(
2258 warnings.is_empty(),
2259 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_cross_file_check_parent_path() {
2265 use crate::workspace_index::WorkspaceIndex;
2266
2267 let rule = MD057ExistingRelativeLinks::new();
2268
2269 let mut workspace_index = WorkspaceIndex::new();
2271 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2272
2273 let mut file_index = FileIndex::new();
2275 file_index.add_cross_file_link(CrossFileLinkIndex {
2276 target_path: "../readme.md".to_string(),
2277 fragment: "".to_string(),
2278 line: 5,
2279 column: 1,
2280 origin: LinkOrigin::Body,
2281 });
2282
2283 let warnings = rule
2285 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2286 .unwrap();
2287
2288 assert!(warnings.is_empty());
2290 }
2291
2292 #[test]
2293 fn test_cross_file_check_html_link_with_md_source() {
2294 use crate::workspace_index::WorkspaceIndex;
2297
2298 let rule = MD057ExistingRelativeLinks::new();
2299
2300 let mut workspace_index = WorkspaceIndex::new();
2302 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2303
2304 let mut file_index = FileIndex::new();
2306 file_index.add_cross_file_link(CrossFileLinkIndex {
2307 target_path: "guide.html".to_string(),
2308 fragment: "section".to_string(),
2309 line: 10,
2310 column: 5,
2311 origin: LinkOrigin::Body,
2312 });
2313
2314 let warnings = rule
2316 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2317 .unwrap();
2318
2319 assert!(
2321 warnings.is_empty(),
2322 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_cross_file_check_html_link_without_source() {
2328 use crate::workspace_index::WorkspaceIndex;
2332
2333 let rule = MD057ExistingRelativeLinks::new();
2334 let workspace_index = WorkspaceIndex::new();
2335
2336 let mut file_index = FileIndex::new();
2337 file_index.add_cross_file_link(CrossFileLinkIndex {
2338 target_path: "missing.html".to_string(),
2339 fragment: "".to_string(),
2340 line: 10,
2341 column: 5,
2342 origin: LinkOrigin::Body,
2343 });
2344
2345 let warnings = rule
2346 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2347 .unwrap();
2348
2349 assert!(
2351 warnings.is_empty(),
2352 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_normalize_path_function() {
2358 assert_eq!(
2360 normalize_relative_path(Path::new("docs/guide.md")),
2361 PathBuf::from("docs/guide.md")
2362 );
2363
2364 assert_eq!(
2366 normalize_relative_path(Path::new("./docs/guide.md")),
2367 PathBuf::from("docs/guide.md")
2368 );
2369
2370 assert_eq!(
2372 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2373 PathBuf::from("docs/guide.md")
2374 );
2375
2376 assert_eq!(
2378 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2379 PathBuf::from("a/d.md")
2380 );
2381 }
2382
2383 #[test]
2384 fn test_html_link_with_md_source() {
2385 let temp_dir = tempdir().unwrap();
2387 let base_path = temp_dir.path();
2388
2389 let md_file = base_path.join("guide.md");
2391 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2392
2393 let content = r#"
2394[Read the guide](guide.html)
2395[Also here](getting-started.html)
2396"#;
2397
2398 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2399 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2400 let result = rule.check(&ctx).unwrap();
2401
2402 assert_eq!(
2404 result.len(),
2405 1,
2406 "Should only warn about missing source. Got: {result:?}"
2407 );
2408 assert!(result[0].message.contains("getting-started.html"));
2409 }
2410
2411 #[test]
2412 fn test_htm_link_with_md_source() {
2413 let temp_dir = tempdir().unwrap();
2415 let base_path = temp_dir.path();
2416
2417 let md_file = base_path.join("page.md");
2418 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2419
2420 let content = "[Page](page.htm)";
2421
2422 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2423 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2424 let result = rule.check(&ctx).unwrap();
2425
2426 assert!(
2427 result.is_empty(),
2428 "Should not warn when .md source exists for .htm link"
2429 );
2430 }
2431
2432 #[test]
2433 fn test_html_link_finds_various_markdown_extensions() {
2434 let temp_dir = tempdir().unwrap();
2436 let base_path = temp_dir.path();
2437
2438 File::create(base_path.join("doc.md")).unwrap();
2439 File::create(base_path.join("tutorial.mdx")).unwrap();
2440 File::create(base_path.join("guide.markdown")).unwrap();
2441
2442 let content = r#"
2443[Doc](doc.html)
2444[Tutorial](tutorial.html)
2445[Guide](guide.html)
2446"#;
2447
2448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2449 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2450 let result = rule.check(&ctx).unwrap();
2451
2452 assert!(
2453 result.is_empty(),
2454 "Should find all markdown variants as source files. Got: {result:?}"
2455 );
2456 }
2457
2458 #[test]
2459 fn test_html_link_in_subdirectory() {
2460 let temp_dir = tempdir().unwrap();
2462 let base_path = temp_dir.path();
2463
2464 let docs_dir = base_path.join("docs");
2465 std::fs::create_dir(&docs_dir).unwrap();
2466 File::create(docs_dir.join("guide.md"))
2467 .unwrap()
2468 .write_all(b"# Guide")
2469 .unwrap();
2470
2471 let content = "[Guide](docs/guide.html)";
2472
2473 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2474 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2475 let result = rule.check(&ctx).unwrap();
2476
2477 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2478 }
2479
2480 #[test]
2481 fn test_absolute_path_skipped_in_check() {
2482 let temp_dir = tempdir().unwrap();
2485 let base_path = temp_dir.path();
2486
2487 let content = r#"
2488# Test Document
2489
2490[Go Runtime](/pkg/runtime)
2491[Go Runtime with Fragment](/pkg/runtime#section)
2492[API Docs](/api/v1/users)
2493[Blog Post](/blog/2024/release.html)
2494[React Hook](/react/hooks/use-state.html)
2495"#;
2496
2497 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2498 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2499 let result = rule.check(&ctx).unwrap();
2500
2501 assert!(
2503 result.is_empty(),
2504 "Absolute paths should be skipped. Got warnings: {result:?}"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_absolute_path_skipped_in_cross_file_check() {
2510 use crate::workspace_index::WorkspaceIndex;
2512
2513 let rule = MD057ExistingRelativeLinks::new();
2514
2515 let workspace_index = WorkspaceIndex::new();
2517
2518 let mut file_index = FileIndex::new();
2520 file_index.add_cross_file_link(CrossFileLinkIndex {
2521 target_path: "/pkg/runtime.md".to_string(),
2522 fragment: "".to_string(),
2523 line: 5,
2524 column: 1,
2525 origin: LinkOrigin::Body,
2526 });
2527 file_index.add_cross_file_link(CrossFileLinkIndex {
2528 target_path: "/api/v1/users.md".to_string(),
2529 fragment: "section".to_string(),
2530 line: 10,
2531 column: 1,
2532 origin: LinkOrigin::Body,
2533 });
2534
2535 let warnings = rule
2537 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2538 .unwrap();
2539
2540 assert!(
2542 warnings.is_empty(),
2543 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_protocol_relative_url_not_skipped() {
2549 let temp_dir = tempdir().unwrap();
2552 let base_path = temp_dir.path();
2553
2554 let content = r#"
2555# Test Document
2556
2557[External](//example.com/page)
2558[Another](//cdn.example.com/asset.js)
2559"#;
2560
2561 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2562 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2563 let result = rule.check(&ctx).unwrap();
2564
2565 assert!(
2567 result.is_empty(),
2568 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn test_email_addresses_skipped() {
2574 let temp_dir = tempdir().unwrap();
2577 let base_path = temp_dir.path();
2578
2579 let content = r#"
2580# Test Document
2581
2582[Contact](user@example.com)
2583[Steering](steering@kubernetes.io)
2584[Support](john.doe+filter@company.co.uk)
2585[User](user_name@sub.domain.com)
2586"#;
2587
2588 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2589 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2590 let result = rule.check(&ctx).unwrap();
2591
2592 assert!(
2594 result.is_empty(),
2595 "Email addresses should be skipped. Got warnings: {result:?}"
2596 );
2597 }
2598
2599 #[test]
2600 fn test_email_addresses_vs_file_paths() {
2601 let temp_dir = tempdir().unwrap();
2604 let base_path = temp_dir.path();
2605
2606 let content = r#"
2607# Test Document
2608
2609[Email](user@example.com) <!-- Should be skipped (email) -->
2610[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2611[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2612"#;
2613
2614 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2615 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2616 let result = rule.check(&ctx).unwrap();
2617
2618 assert!(
2620 result.is_empty(),
2621 "All email addresses should be skipped. Got: {result:?}"
2622 );
2623 }
2624
2625 #[test]
2626 fn test_diagnostic_position_accuracy() {
2627 let temp_dir = tempdir().unwrap();
2629 let base_path = temp_dir.path();
2630
2631 let content = "prefix [text](missing.md) suffix";
2634 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639 let result = rule.check(&ctx).unwrap();
2640
2641 assert_eq!(result.len(), 1, "Should have exactly one warning");
2642 assert_eq!(result[0].line, 1, "Should be on line 1");
2643 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2644 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2645 }
2646
2647 #[test]
2648 fn test_diagnostic_position_non_ascii_link() {
2649 let temp_dir = tempdir().unwrap();
2652 let base_path = temp_dir.path();
2653
2654 let content = "你好你好[你好](not-exist.md) bar";
2658
2659 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2660 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2661 let result = rule.check(&ctx).unwrap();
2662
2663 assert_eq!(result.len(), 1, "Should have exactly one warning");
2664 assert_eq!(result[0].line, 1, "Should be on line 1");
2665 assert_eq!(
2666 result[0].column, 10,
2667 "Column must be a character offset, not a byte offset"
2668 );
2669 assert_eq!(result[0].end_column, 22, "End column must be character-based");
2670 }
2671
2672 #[test]
2673 fn test_diagnostic_position_angle_brackets() {
2674 let temp_dir = tempdir().unwrap();
2676 let base_path = temp_dir.path();
2677
2678 let content = "[link](<missing.md>)";
2681 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2684 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2685 let result = rule.check(&ctx).unwrap();
2686
2687 assert_eq!(result.len(), 1, "Should have exactly one warning");
2688 assert_eq!(result[0].line, 1, "Should be on line 1");
2689 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2690 }
2691
2692 #[test]
2693 fn test_diagnostic_position_multiline() {
2694 let temp_dir = tempdir().unwrap();
2696 let base_path = temp_dir.path();
2697
2698 let content = r#"# Title
2699Some text on line 2
2700[link on line 3](missing1.md)
2701More text
2702[link on line 5](missing2.md)"#;
2703
2704 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2705 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2706 let result = rule.check(&ctx).unwrap();
2707
2708 assert_eq!(result.len(), 2, "Should have two warnings");
2709
2710 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2712 assert!(result[0].message.contains("missing1.md"));
2713
2714 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2716 assert!(result[1].message.contains("missing2.md"));
2717 }
2718
2719 #[test]
2720 fn test_diagnostic_position_with_spaces() {
2721 let temp_dir = tempdir().unwrap();
2723 let base_path = temp_dir.path();
2724
2725 let content = "[link]( missing.md )";
2726 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2731 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2732 let result = rule.check(&ctx).unwrap();
2733
2734 assert_eq!(result.len(), 1, "Should have exactly one warning");
2735 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2737 }
2738
2739 #[test]
2740 fn test_diagnostic_position_image() {
2741 let temp_dir = tempdir().unwrap();
2743 let base_path = temp_dir.path();
2744
2745 let content = "";
2746
2747 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2748 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2749 let result = rule.check(&ctx).unwrap();
2750
2751 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2752 assert_eq!(result[0].line, 1);
2753 assert!(result[0].column > 0, "Should have valid column position");
2755 assert!(result[0].message.contains("missing.jpg"));
2756 }
2757
2758 #[test]
2759 fn test_diagnostic_position_non_ascii_image() {
2760 let temp_dir = tempdir().unwrap();
2762 let base_path = temp_dir.path();
2763
2764 let content = "你好你好";
2767
2768 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2769 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2770 let result = rule.check(&ctx).unwrap();
2771
2772 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2773 assert_eq!(result[0].line, 1, "Should be on line 1");
2774 assert_eq!(
2775 result[0].column, 5,
2776 "Column must be a character offset, not a byte offset"
2777 );
2778 assert!(result[0].message.contains("not-exist.png"));
2779 }
2780
2781 #[test]
2782 fn test_diagnostic_position_non_ascii_reference_def() {
2783 let temp_dir = tempdir().unwrap();
2787 let base_path = temp_dir.path();
2788
2789 let content = "[你好]: not-exist.md";
2792
2793 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2794 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2795 let result = rule.check(&ctx).unwrap();
2796
2797 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2798 assert_eq!(result[0].line, 1, "Should be on line 1");
2799 assert_eq!(
2800 result[0].column, 7,
2801 "Column must be a character offset, not a byte offset"
2802 );
2803 assert_eq!(result[0].end_column, 19, "End column must be character-based");
2804 }
2805
2806 #[test]
2807 fn test_wikilinks_skipped() {
2808 let temp_dir = tempdir().unwrap();
2811 let base_path = temp_dir.path();
2812
2813 let content = r#"# Test Document
2814
2815[[Microsoft#Windows OS]]
2816[[SomePage]]
2817[[Page With Spaces]]
2818[[path/to/page#section]]
2819[[page|Display Text]]
2820
2821This is a [real missing link](missing.md) that should be flagged.
2822"#;
2823
2824 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2825 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2826 let result = rule.check(&ctx).unwrap();
2827
2828 assert_eq!(
2830 result.len(),
2831 1,
2832 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2833 );
2834 assert!(
2835 result[0].message.contains("missing.md"),
2836 "Warning should be for missing.md, not wikilinks"
2837 );
2838 }
2839
2840 #[test]
2841 fn test_wiki_embeds_skipped() {
2842 let temp_dir = tempdir().unwrap();
2846 let base_path = temp_dir.path();
2847
2848 let content = r#"# Test Document
2849
2850![[diagram.png]]
2851![[subfolder/diagram.png]]
2852![[diagram.png|300]]
2853![[Some Note]]
2854
2855This is a [real missing link](missing.md) that should be flagged.
2856"#;
2857
2858 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2859 for flavor in [
2860 crate::config::MarkdownFlavor::Obsidian,
2861 crate::config::MarkdownFlavor::Standard,
2862 ] {
2863 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
2864 let result = rule.check(&ctx).unwrap();
2865
2866 assert_eq!(
2867 result.len(),
2868 1,
2869 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
2870 );
2871 assert!(result[0].message.contains("missing.md"));
2872 }
2873 }
2874
2875 #[test]
2876 fn test_wikilinks_not_added_to_index() {
2877 let temp_dir = tempdir().unwrap();
2879 let base_path = temp_dir.path();
2880
2881 let content = r#"# Test Document
2882
2883[[Microsoft#Windows OS]]
2884[[SomePage#section]]
2885[Regular Link](other.md)
2886"#;
2887
2888 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2889 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2890
2891 let mut file_index = FileIndex::new();
2892 rule.contribute_to_index(&ctx, &mut file_index);
2893
2894 let cross_file_links = &file_index.cross_file_links;
2897 assert_eq!(
2898 cross_file_links.len(),
2899 1,
2900 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2901 );
2902 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2903 }
2904
2905 #[test]
2906 fn test_reference_definition_missing_file() {
2907 let temp_dir = tempdir().unwrap();
2909 let base_path = temp_dir.path();
2910
2911 let content = r#"# Test Document
2912
2913[test]: ./missing.md
2914[example]: ./nonexistent.html
2915
2916Use [test] and [example] here.
2917"#;
2918
2919 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2921 let result = rule.check(&ctx).unwrap();
2922
2923 assert_eq!(
2925 result.len(),
2926 2,
2927 "Should have warnings for missing reference definition targets. Got: {result:?}"
2928 );
2929 assert!(
2930 result.iter().any(|w| w.message.contains("missing.md")),
2931 "Should warn about missing.md"
2932 );
2933 assert!(
2934 result.iter().any(|w| w.message.contains("nonexistent.html")),
2935 "Should warn about nonexistent.html"
2936 );
2937 }
2938
2939 #[test]
2940 fn test_reference_definition_existing_file() {
2941 let temp_dir = tempdir().unwrap();
2943 let base_path = temp_dir.path();
2944
2945 let exists_path = base_path.join("exists.md");
2947 File::create(&exists_path)
2948 .unwrap()
2949 .write_all(b"# Existing file")
2950 .unwrap();
2951
2952 let content = r#"# Test Document
2953
2954[test]: ./exists.md
2955
2956Use [test] here.
2957"#;
2958
2959 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2960 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2961 let result = rule.check(&ctx).unwrap();
2962
2963 assert!(
2965 result.is_empty(),
2966 "Should not warn about existing file. Got: {result:?}"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_reference_definition_external_url_skipped() {
2972 let temp_dir = tempdir().unwrap();
2974 let base_path = temp_dir.path();
2975
2976 let content = r#"# Test Document
2977
2978[google]: https://google.com
2979[example]: http://example.org
2980[mail]: mailto:test@example.com
2981[ftp]: ftp://files.example.com
2982[local]: ./missing.md
2983
2984Use [google], [example], [mail], [ftp], [local] here.
2985"#;
2986
2987 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2988 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2989 let result = rule.check(&ctx).unwrap();
2990
2991 assert_eq!(
2993 result.len(),
2994 1,
2995 "Should only warn about local missing file. Got: {result:?}"
2996 );
2997 assert!(
2998 result[0].message.contains("missing.md"),
2999 "Warning should be for missing.md"
3000 );
3001 }
3002
3003 #[test]
3004 fn test_reference_definition_fragment_only_skipped() {
3005 let temp_dir = tempdir().unwrap();
3007 let base_path = temp_dir.path();
3008
3009 let content = r#"# Test Document
3010
3011[section]: #my-section
3012
3013Use [section] here.
3014"#;
3015
3016 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3017 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3018 let result = rule.check(&ctx).unwrap();
3019
3020 assert!(
3022 result.is_empty(),
3023 "Should not warn about fragment-only reference. Got: {result:?}"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_reference_definition_column_position() {
3029 let temp_dir = tempdir().unwrap();
3031 let base_path = temp_dir.path();
3032
3033 let content = "[ref]: ./missing.md";
3036 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3040 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3041 let result = rule.check(&ctx).unwrap();
3042
3043 assert_eq!(result.len(), 1, "Should have exactly one warning");
3044 assert_eq!(result[0].line, 1, "Should be on line 1");
3045 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3046 }
3047
3048 #[test]
3049 fn test_reference_definition_html_with_md_source() {
3050 let temp_dir = tempdir().unwrap();
3052 let base_path = temp_dir.path();
3053
3054 let md_file = base_path.join("guide.md");
3056 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3057
3058 let content = r#"# Test Document
3059
3060[guide]: ./guide.html
3061[missing]: ./missing.html
3062
3063Use [guide] and [missing] here.
3064"#;
3065
3066 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3067 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3068 let result = rule.check(&ctx).unwrap();
3069
3070 assert_eq!(
3072 result.len(),
3073 1,
3074 "Should only warn about missing source. Got: {result:?}"
3075 );
3076 assert!(result[0].message.contains("missing.html"));
3077 }
3078
3079 #[test]
3080 fn test_reference_definition_url_encoded() {
3081 let temp_dir = tempdir().unwrap();
3083 let base_path = temp_dir.path();
3084
3085 let file_with_spaces = base_path.join("file with spaces.md");
3087 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3088
3089 let content = r#"# Test Document
3090
3091[spaces]: ./file%20with%20spaces.md
3092[missing]: ./missing%20file.md
3093
3094Use [spaces] and [missing] here.
3095"#;
3096
3097 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3098 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3099 let result = rule.check(&ctx).unwrap();
3100
3101 assert_eq!(
3103 result.len(),
3104 1,
3105 "Should only warn about missing URL-encoded file. Got: {result:?}"
3106 );
3107 assert!(result[0].message.contains("missing%20file.md"));
3108 }
3109
3110 #[test]
3111 fn test_inline_and_reference_both_checked() {
3112 let temp_dir = tempdir().unwrap();
3114 let base_path = temp_dir.path();
3115
3116 let content = r#"# Test Document
3117
3118[inline link](./inline-missing.md)
3119[ref]: ./ref-missing.md
3120
3121Use [ref] here.
3122"#;
3123
3124 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3125 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3126 let result = rule.check(&ctx).unwrap();
3127
3128 assert_eq!(
3130 result.len(),
3131 2,
3132 "Should warn about both inline and reference links. Got: {result:?}"
3133 );
3134 assert!(
3135 result.iter().any(|w| w.message.contains("inline-missing.md")),
3136 "Should warn about inline-missing.md"
3137 );
3138 assert!(
3139 result.iter().any(|w| w.message.contains("ref-missing.md")),
3140 "Should warn about ref-missing.md"
3141 );
3142 }
3143
3144 #[test]
3145 fn test_footnote_definitions_not_flagged() {
3146 let rule = MD057ExistingRelativeLinks::default();
3149
3150 let content = r#"# Title
3151
3152A footnote[^1].
3153
3154[^1]: [link](https://www.google.com).
3155"#;
3156
3157 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3158 let result = rule.check(&ctx).unwrap();
3159
3160 assert!(
3161 result.is_empty(),
3162 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3163 );
3164 }
3165
3166 #[test]
3167 fn test_footnote_with_relative_link_inside() {
3168 let rule = MD057ExistingRelativeLinks::default();
3171
3172 let content = r#"# Title
3173
3174See the footnote[^1].
3175
3176[^1]: Check out [this file](./existing.md) for more info.
3177[^2]: Also see [missing](./does-not-exist.md).
3178"#;
3179
3180 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3181 let result = rule.check(&ctx).unwrap();
3182
3183 for warning in &result {
3188 assert!(
3189 !warning.message.contains("[this file]"),
3190 "Footnote content should not be treated as URL: {warning:?}"
3191 );
3192 assert!(
3193 !warning.message.contains("[missing]"),
3194 "Footnote content should not be treated as URL: {warning:?}"
3195 );
3196 }
3197 }
3198
3199 #[test]
3200 fn test_mixed_footnotes_and_reference_definitions() {
3201 let temp_dir = tempdir().unwrap();
3203 let base_path = temp_dir.path();
3204
3205 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3206
3207 let content = r#"# Title
3208
3209A footnote[^1] and a [ref link][myref].
3210
3211[^1]: This is a footnote with [link](https://example.com).
3212
3213[myref]: ./missing-file.md "This should be checked"
3214"#;
3215
3216 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3217 let result = rule.check(&ctx).unwrap();
3218
3219 assert_eq!(
3221 result.len(),
3222 1,
3223 "Should only warn about the regular reference definition. Got: {result:?}"
3224 );
3225 assert!(
3226 result[0].message.contains("missing-file.md"),
3227 "Should warn about missing-file.md in reference definition"
3228 );
3229 }
3230
3231 #[test]
3232 fn test_absolute_links_ignore_by_default() {
3233 let temp_dir = tempdir().unwrap();
3235 let base_path = temp_dir.path();
3236
3237 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3238
3239 let content = r#"# Links
3240
3241[API docs](/api/v1/users)
3242[Blog post](/blog/2024/release.html)
3243
3244
3245[ref]: /docs/reference.md
3246"#;
3247
3248 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3249 let result = rule.check(&ctx).unwrap();
3250
3251 assert!(
3253 result.is_empty(),
3254 "Absolute links should be ignored by default. Got: {result:?}"
3255 );
3256 }
3257
3258 #[test]
3259 fn test_absolute_links_warn_config() {
3260 let temp_dir = tempdir().unwrap();
3262 let base_path = temp_dir.path();
3263
3264 let config = MD057Config {
3265 absolute_links: AbsoluteLinksOption::Warn,
3266 ..Default::default()
3267 };
3268 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3269
3270 let content = r#"# Links
3271
3272[API docs](/api/v1/users)
3273[Blog post](/blog/2024/release.html)
3274"#;
3275
3276 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3277 let result = rule.check(&ctx).unwrap();
3278
3279 assert_eq!(
3281 result.len(),
3282 2,
3283 "Should warn about both absolute links. Got: {result:?}"
3284 );
3285 assert!(
3286 result[0].message.contains("cannot be validated locally"),
3287 "Warning should explain why: {}",
3288 result[0].message
3289 );
3290 assert!(
3291 result[0].message.contains("/api/v1/users"),
3292 "Warning should include the link path"
3293 );
3294 }
3295
3296 #[test]
3297 fn test_absolute_links_warn_images() {
3298 let temp_dir = tempdir().unwrap();
3300 let base_path = temp_dir.path();
3301
3302 let config = MD057Config {
3303 absolute_links: AbsoluteLinksOption::Warn,
3304 ..Default::default()
3305 };
3306 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3307
3308 let content = r#"# Images
3309
3310
3311"#;
3312
3313 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3314 let result = rule.check(&ctx).unwrap();
3315
3316 assert_eq!(
3317 result.len(),
3318 1,
3319 "Should warn about absolute image path. Got: {result:?}"
3320 );
3321 assert!(
3322 result[0].message.contains("/assets/logo.png"),
3323 "Warning should include the image path"
3324 );
3325 }
3326
3327 #[test]
3328 fn test_absolute_links_warn_reference_definitions() {
3329 let temp_dir = tempdir().unwrap();
3331 let base_path = temp_dir.path();
3332
3333 let config = MD057Config {
3334 absolute_links: AbsoluteLinksOption::Warn,
3335 ..Default::default()
3336 };
3337 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3338
3339 let content = r#"# Reference
3340
3341See the [docs][ref].
3342
3343[ref]: /docs/reference.md
3344"#;
3345
3346 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3347 let result = rule.check(&ctx).unwrap();
3348
3349 assert_eq!(
3350 result.len(),
3351 1,
3352 "Should warn about absolute reference definition. Got: {result:?}"
3353 );
3354 assert!(
3355 result[0].message.contains("/docs/reference.md"),
3356 "Warning should include the reference path"
3357 );
3358 }
3359
3360 #[test]
3361 fn test_search_paths_inline_link() {
3362 let temp_dir = tempdir().unwrap();
3363 let base_path = temp_dir.path();
3364
3365 let assets_dir = base_path.join("assets");
3367 std::fs::create_dir_all(&assets_dir).unwrap();
3368 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3369
3370 let config = MD057Config {
3371 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3372 ..Default::default()
3373 };
3374 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3375
3376 let content = "# Test\n\n[Photo](photo.png)\n";
3377 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3378 let result = rule.check(&ctx).unwrap();
3379
3380 assert!(
3381 result.is_empty(),
3382 "Should find photo.png via search-paths. Got: {result:?}"
3383 );
3384 }
3385
3386 #[test]
3387 fn test_search_paths_image() {
3388 let temp_dir = tempdir().unwrap();
3389 let base_path = temp_dir.path();
3390
3391 let assets_dir = base_path.join("attachments");
3392 std::fs::create_dir_all(&assets_dir).unwrap();
3393 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3394
3395 let config = MD057Config {
3396 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3397 ..Default::default()
3398 };
3399 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3400
3401 let content = "# Test\n\n\n";
3402 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3403 let result = rule.check(&ctx).unwrap();
3404
3405 assert!(
3406 result.is_empty(),
3407 "Should find diagram.svg via search-paths. Got: {result:?}"
3408 );
3409 }
3410
3411 #[test]
3412 fn test_search_paths_reference_definition() {
3413 let temp_dir = tempdir().unwrap();
3414 let base_path = temp_dir.path();
3415
3416 let assets_dir = base_path.join("images");
3417 std::fs::create_dir_all(&assets_dir).unwrap();
3418 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3419
3420 let config = MD057Config {
3421 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3422 ..Default::default()
3423 };
3424 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3425
3426 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3427 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3428 let result = rule.check(&ctx).unwrap();
3429
3430 assert!(
3431 result.is_empty(),
3432 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3433 );
3434 }
3435
3436 #[test]
3437 fn test_search_paths_still_warns_when_truly_missing() {
3438 let temp_dir = tempdir().unwrap();
3439 let base_path = temp_dir.path();
3440
3441 let assets_dir = base_path.join("assets");
3442 std::fs::create_dir_all(&assets_dir).unwrap();
3443
3444 let config = MD057Config {
3445 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3446 ..Default::default()
3447 };
3448 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3449
3450 let content = "# Test\n\n\n";
3451 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3452 let result = rule.check(&ctx).unwrap();
3453
3454 assert_eq!(
3455 result.len(),
3456 1,
3457 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3458 );
3459 }
3460
3461 #[test]
3462 fn test_search_paths_nonexistent_directory() {
3463 let temp_dir = tempdir().unwrap();
3464 let base_path = temp_dir.path();
3465
3466 let config = MD057Config {
3467 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3468 ..Default::default()
3469 };
3470 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3471
3472 let content = "# Test\n\n\n";
3473 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3474 let result = rule.check(&ctx).unwrap();
3475
3476 assert_eq!(
3477 result.len(),
3478 1,
3479 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3480 );
3481 }
3482
3483 #[test]
3484 fn test_obsidian_attachment_folder_named() {
3485 let temp_dir = tempdir().unwrap();
3486 let vault = temp_dir.path().join("vault");
3487 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3488 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3489 std::fs::create_dir_all(vault.join("notes")).unwrap();
3490
3491 std::fs::write(
3492 vault.join(".obsidian/app.json"),
3493 r#"{"attachmentFolderPath": "Attachments"}"#,
3494 )
3495 .unwrap();
3496 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3497
3498 let notes_dir = vault.join("notes");
3499 let source_file = notes_dir.join("test.md");
3500 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3501
3502 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3503
3504 let content = "# Test\n\n\n";
3505 let ctx =
3506 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3507 let result = rule.check(&ctx).unwrap();
3508
3509 assert!(
3510 result.is_empty(),
3511 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3512 );
3513 }
3514
3515 #[test]
3516 fn test_obsidian_attachment_same_folder_as_file() {
3517 let temp_dir = tempdir().unwrap();
3518 let vault = temp_dir.path().join("vault-rf");
3519 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3520 std::fs::create_dir_all(vault.join("notes")).unwrap();
3521
3522 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3523
3524 let notes_dir = vault.join("notes");
3526 let source_file = notes_dir.join("test.md");
3527 std::fs::write(&source_file, "placeholder").unwrap();
3528 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3529
3530 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3531
3532 let content = "# Test\n\n\n";
3533 let ctx =
3534 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3535 let result = rule.check(&ctx).unwrap();
3536
3537 assert!(
3538 result.is_empty(),
3539 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3540 );
3541 }
3542
3543 #[test]
3544 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3545 let temp_dir = tempdir().unwrap();
3546 let vault = temp_dir.path().join("vault-nf");
3547 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3548 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3549 std::fs::create_dir_all(vault.join("notes")).unwrap();
3550
3551 std::fs::write(
3552 vault.join(".obsidian/app.json"),
3553 r#"{"attachmentFolderPath": "Attachments"}"#,
3554 )
3555 .unwrap();
3556 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3557
3558 let notes_dir = vault.join("notes");
3559 let source_file = notes_dir.join("test.md");
3560 std::fs::write(&source_file, "placeholder").unwrap();
3561
3562 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3563
3564 let content = "# Test\n\n\n";
3565 let ctx =
3567 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3568 let result = rule.check(&ctx).unwrap();
3569
3570 assert_eq!(
3571 result.len(),
3572 1,
3573 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3574 );
3575 }
3576
3577 #[test]
3578 fn test_search_paths_combined_with_obsidian() {
3579 let temp_dir = tempdir().unwrap();
3580 let vault = temp_dir.path().join("vault-combo");
3581 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3582 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3583 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3584 std::fs::create_dir_all(vault.join("notes")).unwrap();
3585
3586 std::fs::write(
3587 vault.join(".obsidian/app.json"),
3588 r#"{"attachmentFolderPath": "Attachments"}"#,
3589 )
3590 .unwrap();
3591 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3592 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3593
3594 let notes_dir = vault.join("notes");
3595 let source_file = notes_dir.join("test.md");
3596 std::fs::write(&source_file, "placeholder").unwrap();
3597
3598 let extra_assets_dir = vault.join("extra-assets");
3599 let config = MD057Config {
3600 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3601 ..Default::default()
3602 };
3603 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3604
3605 let content = "# Test\n\n\n\n\n";
3607 let ctx =
3608 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3609 let result = rule.check(&ctx).unwrap();
3610
3611 assert!(
3612 result.is_empty(),
3613 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3614 );
3615 }
3616
3617 #[test]
3618 fn test_obsidian_attachment_subfolder_under_file() {
3619 let temp_dir = tempdir().unwrap();
3620 let vault = temp_dir.path().join("vault-sub");
3621 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3622 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3623
3624 std::fs::write(
3625 vault.join(".obsidian/app.json"),
3626 r#"{"attachmentFolderPath": "./assets"}"#,
3627 )
3628 .unwrap();
3629 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3630
3631 let notes_dir = vault.join("notes");
3632 let source_file = notes_dir.join("test.md");
3633 std::fs::write(&source_file, "placeholder").unwrap();
3634
3635 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3636
3637 let content = "# Test\n\n\n";
3638 let ctx =
3639 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3640 let result = rule.check(&ctx).unwrap();
3641
3642 assert!(
3643 result.is_empty(),
3644 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3645 );
3646 }
3647
3648 #[test]
3649 fn test_obsidian_attachment_vault_root() {
3650 let temp_dir = tempdir().unwrap();
3651 let vault = temp_dir.path().join("vault-root");
3652 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3653 std::fs::create_dir_all(vault.join("notes")).unwrap();
3654
3655 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3657 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3658
3659 let notes_dir = vault.join("notes");
3660 let source_file = notes_dir.join("test.md");
3661 std::fs::write(&source_file, "placeholder").unwrap();
3662
3663 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3664
3665 let content = "# Test\n\n\n";
3666 let ctx =
3667 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3668 let result = rule.check(&ctx).unwrap();
3669
3670 assert!(
3671 result.is_empty(),
3672 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3673 );
3674 }
3675
3676 #[test]
3677 fn test_search_paths_multiple_directories() {
3678 let temp_dir = tempdir().unwrap();
3679 let base_path = temp_dir.path();
3680
3681 let dir_a = base_path.join("dir-a");
3682 let dir_b = base_path.join("dir-b");
3683 std::fs::create_dir_all(&dir_a).unwrap();
3684 std::fs::create_dir_all(&dir_b).unwrap();
3685 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3686 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3687
3688 let config = MD057Config {
3689 search_paths: vec![
3690 dir_a.to_string_lossy().into_owned(),
3691 dir_b.to_string_lossy().into_owned(),
3692 ],
3693 ..Default::default()
3694 };
3695 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3696
3697 let content = "# Test\n\n\n\n\n";
3698 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3699 let result = rule.check(&ctx).unwrap();
3700
3701 assert!(
3702 result.is_empty(),
3703 "Should find files across multiple search paths. Got: {result:?}"
3704 );
3705 }
3706
3707 #[test]
3716 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
3717 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
3718
3719 let temp_dir = tempdir().unwrap();
3720 let base_path = temp_dir.path();
3721
3722 let file_path = base_path.join("README.md");
3723 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
3724 std::fs::write(&file_path, content).unwrap();
3725
3726 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
3727
3728 let ctx = crate::lint_context::LintContext::new(
3729 content,
3730 crate::config::MarkdownFlavor::Standard,
3731 Some(file_path.clone()),
3732 );
3733 let per_file = rule.check(&ctx).unwrap();
3734 assert_eq!(
3735 per_file.len(),
3736 1,
3737 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
3738 );
3739
3740 let mut file_index = FileIndex::default();
3741 file_index.cross_file_links.push(CrossFileLinkIndex {
3742 target_path: "missing-guide.md".to_string(),
3743 fragment: String::new(),
3744 line: 3,
3745 column: 1,
3746 origin: LinkOrigin::Body,
3747 });
3748
3749 let result = rule
3750 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
3751 .unwrap();
3752
3753 assert!(
3754 result.is_empty(),
3755 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
3756 );
3757 }
3758
3759 #[test]
3760 fn test_check_clears_stale_cache() {
3761 let temp_dir = tempdir().unwrap();
3764 let base_path = temp_dir.path();
3765
3766 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3767
3768 let phantom_path = base_path.join("phantom.md");
3770 {
3771 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3772 cache.insert(phantom_path.clone(), true);
3773 }
3774
3775 let content = "[phantom](phantom.md)\n";
3776 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3777 let warnings = rule.check(&ctx).unwrap();
3778
3779 assert_eq!(
3781 warnings.len(),
3782 1,
3783 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3784 );
3785 assert!(warnings[0].message.contains("phantom.md"));
3786 }
3787
3788 #[test]
3789 fn test_check_does_not_carry_over_cache_between_runs() {
3790 let temp_dir = tempdir().unwrap();
3792 let base_path = temp_dir.path();
3793
3794 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3795
3796 let content = "[missing](nonexistent.md)\n";
3797 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3798
3799 let warnings_1 = rule.check(&ctx).unwrap();
3801 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3802
3803 let nonexistent_path = base_path.join("nonexistent.md");
3805 {
3806 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3807 cache.insert(nonexistent_path.clone(), true);
3808 }
3809
3810 let warnings_2 = rule.check(&ctx).unwrap();
3812 assert_eq!(
3813 warnings_2.len(),
3814 1,
3815 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3816 );
3817 }
3818
3819 #[test]
3825 fn test_no_duplicate_warnings_for_broken_relative_link() {
3826 use crate::workspace_index::WorkspaceIndex;
3827
3828 let temp_dir = tempdir().unwrap();
3829 let base_path = temp_dir.path();
3830
3831 let source_file = base_path.join("index.md");
3833 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3834
3835 let content = "[broken](does/not/exist.md)\n";
3836
3837 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3838
3839 let ctx = crate::lint_context::LintContext::new(
3841 content,
3842 crate::config::MarkdownFlavor::Standard,
3843 Some(source_file.clone()),
3844 );
3845 let check_warnings = rule.check(&ctx).unwrap();
3846
3847 let mut file_index = FileIndex::new();
3849 rule.contribute_to_index(&ctx, &mut file_index);
3850 let workspace_index = WorkspaceIndex::new();
3851 let cross_warnings = rule
3852 .cross_file_check(&source_file, &file_index, &workspace_index)
3853 .unwrap();
3854
3855 let total = check_warnings.len() + cross_warnings.len();
3856 assert_eq!(
3857 total, 1,
3858 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3859 check={check_warnings:?}, cross={cross_warnings:?}"
3860 );
3861 }
3862
3863 #[test]
3868 fn test_absolute_dir_link_accepted_relative_to_roots() {
3869 let temp_dir = tempdir().unwrap();
3870 let root = temp_dir.path();
3871
3872 let dir_d = root.join("d");
3874 std::fs::create_dir_all(&dir_d).unwrap();
3875 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3876
3877 let content = "\
3880[absolute dir](/d)\n\
3881[relative dir](d)\n\
3882[absolute file](/d/foo.md)\n\
3883[relative file](d/foo.md)\n";
3884
3885 let config = MD057Config {
3886 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3887 roots: vec![],
3888 ..Default::default()
3889 };
3890 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3891
3892 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3893 let result = rule.check(&ctx).unwrap();
3894
3895 assert!(
3896 result.is_empty(),
3897 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3898 );
3899 }
3900
3901 #[test]
3904 fn test_absolute_trailing_slash_dir_link_requires_index() {
3905 let temp_dir = tempdir().unwrap();
3906 let root = temp_dir.path();
3907
3908 let dir_d = root.join("d");
3910 std::fs::create_dir_all(&dir_d).unwrap();
3911 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3912
3913 let content = "[dir with slash](/d/)\n";
3915
3916 let config = MD057Config {
3917 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3918 roots: vec![],
3919 ..Default::default()
3920 };
3921 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3922
3923 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3924 let result = rule.check(&ctx).unwrap();
3925
3926 assert_eq!(
3927 result.len(),
3928 1,
3929 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3930 );
3931 }
3932
3933 #[test]
3937 fn test_docs_dir_variant_still_enforces_index_md() {
3938 let temp_dir = tempdir().unwrap();
3939 let root = temp_dir.path();
3940
3941 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3943
3944 let docs_dir = root.join("docs");
3946 std::fs::create_dir_all(&docs_dir).unwrap();
3947 let section_dir = docs_dir.join("section");
3948 std::fs::create_dir_all(§ion_dir).unwrap();
3949 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3950
3951 let source_file = docs_dir.join("index.md");
3953 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3954
3955 let config = MD057Config {
3956 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3957 ..Default::default()
3958 };
3959 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3960
3961 let content = "[sec](/section)\n";
3962 let ctx = crate::lint_context::LintContext::new(
3963 content,
3964 crate::config::MarkdownFlavor::Standard,
3965 Some(source_file.clone()),
3966 );
3967 let result = rule.check(&ctx).unwrap();
3968
3969 assert_eq!(
3971 result.len(),
3972 1,
3973 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3974 );
3975 assert!(
3976 result[0].message.contains("index.md") || result[0].message.contains("section"),
3977 "Message should mention the directory or missing index.md: {}",
3978 result[0].message
3979 );
3980 }
3981
3982 #[test]
3988 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3989 let temp_dir = tempdir().unwrap();
3990 let root = temp_dir.path();
3991
3992 let guide_dir = root.join("guide");
3994 std::fs::create_dir_all(&guide_dir).unwrap();
3995 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3996
3997 let content = "[guide with fragment](/guide/#intro)\n";
3999
4000 let config = MD057Config {
4001 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4002 roots: vec![],
4003 ..Default::default()
4004 };
4005 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4006 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4007 let result = rule.check(&ctx).unwrap();
4008
4009 assert_eq!(
4010 result.len(),
4011 1,
4012 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4013 );
4014 }
4015}
4016
4017#[cfg(test)]
4018mod self_referential_links_tests {
4019 use super::*;
4020 use tempfile::tempdir;
4021
4022 fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4024 let source_file = dir.join(name);
4025 std::fs::write(&source_file, content).unwrap();
4026 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4027 let ctx =
4028 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4029 rule.check(&ctx).unwrap()
4030 }
4031
4032 fn enabled() -> MD057Config {
4033 MD057Config {
4034 self_referential_links: true,
4035 ..Default::default()
4036 }
4037 }
4038
4039 #[test]
4040 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4041 let temp_dir = tempdir().unwrap();
4042 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4043 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4044
4045 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4046 assert_eq!(
4047 result[0].message,
4048 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4049 );
4050 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4051 assert_eq!(fix.replacement, "#level-2-heading");
4052 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4053 }
4054
4055 #[test]
4056 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4057 let temp_dir = tempdir().unwrap();
4058 let content = "# Title\n\nSee [this file](test.md).\n";
4059 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4060
4061 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4062 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4063 assert!(
4064 result[0].fix.is_none(),
4065 "Dropping the link would change the document, so there is no fix"
4066 );
4067 }
4068
4069 #[test]
4070 fn test_the_check_is_off_by_default() {
4071 let temp_dir = tempdir().unwrap();
4072 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4073 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4074
4075 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4076 }
4077
4078 #[test]
4079 fn test_a_link_to_another_file_is_left_alone() {
4080 let temp_dir = tempdir().unwrap();
4081 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4082 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4083 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4084
4085 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4086 }
4087
4088 #[test]
4089 fn test_a_self_link_written_with_traversal_reports_once() {
4090 let temp_dir = tempdir().unwrap();
4091 let sub_dir = temp_dir.path().join("sub");
4092 std::fs::create_dir_all(&sub_dir).unwrap();
4093
4094 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4095 let config = MD057Config {
4096 self_referential_links: true,
4097 compact_paths: true,
4098 ..Default::default()
4099 };
4100 let result = check_as_file(&sub_dir, "test.md", content, config);
4101
4102 assert_eq!(
4103 result.len(),
4104 1,
4105 "A compacted path would still be a link back to this file. Got: {result:?}"
4106 );
4107 assert_eq!(
4108 result[0].message,
4109 "Relative link '../sub/test.md' points to the file it is in"
4110 );
4111 }
4112
4113 #[test]
4114 fn test_compact_paths_still_reports_a_link_to_another_file() {
4115 let temp_dir = tempdir().unwrap();
4116 let sub_dir = temp_dir.path().join("sub");
4117 std::fs::create_dir_all(&sub_dir).unwrap();
4118 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4119
4120 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4121 let config = MD057Config {
4122 self_referential_links: true,
4123 compact_paths: true,
4124 ..Default::default()
4125 };
4126 let result = check_as_file(&sub_dir, "test.md", content, config);
4127
4128 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4129 assert_eq!(
4130 result[0].message,
4131 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4132 );
4133 }
4134
4135 #[test]
4136 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4137 let temp_dir = tempdir().unwrap();
4138 let content = "# Title\n\nSee [this file](test#title).\n";
4139 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4140
4141 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4142 assert_eq!(
4143 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4144 Some("#title"),
4145 "Got: {result:?}"
4146 );
4147 }
4148
4149 #[test]
4150 fn test_a_reference_definition_pointing_at_its_own_file() {
4151 let temp_dir = tempdir().unwrap();
4152 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4153 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4154
4155 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4156 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4157 assert_eq!(fix.replacement, "#level-2-heading");
4158 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4159 }
4160
4161 #[test]
4162 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4163 let temp_dir = tempdir().unwrap();
4164 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4165 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4166
4167 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4168 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4169 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4172 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4173 .fix(&crate::lint_context::LintContext::new(
4174 content,
4175 crate::config::MarkdownFlavor::Standard,
4176 Some(temp_dir.path().join("test.md")),
4177 ))
4178 .unwrap();
4179 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4180 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4181 }
4182
4183 #[test]
4184 fn test_a_self_link_resolved_through_a_search_path() {
4185 let temp_dir = tempdir().unwrap();
4186 let guide_dir = temp_dir.path().join("docs/guide");
4187 std::fs::create_dir_all(&guide_dir).unwrap();
4188 let config = MD057Config {
4189 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4190 ..enabled()
4191 };
4192 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4193 let result = check_as_file(&guide_dir, "test.md", content, config);
4194
4195 assert_eq!(
4196 result.len(),
4197 1,
4198 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4199 );
4200 assert_eq!(
4201 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4202 Some("#title"),
4203 "Got: {result:?}"
4204 );
4205 }
4206
4207 #[test]
4208 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4209 let temp_dir = tempdir().unwrap();
4210 let guide_dir = temp_dir.path().join("docs/guide");
4211 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4212 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4213 let config = MD057Config {
4214 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4215 ..enabled()
4216 };
4217 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4218 let result = check_as_file(&guide_dir, "test.md", content, config);
4219
4220 assert!(
4221 result.is_empty(),
4222 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4223 );
4224 }
4225
4226 #[test]
4227 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4228 let temp_dir = tempdir().unwrap();
4229 let content = "# Title\n\n\n";
4230 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4231
4232 assert!(
4233 result.is_empty(),
4234 "An image is not a link the reader follows. Got: {result:?}"
4235 );
4236 }
4237
4238 #[test]
4239 fn test_a_query_string_is_reported_without_a_suggestion() {
4240 let temp_dir = tempdir().unwrap();
4241 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4242 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4243
4244 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4245 assert!(
4246 result[0].fix.is_none(),
4247 "A query does not survive losing its path. Got: {result:?}"
4248 );
4249 }
4250
4251 #[test]
4252 fn test_fix_rewrites_the_document_and_settles() {
4253 let temp_dir = tempdir().unwrap();
4254 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4255 let source_file = temp_dir.path().join("test.md");
4256 std::fs::write(&source_file, content).unwrap();
4257
4258 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4259 let ctx = crate::lint_context::LintContext::new(
4260 content,
4261 crate::config::MarkdownFlavor::Standard,
4262 Some(source_file.clone()),
4263 );
4264 let fixed = rule.fix(&ctx).unwrap();
4265 assert_eq!(
4266 fixed,
4267 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4268 );
4269
4270 let refixed =
4271 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4272 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4273 }
4274
4275 #[test]
4276 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4277 let unfixable = MD057ExistingRelativeLinks::default();
4278 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4279
4280 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4281 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4282 }
4283
4284 #[test]
4285 fn test_the_option_is_read_from_kebab_and_snake_case() {
4286 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4287 assert!(kebab.self_referential_links);
4288
4289 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4290 assert!(snake.self_referential_links);
4291 }
4292
4293 fn front_matter_checked() -> MD057Config {
4294 MD057Config {
4295 check_frontmatter: true,
4296 ..Default::default()
4297 }
4298 }
4299
4300 #[test]
4301 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4302 let temp_dir = tempdir().unwrap();
4303 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4304 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4305
4306 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4307 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4308 assert_eq!(result[0].line, 2);
4309 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4310 assert_eq!(result[0].end_column, 23);
4311 }
4312
4313 #[test]
4314 fn test_frontmatter_paths_are_not_checked_by_default() {
4315 let temp_dir = tempdir().unwrap();
4316 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4317 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4318
4319 assert!(
4320 result.is_empty(),
4321 "Frontmatter is only checked on request. Got: {result:?}"
4322 );
4323 }
4324
4325 #[test]
4326 fn test_an_existing_frontmatter_path_is_not_reported() {
4327 let temp_dir = tempdir().unwrap();
4328 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4329 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4330 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4331
4332 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4333 assert_eq!(result[0].line, 3);
4334 }
4335
4336 #[test]
4337 fn test_an_ignored_frontmatter_field_is_not_checked() {
4338 let temp_dir = tempdir().unwrap();
4339 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4340 let config = MD057Config {
4341 check_frontmatter: true,
4342 ignore_frontmatter_fields: vec!["Image".to_string()],
4343 ..Default::default()
4344 };
4345 let result = check_as_file(temp_dir.path(), "test.md", content, config);
4346
4347 assert_eq!(
4348 result.len(),
4349 1,
4350 "The ignored field is skipped and the other is not. Got: {result:?}"
4351 );
4352 assert_eq!(result[0].line, 3);
4353 }
4354
4355 #[test]
4356 fn test_an_external_frontmatter_url_is_not_reported() {
4357 let temp_dir = tempdir().unwrap();
4358 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4359 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4360
4361 assert!(
4362 result.is_empty(),
4363 "An external URL has no local target. Got: {result:?}"
4364 );
4365 }
4366
4367 #[test]
4368 fn test_a_frontmatter_fragment_is_left_to_md051() {
4369 let temp_dir = tempdir().unwrap();
4370 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4371 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4372
4373 assert!(
4374 result.is_empty(),
4375 "A fragment names a heading, not a file. Got: {result:?}"
4376 );
4377 }
4378
4379 #[test]
4380 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4381 let temp_dir = tempdir().unwrap();
4382 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4383
4384 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4385 assert!(
4386 ignored.is_empty(),
4387 "Absolute paths are ignored by default. Got: {ignored:?}"
4388 );
4389
4390 let warning_config = MD057Config {
4391 check_frontmatter: true,
4392 absolute_links: AbsoluteLinksOption::Warn,
4393 ..Default::default()
4394 };
4395 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4396 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4397 assert_eq!(
4398 warned[0].message,
4399 "Absolute link '/docs/guide.md' cannot be validated locally"
4400 );
4401 }
4402
4403 #[test]
4404 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4405 let temp_dir = tempdir().unwrap();
4406 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4407 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4408 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4409
4410 assert_eq!(
4411 result.len(),
4412 1,
4413 "A query names no file, so only the missing target is reported. Got: {result:?}"
4414 );
4415 assert_eq!(result[0].line, 2);
4416 assert_eq!(
4417 result[0].message,
4418 "Relative link 'docs/missing.md?raw=true' does not exist"
4419 );
4420 }
4421
4422 #[test]
4423 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4424 let temp_dir = tempdir().unwrap();
4425 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4426 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4427
4428 assert!(
4429 result.is_empty(),
4430 "Only path-shaped values are destinations. Got: {result:?}"
4431 );
4432 }
4433}