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 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
887
888 let base_path: Option<PathBuf> = {
892 if explicit_base.is_some() {
893 explicit_base
894 } else if let Some(ref resolved_file) = self_path {
895 resolved_file
899 .parent()
900 .map(std::path::Path::to_path_buf)
901 .or_else(|| Some(CURRENT_DIR.clone()))
902 } else {
903 None
905 }
906 };
907
908 let Some(base_path) = base_path else {
910 return Ok(warnings);
911 };
912
913 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
915
916 if !ctx.links().is_empty() {
918 let lines = ctx.raw_lines();
922
923 let mut processed_lines = std::collections::HashSet::new();
926
927 for link in ctx.links() {
928 let line_idx = link.line - 1;
929 if line_idx >= lines.len() {
930 continue;
931 }
932
933 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
935 continue;
936 }
937
938 if !processed_lines.insert(line_idx) {
940 continue;
941 }
942
943 let line = lines[line_idx];
944
945 if !line.contains("](") {
947 continue;
948 }
949
950 for link_match in LINK_START_REGEX.find_iter(line) {
952 if link_match.as_str().starts_with('!') {
959 let escapes = line[..link_match.start()]
960 .bytes()
961 .rev()
962 .take_while(|&b| b == b'\\')
963 .count();
964 if escapes % 2 == 0 {
965 continue;
966 }
967 }
968
969 let start_pos = link_match.start();
970 let end_pos = link_match.end();
971
972 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
974 let absolute_start_pos = line_start_byte + start_pos;
975
976 if ctx.is_in_code_span_byte(absolute_start_pos) {
978 continue;
979 }
980
981 if ctx.is_in_math_span(absolute_start_pos) {
983 continue;
984 }
985
986 if ctx.is_in_shortcode(absolute_start_pos) {
991 continue;
992 }
993
994 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1001 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1002 .or_else(|| {
1003 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1004 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1005 });
1006
1007 if let Some((caps, url_group)) = caps_and_url {
1008 let url = url_group.as_str().trim();
1009
1010 if url.is_empty() {
1012 continue;
1013 }
1014
1015 if url.starts_with('`') && url.ends_with('`') {
1019 continue;
1020 }
1021
1022 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1024 continue;
1025 }
1026
1027 if Self::is_absolute_path(url) {
1029 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1030 warnings.push(LintWarning {
1031 rule_name: Some(self.name().to_string()),
1032 line: link.line,
1033 column: byte_to_char_count(line, url_group.start()),
1034 end_line: link.line,
1035 end_column: byte_to_char_count(line, url_group.end()),
1036 message,
1037 severity: Severity::Warning,
1038 fix: None,
1039 });
1040 }
1041 continue;
1042 }
1043
1044 let full_url_for_compact = if let Some(frag) = caps.get(2) {
1048 format!("{url}{}", frag.as_str())
1049 } else {
1050 url.to_string()
1051 };
1052 if let Some(self_link) = self.self_referential_link(
1057 &full_url_for_compact,
1058 &base_path,
1059 &extra_search_paths,
1060 self_path.as_deref(),
1061 ) {
1062 let url_start = url_group.start();
1063 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1064 let fix_byte_start = line_start_byte + url_start;
1065 let fix_byte_end = line_start_byte + url_end;
1066 warnings.push(LintWarning {
1067 rule_name: Some(self.name().to_string()),
1068 line: link.line,
1069 column: byte_to_char_count(line, url_start),
1070 end_line: link.line,
1071 end_column: byte_to_char_count(line, url_end),
1072 message: Self::self_referential_message(&full_url_for_compact, &self_link),
1073 severity: Severity::Warning,
1074 fix: match &self_link {
1075 SelfReferentialLink::Fragment(fragment) => {
1076 Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1077 }
1078 SelfReferentialLink::WholeFile => None,
1079 },
1080 });
1081 continue;
1082 }
1083
1084 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1085 let url_start = url_group.start();
1086 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1087 let fix_byte_start = line_start_byte + url_start;
1088 let fix_byte_end = line_start_byte + url_end;
1089 warnings.push(LintWarning {
1090 rule_name: Some(self.name().to_string()),
1091 line: link.line,
1092 column: byte_to_char_count(line, url_start),
1093 end_line: link.line,
1094 end_column: byte_to_char_count(line, url_end),
1095 message: format!(
1096 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1097 ),
1098 severity: Severity::Warning,
1099 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1100 });
1101 }
1102
1103 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1104 continue;
1105 }
1106
1107 let url_start = url_group.start();
1111 let url_end = url_group.end();
1112
1113 warnings.push(LintWarning {
1114 rule_name: Some(self.name().to_string()),
1115 line: link.line,
1116 column: byte_to_char_count(line, url_start),
1117 end_line: link.line,
1118 end_column: byte_to_char_count(line, url_end),
1119 message: format!("Relative link '{url}' does not exist"),
1120 severity: Severity::Error,
1121 fix: None,
1122 });
1123 }
1124 }
1125 }
1126 }
1127
1128 for image in ctx.images() {
1130 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1132 continue;
1133 }
1134
1135 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1139 continue;
1140 }
1141
1142 if ctx.is_in_shortcode(image.byte_offset) {
1145 continue;
1146 }
1147
1148 let url = image.url.as_ref();
1149
1150 if url.is_empty() {
1152 continue;
1153 }
1154
1155 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1157 continue;
1158 }
1159
1160 if Self::is_absolute_path(url) {
1162 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1163 warnings.push(LintWarning {
1164 rule_name: Some(self.name().to_string()),
1165 line: image.line,
1166 column: image.start_col + 1,
1167 end_line: image.line,
1168 end_column: image.start_col + 1 + url.chars().count(),
1169 message,
1170 severity: Severity::Warning,
1171 fix: None,
1172 });
1173 }
1174 continue;
1175 }
1176
1177 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1179 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1182 let fix_byte_start = image.byte_offset + url_offset;
1183 let fix_byte_end = fix_byte_start + url.len();
1184 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1185 });
1186
1187 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1188 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1189 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1192 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1193 });
1194 warnings.push(LintWarning {
1195 rule_name: Some(self.name().to_string()),
1196 line: image.line,
1197 column: url_col,
1198 end_line: image.line,
1199 end_column: url_col + url.chars().count(),
1200 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1201 severity: Severity::Warning,
1202 fix,
1203 });
1204 }
1205
1206 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1207 continue;
1208 }
1209
1210 warnings.push(LintWarning {
1213 rule_name: Some(self.name().to_string()),
1214 line: image.line,
1215 column: image.start_col + 1,
1216 end_line: image.line,
1217 end_column: image.start_col + 1 + url.chars().count(),
1218 message: format!("Relative link '{url}' does not exist"),
1219 severity: Severity::Error,
1220 fix: None,
1221 });
1222 }
1223
1224 for ref_def in ctx.reference_definitions() {
1226 let url = &ref_def.url;
1227
1228 if url.is_empty() {
1230 continue;
1231 }
1232
1233 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1235 continue;
1236 }
1237
1238 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1242 let (line, col) = url_range
1243 .as_ref()
1244 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1245 let end_col = col + url.chars().count();
1246
1247 if Self::is_absolute_path(url) {
1249 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1250 warnings.push(LintWarning {
1251 rule_name: Some(self.name().to_string()),
1252 line,
1253 column: col,
1254 end_line: line,
1255 end_column: end_col,
1256 message,
1257 severity: Severity::Warning,
1258 fix: None,
1259 });
1260 }
1261 continue;
1262 }
1263
1264 if let Some(self_link) =
1266 self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
1267 {
1268 warnings.push(LintWarning {
1269 rule_name: Some(self.name().to_string()),
1270 line,
1271 column: col,
1272 end_line: line,
1273 end_column: end_col,
1274 message: Self::self_referential_message(url, &self_link),
1275 severity: Severity::Warning,
1276 fix: match (&self_link, &url_range) {
1277 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1278 Some(Fix::new(range.clone(), fragment.clone()))
1279 }
1280 _ => None,
1281 },
1282 });
1283 continue;
1284 }
1285
1286 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1288 warnings.push(LintWarning {
1289 rule_name: Some(self.name().to_string()),
1290 line,
1291 column: col,
1292 end_line: line,
1293 end_column: end_col,
1294 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1295 severity: Severity::Warning,
1296 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1297 });
1298 }
1299
1300 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1301 continue;
1302 }
1303
1304 warnings.push(LintWarning {
1306 rule_name: Some(self.name().to_string()),
1307 line,
1308 column: col,
1309 end_line: line,
1310 end_column: end_col,
1311 message: format!("Relative link '{url}' does not exist"),
1312 severity: Severity::Error,
1313 fix: None,
1314 });
1315 }
1316
1317 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1318
1319 Ok(warnings)
1320 }
1321
1322 fn fix_capability(&self) -> FixCapability {
1323 if self.produces_fixes() {
1324 FixCapability::ConditionallyFixable
1325 } else {
1326 FixCapability::Unfixable
1327 }
1328 }
1329
1330 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1331 if !self.produces_fixes() {
1332 return Ok(ctx.content.to_string());
1333 }
1334
1335 let warnings = self.check(ctx)?;
1336 let warnings =
1337 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1338 let mut content = ctx.content.to_string();
1339
1340 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1342 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1343
1344 let mut last_applied_start: Option<usize> = None;
1350 for fix in fixes {
1351 if let Some(prev_start) = last_applied_start
1352 && fix.range.end > prev_start
1353 {
1354 continue;
1355 }
1356 if fix.range.end <= content.len() {
1357 content.replace_range(fix.range.clone(), &fix.replacement);
1358 last_applied_start = Some(fix.range.start);
1359 }
1360 }
1361
1362 Ok(content)
1363 }
1364
1365 fn as_any(&self) -> &dyn std::any::Any {
1366 self
1367 }
1368
1369 crate::impl_rule_config_sections!(MD057Config);
1370
1371 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1372 where
1373 Self: Sized,
1374 {
1375 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1376 Box::new(Self::from_config_struct(rule_config))
1380 }
1381
1382 fn cross_file_scope(&self) -> CrossFileScope {
1383 CrossFileScope::Workspace
1384 }
1385
1386 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1387 let links = extract_cross_file_links(ctx);
1390 for link in links.relative {
1391 index.add_cross_file_link(link);
1392 }
1393 for link in links.root_relative {
1396 index.add_root_relative_link(link);
1397 }
1398 }
1399
1400 fn cross_file_check(
1401 &self,
1402 _file_path: &Path,
1403 _file_index: &FileIndex,
1404 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1405 ) -> LintResult {
1406 Ok(Vec::new())
1416 }
1417}
1418
1419fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1424 let from_components: Vec<_> = from_dir.components().collect();
1425 let to_components: Vec<_> = to_path.components().collect();
1426
1427 let common_len = from_components
1429 .iter()
1430 .zip(to_components.iter())
1431 .take_while(|(a, b)| a == b)
1432 .count();
1433
1434 let mut result = PathBuf::new();
1435
1436 for _ in common_len..from_components.len() {
1438 result.push("..");
1439 }
1440
1441 for component in &to_components[common_len..] {
1443 result.push(component);
1444 }
1445
1446 result
1447}
1448
1449fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1455 let link_path = Path::new(raw_link_path);
1456
1457 let has_traversal = link_path
1459 .components()
1460 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1461
1462 if !has_traversal {
1463 return None;
1464 }
1465
1466 let combined = source_dir.join(link_path);
1468 let normalized_target = normalize_relative_path(&combined);
1469
1470 let normalized_source = normalize_relative_path(source_dir);
1472 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1473
1474 if shortest != link_path {
1476 let compact = shortest.to_string_lossy().to_string();
1477 if compact.is_empty() {
1479 return None;
1480 }
1481 Some(compact.replace('\\', "/"))
1483 } else {
1484 None
1485 }
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490 use super::*;
1491 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1492 use std::fs::File;
1493 use std::io::Write;
1494 use tempfile::tempdir;
1495
1496 #[test]
1497 fn test_strip_query_and_fragment() {
1498 assert_eq!(
1500 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1501 "file.png"
1502 );
1503 assert_eq!(
1504 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1505 "file.png"
1506 );
1507 assert_eq!(
1508 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1509 "file.png"
1510 );
1511
1512 assert_eq!(
1514 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1515 "file.md"
1516 );
1517 assert_eq!(
1518 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1519 "file.md"
1520 );
1521
1522 assert_eq!(
1524 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1525 "file.md"
1526 );
1527
1528 assert_eq!(
1530 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1531 "file.png"
1532 );
1533
1534 assert_eq!(
1536 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1537 "path/to/image.png"
1538 );
1539 assert_eq!(
1540 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1541 "path/to/image.png"
1542 );
1543
1544 assert_eq!(
1546 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1547 "file.md"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_url_decode() {
1553 assert_eq!(
1555 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1556 "penguin with space.jpg"
1557 );
1558
1559 assert_eq!(
1561 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1562 "assets/my file name.png"
1563 );
1564
1565 assert_eq!(
1567 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1568 "hello world!.md"
1569 );
1570
1571 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1573
1574 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1576
1577 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1579
1580 assert_eq!(
1582 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1583 "normal-file.md"
1584 );
1585
1586 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1588
1589 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1591
1592 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1594
1595 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1597
1598 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1600
1601 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1603
1604 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1606
1607 assert_eq!(
1609 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1610 "path/to/file.md"
1611 );
1612
1613 assert_eq!(
1615 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1616 "hello world/foo bar.md"
1617 );
1618
1619 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1621
1622 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1624 }
1625
1626 #[test]
1627 fn test_url_encoded_filenames() {
1628 let temp_dir = tempdir().unwrap();
1630 let base_path = temp_dir.path();
1631
1632 let file_with_spaces = base_path.join("penguin with space.jpg");
1634 File::create(&file_with_spaces)
1635 .unwrap()
1636 .write_all(b"image data")
1637 .unwrap();
1638
1639 let subdir = base_path.join("my images");
1641 std::fs::create_dir(&subdir).unwrap();
1642 let nested_file = subdir.join("photo 1.png");
1643 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1644
1645 let content = r#"
1647# Test Document with URL-Encoded Links
1648
1649
1650
1651
1652"#;
1653
1654 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1655
1656 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658
1659 assert_eq!(
1661 result.len(),
1662 1,
1663 "Should only warn about missing%20file.jpg. Got: {result:?}"
1664 );
1665 assert!(
1666 result[0].message.contains("missing%20file.jpg"),
1667 "Warning should mention the URL-encoded filename"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_external_urls() {
1673 let rule = MD057ExistingRelativeLinks::new();
1674
1675 assert!(rule.is_external_url("https://example.com"));
1677 assert!(rule.is_external_url("http://example.com"));
1678 assert!(rule.is_external_url("ftp://example.com"));
1679 assert!(rule.is_external_url("www.example.com"));
1680 assert!(rule.is_external_url("example.com"));
1681
1682 assert!(rule.is_external_url("file:///path/to/file"));
1684 assert!(rule.is_external_url("smb://server/share"));
1685 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1686 assert!(rule.is_external_url("mailto:user@example.com"));
1687 assert!(rule.is_external_url("tel:+1234567890"));
1688 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1689 assert!(rule.is_external_url("javascript:void(0)"));
1690 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1691 assert!(rule.is_external_url("git://github.com/repo.git"));
1692
1693 assert!(rule.is_external_url("user@example.com"));
1696 assert!(rule.is_external_url("steering@kubernetes.io"));
1697 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1698 assert!(rule.is_external_url("user_name@sub.domain.com"));
1699 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1700
1701 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"));
1712 assert!(!rule.is_external_url("/blog/2024/release.html"));
1713 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1714 assert!(!rule.is_external_url("/pkg/runtime"));
1715 assert!(!rule.is_external_url("/doc/go1compat"));
1716 assert!(!rule.is_external_url("/index.html"));
1717 assert!(!rule.is_external_url("/assets/logo.png"));
1718
1719 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1721 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1722 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1723 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1724 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1725
1726 assert!(rule.is_external_url("~/assets/image.png"));
1729 assert!(rule.is_external_url("~/components/Button.vue"));
1730 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1734 assert!(rule.is_external_url("@images/photo.jpg"));
1735 assert!(rule.is_external_url("@assets/styles.css"));
1736
1737 assert!(!rule.is_external_url("./relative/path.md"));
1739 assert!(!rule.is_external_url("relative/path.md"));
1740 assert!(!rule.is_external_url("../parent/path.md"));
1741 }
1742
1743 #[test]
1744 fn test_dot_com_only_skips_bare_domains() {
1745 let rule = MD057ExistingRelativeLinks::new();
1746
1747 assert!(rule.is_external_url("example.com"));
1749 assert!(rule.is_external_url("sub.example.com"));
1750
1751 assert!(!rule.is_external_url("../../vendor.com"));
1755 assert!(!rule.is_external_url("./vendor.com"));
1756 assert!(!rule.is_external_url("docs/vendor.com"));
1757 }
1758
1759 #[test]
1760 fn test_framework_path_aliases() {
1761 let temp_dir = tempdir().unwrap();
1763 let base_path = temp_dir.path();
1764
1765 let content = r#"
1767# Framework Path Aliases
1768
1769
1770
1771
1772
1773[Link](@/pages/about.md)
1774
1775This is a [real missing link](missing.md) that should be flagged.
1776"#;
1777
1778 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1779
1780 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781 let result = rule.check(&ctx).unwrap();
1782
1783 assert_eq!(
1785 result.len(),
1786 1,
1787 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1788 );
1789 assert!(
1790 result[0].message.contains("missing.md"),
1791 "Warning should be for missing.md"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_url_decode_security_path_traversal() {
1797 let temp_dir = tempdir().unwrap();
1800 let base_path = temp_dir.path();
1801
1802 let file_in_base = base_path.join("safe.md");
1804 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1805
1806 let content = r#"
1811[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1812[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1813[Safe link](safe.md)
1814"#;
1815
1816 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1817
1818 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(
1824 result.len(),
1825 2,
1826 "Should have warnings for traversal attempts. Got: {result:?}"
1827 );
1828 }
1829
1830 #[test]
1831 fn test_url_encoded_utf8_filenames() {
1832 let temp_dir = tempdir().unwrap();
1834 let base_path = temp_dir.path();
1835
1836 let cafe_file = base_path.join("café.md");
1838 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1839
1840 let content = r#"
1841[Café link](caf%C3%A9.md)
1842[Missing unicode](r%C3%A9sum%C3%A9.md)
1843"#;
1844
1845 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1846
1847 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1848 let result = rule.check(&ctx).unwrap();
1849
1850 assert_eq!(
1852 result.len(),
1853 1,
1854 "Should only warn about missing résumé.md. Got: {result:?}"
1855 );
1856 assert!(
1857 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1858 "Warning should mention the URL-encoded filename"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_url_encoded_emoji_filenames() {
1864 let temp_dir = tempdir().unwrap();
1867 let base_path = temp_dir.path();
1868
1869 let emoji_dir = base_path.join("👤 Personal");
1871 std::fs::create_dir(&emoji_dir).unwrap();
1872
1873 let file_path = emoji_dir.join("TV Shows.md");
1875 File::create(&file_path)
1876 .unwrap()
1877 .write_all(b"# TV Shows\n\nContent here.")
1878 .unwrap();
1879
1880 let content = r#"
1883# Test Document
1884
1885[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1886[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1887"#;
1888
1889 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1890
1891 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let result = rule.check(&ctx).unwrap();
1893
1894 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1896 assert!(
1897 result[0].message.contains("Missing.md"),
1898 "Warning should be for Missing.md, got: {}",
1899 result[0].message
1900 );
1901 }
1902
1903 #[test]
1904 fn test_no_warnings_without_base_path() {
1905 let rule = MD057ExistingRelativeLinks::new();
1906 let content = "[Link](missing.md)";
1907
1908 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let result = rule.check(&ctx).unwrap();
1910 assert!(result.is_empty(), "Should have no warnings without base path");
1911 }
1912
1913 #[test]
1914 fn test_existing_and_missing_links() {
1915 let temp_dir = tempdir().unwrap();
1917 let base_path = temp_dir.path();
1918
1919 let exists_path = base_path.join("exists.md");
1921 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1922
1923 assert!(exists_path.exists(), "exists.md should exist for this test");
1925
1926 let content = r#"
1928# Test Document
1929
1930[Valid Link](exists.md)
1931[Invalid Link](missing.md)
1932[External Link](https://example.com)
1933[Media Link](image.jpg)
1934 "#;
1935
1936 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1938
1939 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 let result = rule.check(&ctx).unwrap();
1942
1943 assert_eq!(result.len(), 2);
1945 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1946 assert!(messages.iter().any(|m| m.contains("missing.md")));
1947 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1948 }
1949
1950 #[test]
1951 fn test_angle_bracket_links() {
1952 let temp_dir = tempdir().unwrap();
1954 let base_path = temp_dir.path();
1955
1956 let exists_path = base_path.join("exists.md");
1958 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1959
1960 let content = r#"
1962# Test Document
1963
1964[Valid Link](<exists.md>)
1965[Invalid Link](<missing.md>)
1966[External Link](<https://example.com>)
1967 "#;
1968
1969 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1971
1972 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973 let result = rule.check(&ctx).unwrap();
1974
1975 assert_eq!(result.len(), 1, "Should have exactly one warning");
1977 assert!(
1978 result[0].message.contains("missing.md"),
1979 "Warning should mention missing.md"
1980 );
1981 }
1982
1983 #[test]
1984 fn test_angle_bracket_links_with_parens() {
1985 let temp_dir = tempdir().unwrap();
1987 let base_path = temp_dir.path();
1988
1989 let app_dir = base_path.join("app");
1991 std::fs::create_dir(&app_dir).unwrap();
1992 let upload_dir = app_dir.join("(upload)");
1993 std::fs::create_dir(&upload_dir).unwrap();
1994 let page_file = upload_dir.join("page.tsx");
1995 File::create(&page_file)
1996 .unwrap()
1997 .write_all(b"export default function Page() {}")
1998 .unwrap();
1999
2000 let content = r#"
2002# Test Document with Paths Containing Parens
2003
2004[Upload Page](<app/(upload)/page.tsx>)
2005[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2006[Missing](<app/(missing)/file.md>)
2007"#;
2008
2009 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2010
2011 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let result = rule.check(&ctx).unwrap();
2013
2014 assert_eq!(
2016 result.len(),
2017 1,
2018 "Should have exactly one warning for missing file. Got: {result:?}"
2019 );
2020 assert!(
2021 result[0].message.contains("app/(missing)/file.md"),
2022 "Warning should mention app/(missing)/file.md"
2023 );
2024 }
2025
2026 #[test]
2027 fn test_all_file_types_checked() {
2028 let temp_dir = tempdir().unwrap();
2030 let base_path = temp_dir.path();
2031
2032 let content = r#"
2034[Image Link](image.jpg)
2035[Video Link](video.mp4)
2036[Markdown Link](document.md)
2037[PDF Link](file.pdf)
2038"#;
2039
2040 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2041
2042 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2043 let result = rule.check(&ctx).unwrap();
2044
2045 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2047 }
2048
2049 #[test]
2050 fn test_code_span_detection() {
2051 let rule = MD057ExistingRelativeLinks::new();
2052
2053 let temp_dir = tempdir().unwrap();
2055 let base_path = temp_dir.path();
2056
2057 let rule = rule.with_path(base_path);
2058
2059 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2061
2062 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let result = rule.check(&ctx).unwrap();
2064
2065 assert_eq!(result.len(), 1, "Should only flag the real link");
2067 assert!(result[0].message.contains("nonexistent.md"));
2068 }
2069
2070 #[test]
2071 fn test_inline_code_spans() {
2072 let temp_dir = tempdir().unwrap();
2074 let base_path = temp_dir.path();
2075
2076 let content = r#"
2078# Test Document
2079
2080This is a normal link: [Link](missing.md)
2081
2082This is a code span with a link: `[Link](another-missing.md)`
2083
2084Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2085
2086 "#;
2087
2088 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2090
2091 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2093 let result = rule.check(&ctx).unwrap();
2094
2095 assert_eq!(result.len(), 1, "Should have exactly one warning");
2097 assert!(
2098 result[0].message.contains("missing.md"),
2099 "Warning should be for missing.md"
2100 );
2101 assert!(
2102 !result.iter().any(|w| w.message.contains("another-missing.md")),
2103 "Should not warn about link in code span"
2104 );
2105 assert!(
2106 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2107 "Should not warn about link in inline code"
2108 );
2109 }
2110
2111 #[test]
2112 fn test_extensionless_link_resolution() {
2113 let temp_dir = tempdir().unwrap();
2115 let base_path = temp_dir.path();
2116
2117 let page_path = base_path.join("page.md");
2119 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2120
2121 let content = r#"
2123# Test Document
2124
2125[Link without extension](page)
2126[Link with extension](page.md)
2127[Missing link](nonexistent)
2128"#;
2129
2130 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2131
2132 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2133 let result = rule.check(&ctx).unwrap();
2134
2135 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2138 assert!(
2139 result[0].message.contains("nonexistent"),
2140 "Warning should be for 'nonexistent' not 'page'"
2141 );
2142 }
2143
2144 #[test]
2146 fn test_cross_file_scope() {
2147 let rule = MD057ExistingRelativeLinks::new();
2148 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2149 }
2150
2151 #[test]
2152 fn test_contribute_to_index_extracts_markdown_links() {
2153 let rule = MD057ExistingRelativeLinks::new();
2154 let content = r#"
2155# Document
2156
2157[Link to docs](./docs/guide.md)
2158[Link with fragment](./other.md#section)
2159[External link](https://example.com)
2160[Image link](image.png)
2161[Media file](video.mp4)
2162"#;
2163
2164 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165 let mut index = FileIndex::new();
2166 rule.contribute_to_index(&ctx, &mut index);
2167
2168 assert_eq!(index.cross_file_links.len(), 2);
2170
2171 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2173 assert_eq!(index.cross_file_links[0].fragment, "");
2174
2175 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2177 assert_eq!(index.cross_file_links[1].fragment, "section");
2178 }
2179
2180 #[test]
2181 fn test_contribute_to_index_skips_external_and_anchors() {
2182 let rule = MD057ExistingRelativeLinks::new();
2183 let content = r#"
2184# Document
2185
2186[External](https://example.com)
2187[Another external](http://example.org)
2188[Fragment only](#section)
2189[FTP link](ftp://files.example.com)
2190[Mail link](mailto:test@example.com)
2191[WWW link](www.example.com)
2192"#;
2193
2194 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195 let mut index = FileIndex::new();
2196 rule.contribute_to_index(&ctx, &mut index);
2197
2198 assert_eq!(index.cross_file_links.len(), 0);
2200 }
2201
2202 #[test]
2203 fn test_cross_file_check_valid_link() {
2204 use crate::workspace_index::WorkspaceIndex;
2205
2206 let rule = MD057ExistingRelativeLinks::new();
2207
2208 let mut workspace_index = WorkspaceIndex::new();
2210 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2211
2212 let mut file_index = FileIndex::new();
2214 file_index.add_cross_file_link(CrossFileLinkIndex {
2215 target_path: "guide.md".to_string(),
2216 fragment: "".to_string(),
2217 line: 5,
2218 column: 1,
2219 origin: LinkOrigin::Body,
2220 });
2221
2222 let warnings = rule
2224 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2225 .unwrap();
2226
2227 assert!(warnings.is_empty());
2229 }
2230
2231 #[test]
2232 fn test_cross_file_check_missing_link() {
2233 use crate::workspace_index::WorkspaceIndex;
2236
2237 let rule = MD057ExistingRelativeLinks::new();
2238 let workspace_index = WorkspaceIndex::new();
2239
2240 let mut file_index = FileIndex::new();
2241 file_index.add_cross_file_link(CrossFileLinkIndex {
2242 target_path: "missing.md".to_string(),
2243 fragment: "".to_string(),
2244 line: 5,
2245 column: 1,
2246 origin: LinkOrigin::Body,
2247 });
2248
2249 let warnings = rule
2250 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2251 .unwrap();
2252
2253 assert!(
2255 warnings.is_empty(),
2256 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2257 );
2258 }
2259
2260 #[test]
2261 fn test_cross_file_check_parent_path() {
2262 use crate::workspace_index::WorkspaceIndex;
2263
2264 let rule = MD057ExistingRelativeLinks::new();
2265
2266 let mut workspace_index = WorkspaceIndex::new();
2268 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2269
2270 let mut file_index = FileIndex::new();
2272 file_index.add_cross_file_link(CrossFileLinkIndex {
2273 target_path: "../readme.md".to_string(),
2274 fragment: "".to_string(),
2275 line: 5,
2276 column: 1,
2277 origin: LinkOrigin::Body,
2278 });
2279
2280 let warnings = rule
2282 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2283 .unwrap();
2284
2285 assert!(warnings.is_empty());
2287 }
2288
2289 #[test]
2290 fn test_cross_file_check_html_link_with_md_source() {
2291 use crate::workspace_index::WorkspaceIndex;
2294
2295 let rule = MD057ExistingRelativeLinks::new();
2296
2297 let mut workspace_index = WorkspaceIndex::new();
2299 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2300
2301 let mut file_index = FileIndex::new();
2303 file_index.add_cross_file_link(CrossFileLinkIndex {
2304 target_path: "guide.html".to_string(),
2305 fragment: "section".to_string(),
2306 line: 10,
2307 column: 5,
2308 origin: LinkOrigin::Body,
2309 });
2310
2311 let warnings = rule
2313 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2314 .unwrap();
2315
2316 assert!(
2318 warnings.is_empty(),
2319 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_cross_file_check_html_link_without_source() {
2325 use crate::workspace_index::WorkspaceIndex;
2329
2330 let rule = MD057ExistingRelativeLinks::new();
2331 let workspace_index = WorkspaceIndex::new();
2332
2333 let mut file_index = FileIndex::new();
2334 file_index.add_cross_file_link(CrossFileLinkIndex {
2335 target_path: "missing.html".to_string(),
2336 fragment: "".to_string(),
2337 line: 10,
2338 column: 5,
2339 origin: LinkOrigin::Body,
2340 });
2341
2342 let warnings = rule
2343 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2344 .unwrap();
2345
2346 assert!(
2348 warnings.is_empty(),
2349 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2350 );
2351 }
2352
2353 #[test]
2354 fn test_normalize_path_function() {
2355 assert_eq!(
2357 normalize_relative_path(Path::new("docs/guide.md")),
2358 PathBuf::from("docs/guide.md")
2359 );
2360
2361 assert_eq!(
2363 normalize_relative_path(Path::new("./docs/guide.md")),
2364 PathBuf::from("docs/guide.md")
2365 );
2366
2367 assert_eq!(
2369 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2370 PathBuf::from("docs/guide.md")
2371 );
2372
2373 assert_eq!(
2375 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2376 PathBuf::from("a/d.md")
2377 );
2378 }
2379
2380 #[test]
2381 fn test_html_link_with_md_source() {
2382 let temp_dir = tempdir().unwrap();
2384 let base_path = temp_dir.path();
2385
2386 let md_file = base_path.join("guide.md");
2388 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2389
2390 let content = r#"
2391[Read the guide](guide.html)
2392[Also here](getting-started.html)
2393"#;
2394
2395 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2396 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397 let result = rule.check(&ctx).unwrap();
2398
2399 assert_eq!(
2401 result.len(),
2402 1,
2403 "Should only warn about missing source. Got: {result:?}"
2404 );
2405 assert!(result[0].message.contains("getting-started.html"));
2406 }
2407
2408 #[test]
2409 fn test_htm_link_with_md_source() {
2410 let temp_dir = tempdir().unwrap();
2412 let base_path = temp_dir.path();
2413
2414 let md_file = base_path.join("page.md");
2415 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2416
2417 let content = "[Page](page.htm)";
2418
2419 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2420 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2421 let result = rule.check(&ctx).unwrap();
2422
2423 assert!(
2424 result.is_empty(),
2425 "Should not warn when .md source exists for .htm link"
2426 );
2427 }
2428
2429 #[test]
2430 fn test_html_link_finds_various_markdown_extensions() {
2431 let temp_dir = tempdir().unwrap();
2433 let base_path = temp_dir.path();
2434
2435 File::create(base_path.join("doc.md")).unwrap();
2436 File::create(base_path.join("tutorial.mdx")).unwrap();
2437 File::create(base_path.join("guide.markdown")).unwrap();
2438
2439 let content = r#"
2440[Doc](doc.html)
2441[Tutorial](tutorial.html)
2442[Guide](guide.html)
2443"#;
2444
2445 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2446 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447 let result = rule.check(&ctx).unwrap();
2448
2449 assert!(
2450 result.is_empty(),
2451 "Should find all markdown variants as source files. Got: {result:?}"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_html_link_in_subdirectory() {
2457 let temp_dir = tempdir().unwrap();
2459 let base_path = temp_dir.path();
2460
2461 let docs_dir = base_path.join("docs");
2462 std::fs::create_dir(&docs_dir).unwrap();
2463 File::create(docs_dir.join("guide.md"))
2464 .unwrap()
2465 .write_all(b"# Guide")
2466 .unwrap();
2467
2468 let content = "[Guide](docs/guide.html)";
2469
2470 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2471 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2472 let result = rule.check(&ctx).unwrap();
2473
2474 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2475 }
2476
2477 #[test]
2478 fn test_absolute_path_skipped_in_check() {
2479 let temp_dir = tempdir().unwrap();
2482 let base_path = temp_dir.path();
2483
2484 let content = r#"
2485# Test Document
2486
2487[Go Runtime](/pkg/runtime)
2488[Go Runtime with Fragment](/pkg/runtime#section)
2489[API Docs](/api/v1/users)
2490[Blog Post](/blog/2024/release.html)
2491[React Hook](/react/hooks/use-state.html)
2492"#;
2493
2494 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2495 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2496 let result = rule.check(&ctx).unwrap();
2497
2498 assert!(
2500 result.is_empty(),
2501 "Absolute paths should be skipped. Got warnings: {result:?}"
2502 );
2503 }
2504
2505 #[test]
2506 fn test_absolute_path_skipped_in_cross_file_check() {
2507 use crate::workspace_index::WorkspaceIndex;
2509
2510 let rule = MD057ExistingRelativeLinks::new();
2511
2512 let workspace_index = WorkspaceIndex::new();
2514
2515 let mut file_index = FileIndex::new();
2517 file_index.add_cross_file_link(CrossFileLinkIndex {
2518 target_path: "/pkg/runtime.md".to_string(),
2519 fragment: "".to_string(),
2520 line: 5,
2521 column: 1,
2522 origin: LinkOrigin::Body,
2523 });
2524 file_index.add_cross_file_link(CrossFileLinkIndex {
2525 target_path: "/api/v1/users.md".to_string(),
2526 fragment: "section".to_string(),
2527 line: 10,
2528 column: 1,
2529 origin: LinkOrigin::Body,
2530 });
2531
2532 let warnings = rule
2534 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2535 .unwrap();
2536
2537 assert!(
2539 warnings.is_empty(),
2540 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2541 );
2542 }
2543
2544 #[test]
2545 fn test_protocol_relative_url_not_skipped() {
2546 let temp_dir = tempdir().unwrap();
2549 let base_path = temp_dir.path();
2550
2551 let content = r#"
2552# Test Document
2553
2554[External](//example.com/page)
2555[Another](//cdn.example.com/asset.js)
2556"#;
2557
2558 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2559 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2560 let result = rule.check(&ctx).unwrap();
2561
2562 assert!(
2564 result.is_empty(),
2565 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2566 );
2567 }
2568
2569 #[test]
2570 fn test_email_addresses_skipped() {
2571 let temp_dir = tempdir().unwrap();
2574 let base_path = temp_dir.path();
2575
2576 let content = r#"
2577# Test Document
2578
2579[Contact](user@example.com)
2580[Steering](steering@kubernetes.io)
2581[Support](john.doe+filter@company.co.uk)
2582[User](user_name@sub.domain.com)
2583"#;
2584
2585 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2587 let result = rule.check(&ctx).unwrap();
2588
2589 assert!(
2591 result.is_empty(),
2592 "Email addresses should be skipped. Got warnings: {result:?}"
2593 );
2594 }
2595
2596 #[test]
2597 fn test_email_addresses_vs_file_paths() {
2598 let temp_dir = tempdir().unwrap();
2601 let base_path = temp_dir.path();
2602
2603 let content = r#"
2604# Test Document
2605
2606[Email](user@example.com) <!-- Should be skipped (email) -->
2607[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2608[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2609"#;
2610
2611 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2612 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2613 let result = rule.check(&ctx).unwrap();
2614
2615 assert!(
2617 result.is_empty(),
2618 "All email addresses should be skipped. Got: {result:?}"
2619 );
2620 }
2621
2622 #[test]
2623 fn test_diagnostic_position_accuracy() {
2624 let temp_dir = tempdir().unwrap();
2626 let base_path = temp_dir.path();
2627
2628 let content = "prefix [text](missing.md) suffix";
2631 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2635 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2636 let result = rule.check(&ctx).unwrap();
2637
2638 assert_eq!(result.len(), 1, "Should have exactly one warning");
2639 assert_eq!(result[0].line, 1, "Should be on line 1");
2640 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2641 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2642 }
2643
2644 #[test]
2645 fn test_diagnostic_position_non_ascii_link() {
2646 let temp_dir = tempdir().unwrap();
2649 let base_path = temp_dir.path();
2650
2651 let content = "你好你好[你好](not-exist.md) bar";
2655
2656 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2657 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2658 let result = rule.check(&ctx).unwrap();
2659
2660 assert_eq!(result.len(), 1, "Should have exactly one warning");
2661 assert_eq!(result[0].line, 1, "Should be on line 1");
2662 assert_eq!(
2663 result[0].column, 10,
2664 "Column must be a character offset, not a byte offset"
2665 );
2666 assert_eq!(result[0].end_column, 22, "End column must be character-based");
2667 }
2668
2669 #[test]
2670 fn test_diagnostic_position_angle_brackets() {
2671 let temp_dir = tempdir().unwrap();
2673 let base_path = temp_dir.path();
2674
2675 let content = "[link](<missing.md>)";
2678 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2681 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2682 let result = rule.check(&ctx).unwrap();
2683
2684 assert_eq!(result.len(), 1, "Should have exactly one warning");
2685 assert_eq!(result[0].line, 1, "Should be on line 1");
2686 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2687 }
2688
2689 #[test]
2690 fn test_diagnostic_position_multiline() {
2691 let temp_dir = tempdir().unwrap();
2693 let base_path = temp_dir.path();
2694
2695 let content = r#"# Title
2696Some text on line 2
2697[link on line 3](missing1.md)
2698More text
2699[link on line 5](missing2.md)"#;
2700
2701 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2702 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2703 let result = rule.check(&ctx).unwrap();
2704
2705 assert_eq!(result.len(), 2, "Should have two warnings");
2706
2707 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2709 assert!(result[0].message.contains("missing1.md"));
2710
2711 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2713 assert!(result[1].message.contains("missing2.md"));
2714 }
2715
2716 #[test]
2717 fn test_diagnostic_position_with_spaces() {
2718 let temp_dir = tempdir().unwrap();
2720 let base_path = temp_dir.path();
2721
2722 let content = "[link]( missing.md )";
2723 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2728 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2729 let result = rule.check(&ctx).unwrap();
2730
2731 assert_eq!(result.len(), 1, "Should have exactly one warning");
2732 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2734 }
2735
2736 #[test]
2737 fn test_diagnostic_position_image() {
2738 let temp_dir = tempdir().unwrap();
2740 let base_path = temp_dir.path();
2741
2742 let content = "";
2743
2744 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2745 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2746 let result = rule.check(&ctx).unwrap();
2747
2748 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2749 assert_eq!(result[0].line, 1);
2750 assert!(result[0].column > 0, "Should have valid column position");
2752 assert!(result[0].message.contains("missing.jpg"));
2753 }
2754
2755 #[test]
2756 fn test_diagnostic_position_non_ascii_image() {
2757 let temp_dir = tempdir().unwrap();
2759 let base_path = temp_dir.path();
2760
2761 let content = "你好你好";
2764
2765 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2766 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767 let result = rule.check(&ctx).unwrap();
2768
2769 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2770 assert_eq!(result[0].line, 1, "Should be on line 1");
2771 assert_eq!(
2772 result[0].column, 5,
2773 "Column must be a character offset, not a byte offset"
2774 );
2775 assert!(result[0].message.contains("not-exist.png"));
2776 }
2777
2778 #[test]
2779 fn test_diagnostic_position_non_ascii_reference_def() {
2780 let temp_dir = tempdir().unwrap();
2784 let base_path = temp_dir.path();
2785
2786 let content = "[你好]: not-exist.md";
2789
2790 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2791 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793
2794 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2795 assert_eq!(result[0].line, 1, "Should be on line 1");
2796 assert_eq!(
2797 result[0].column, 7,
2798 "Column must be a character offset, not a byte offset"
2799 );
2800 assert_eq!(result[0].end_column, 19, "End column must be character-based");
2801 }
2802
2803 #[test]
2804 fn test_wikilinks_skipped() {
2805 let temp_dir = tempdir().unwrap();
2808 let base_path = temp_dir.path();
2809
2810 let content = r#"# Test Document
2811
2812[[Microsoft#Windows OS]]
2813[[SomePage]]
2814[[Page With Spaces]]
2815[[path/to/page#section]]
2816[[page|Display Text]]
2817
2818This is a [real missing link](missing.md) that should be flagged.
2819"#;
2820
2821 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2822 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2823 let result = rule.check(&ctx).unwrap();
2824
2825 assert_eq!(
2827 result.len(),
2828 1,
2829 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2830 );
2831 assert!(
2832 result[0].message.contains("missing.md"),
2833 "Warning should be for missing.md, not wikilinks"
2834 );
2835 }
2836
2837 #[test]
2838 fn test_wiki_embeds_skipped() {
2839 let temp_dir = tempdir().unwrap();
2843 let base_path = temp_dir.path();
2844
2845 let content = r#"# Test Document
2846
2847![[diagram.png]]
2848![[subfolder/diagram.png]]
2849![[diagram.png|300]]
2850![[Some Note]]
2851
2852This is a [real missing link](missing.md) that should be flagged.
2853"#;
2854
2855 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2856 for flavor in [
2857 crate::config::MarkdownFlavor::Obsidian,
2858 crate::config::MarkdownFlavor::Standard,
2859 ] {
2860 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
2861 let result = rule.check(&ctx).unwrap();
2862
2863 assert_eq!(
2864 result.len(),
2865 1,
2866 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
2867 );
2868 assert!(result[0].message.contains("missing.md"));
2869 }
2870 }
2871
2872 #[test]
2873 fn test_wikilinks_not_added_to_index() {
2874 let temp_dir = tempdir().unwrap();
2876 let base_path = temp_dir.path();
2877
2878 let content = r#"# Test Document
2879
2880[[Microsoft#Windows OS]]
2881[[SomePage#section]]
2882[Regular Link](other.md)
2883"#;
2884
2885 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2886 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2887
2888 let mut file_index = FileIndex::new();
2889 rule.contribute_to_index(&ctx, &mut file_index);
2890
2891 let cross_file_links = &file_index.cross_file_links;
2894 assert_eq!(
2895 cross_file_links.len(),
2896 1,
2897 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2898 );
2899 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2900 }
2901
2902 #[test]
2903 fn test_reference_definition_missing_file() {
2904 let temp_dir = tempdir().unwrap();
2906 let base_path = temp_dir.path();
2907
2908 let content = r#"# Test Document
2909
2910[test]: ./missing.md
2911[example]: ./nonexistent.html
2912
2913Use [test] and [example] here.
2914"#;
2915
2916 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2918 let result = rule.check(&ctx).unwrap();
2919
2920 assert_eq!(
2922 result.len(),
2923 2,
2924 "Should have warnings for missing reference definition targets. Got: {result:?}"
2925 );
2926 assert!(
2927 result.iter().any(|w| w.message.contains("missing.md")),
2928 "Should warn about missing.md"
2929 );
2930 assert!(
2931 result.iter().any(|w| w.message.contains("nonexistent.html")),
2932 "Should warn about nonexistent.html"
2933 );
2934 }
2935
2936 #[test]
2937 fn test_reference_definition_existing_file() {
2938 let temp_dir = tempdir().unwrap();
2940 let base_path = temp_dir.path();
2941
2942 let exists_path = base_path.join("exists.md");
2944 File::create(&exists_path)
2945 .unwrap()
2946 .write_all(b"# Existing file")
2947 .unwrap();
2948
2949 let content = r#"# Test Document
2950
2951[test]: ./exists.md
2952
2953Use [test] here.
2954"#;
2955
2956 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2957 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2958 let result = rule.check(&ctx).unwrap();
2959
2960 assert!(
2962 result.is_empty(),
2963 "Should not warn about existing file. Got: {result:?}"
2964 );
2965 }
2966
2967 #[test]
2968 fn test_reference_definition_external_url_skipped() {
2969 let temp_dir = tempdir().unwrap();
2971 let base_path = temp_dir.path();
2972
2973 let content = r#"# Test Document
2974
2975[google]: https://google.com
2976[example]: http://example.org
2977[mail]: mailto:test@example.com
2978[ftp]: ftp://files.example.com
2979[local]: ./missing.md
2980
2981Use [google], [example], [mail], [ftp], [local] here.
2982"#;
2983
2984 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2985 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2986 let result = rule.check(&ctx).unwrap();
2987
2988 assert_eq!(
2990 result.len(),
2991 1,
2992 "Should only warn about local missing file. Got: {result:?}"
2993 );
2994 assert!(
2995 result[0].message.contains("missing.md"),
2996 "Warning should be for missing.md"
2997 );
2998 }
2999
3000 #[test]
3001 fn test_reference_definition_fragment_only_skipped() {
3002 let temp_dir = tempdir().unwrap();
3004 let base_path = temp_dir.path();
3005
3006 let content = r#"# Test Document
3007
3008[section]: #my-section
3009
3010Use [section] here.
3011"#;
3012
3013 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3014 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3015 let result = rule.check(&ctx).unwrap();
3016
3017 assert!(
3019 result.is_empty(),
3020 "Should not warn about fragment-only reference. Got: {result:?}"
3021 );
3022 }
3023
3024 #[test]
3025 fn test_reference_definition_column_position() {
3026 let temp_dir = tempdir().unwrap();
3028 let base_path = temp_dir.path();
3029
3030 let content = "[ref]: ./missing.md";
3033 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3037 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let result = rule.check(&ctx).unwrap();
3039
3040 assert_eq!(result.len(), 1, "Should have exactly one warning");
3041 assert_eq!(result[0].line, 1, "Should be on line 1");
3042 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3043 }
3044
3045 #[test]
3046 fn test_reference_definition_html_with_md_source() {
3047 let temp_dir = tempdir().unwrap();
3049 let base_path = temp_dir.path();
3050
3051 let md_file = base_path.join("guide.md");
3053 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3054
3055 let content = r#"# Test Document
3056
3057[guide]: ./guide.html
3058[missing]: ./missing.html
3059
3060Use [guide] and [missing] here.
3061"#;
3062
3063 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3064 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3065 let result = rule.check(&ctx).unwrap();
3066
3067 assert_eq!(
3069 result.len(),
3070 1,
3071 "Should only warn about missing source. Got: {result:?}"
3072 );
3073 assert!(result[0].message.contains("missing.html"));
3074 }
3075
3076 #[test]
3077 fn test_reference_definition_url_encoded() {
3078 let temp_dir = tempdir().unwrap();
3080 let base_path = temp_dir.path();
3081
3082 let file_with_spaces = base_path.join("file with spaces.md");
3084 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3085
3086 let content = r#"# Test Document
3087
3088[spaces]: ./file%20with%20spaces.md
3089[missing]: ./missing%20file.md
3090
3091Use [spaces] and [missing] here.
3092"#;
3093
3094 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3095 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3096 let result = rule.check(&ctx).unwrap();
3097
3098 assert_eq!(
3100 result.len(),
3101 1,
3102 "Should only warn about missing URL-encoded file. Got: {result:?}"
3103 );
3104 assert!(result[0].message.contains("missing%20file.md"));
3105 }
3106
3107 #[test]
3108 fn test_inline_and_reference_both_checked() {
3109 let temp_dir = tempdir().unwrap();
3111 let base_path = temp_dir.path();
3112
3113 let content = r#"# Test Document
3114
3115[inline link](./inline-missing.md)
3116[ref]: ./ref-missing.md
3117
3118Use [ref] here.
3119"#;
3120
3121 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3122 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3123 let result = rule.check(&ctx).unwrap();
3124
3125 assert_eq!(
3127 result.len(),
3128 2,
3129 "Should warn about both inline and reference links. Got: {result:?}"
3130 );
3131 assert!(
3132 result.iter().any(|w| w.message.contains("inline-missing.md")),
3133 "Should warn about inline-missing.md"
3134 );
3135 assert!(
3136 result.iter().any(|w| w.message.contains("ref-missing.md")),
3137 "Should warn about ref-missing.md"
3138 );
3139 }
3140
3141 #[test]
3142 fn test_footnote_definitions_not_flagged() {
3143 let rule = MD057ExistingRelativeLinks::default();
3146
3147 let content = r#"# Title
3148
3149A footnote[^1].
3150
3151[^1]: [link](https://www.google.com).
3152"#;
3153
3154 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3155 let result = rule.check(&ctx).unwrap();
3156
3157 assert!(
3158 result.is_empty(),
3159 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3160 );
3161 }
3162
3163 #[test]
3164 fn test_footnote_with_relative_link_inside() {
3165 let rule = MD057ExistingRelativeLinks::default();
3168
3169 let content = r#"# Title
3170
3171See the footnote[^1].
3172
3173[^1]: Check out [this file](./existing.md) for more info.
3174[^2]: Also see [missing](./does-not-exist.md).
3175"#;
3176
3177 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3178 let result = rule.check(&ctx).unwrap();
3179
3180 for warning in &result {
3185 assert!(
3186 !warning.message.contains("[this file]"),
3187 "Footnote content should not be treated as URL: {warning:?}"
3188 );
3189 assert!(
3190 !warning.message.contains("[missing]"),
3191 "Footnote content should not be treated as URL: {warning:?}"
3192 );
3193 }
3194 }
3195
3196 #[test]
3197 fn test_mixed_footnotes_and_reference_definitions() {
3198 let temp_dir = tempdir().unwrap();
3200 let base_path = temp_dir.path();
3201
3202 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3203
3204 let content = r#"# Title
3205
3206A footnote[^1] and a [ref link][myref].
3207
3208[^1]: This is a footnote with [link](https://example.com).
3209
3210[myref]: ./missing-file.md "This should be checked"
3211"#;
3212
3213 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3214 let result = rule.check(&ctx).unwrap();
3215
3216 assert_eq!(
3218 result.len(),
3219 1,
3220 "Should only warn about the regular reference definition. Got: {result:?}"
3221 );
3222 assert!(
3223 result[0].message.contains("missing-file.md"),
3224 "Should warn about missing-file.md in reference definition"
3225 );
3226 }
3227
3228 #[test]
3229 fn test_absolute_links_ignore_by_default() {
3230 let temp_dir = tempdir().unwrap();
3232 let base_path = temp_dir.path();
3233
3234 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3235
3236 let content = r#"# Links
3237
3238[API docs](/api/v1/users)
3239[Blog post](/blog/2024/release.html)
3240
3241
3242[ref]: /docs/reference.md
3243"#;
3244
3245 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3246 let result = rule.check(&ctx).unwrap();
3247
3248 assert!(
3250 result.is_empty(),
3251 "Absolute links should be ignored by default. Got: {result:?}"
3252 );
3253 }
3254
3255 #[test]
3256 fn test_absolute_links_warn_config() {
3257 let temp_dir = tempdir().unwrap();
3259 let base_path = temp_dir.path();
3260
3261 let config = MD057Config {
3262 absolute_links: AbsoluteLinksOption::Warn,
3263 ..Default::default()
3264 };
3265 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3266
3267 let content = r#"# Links
3268
3269[API docs](/api/v1/users)
3270[Blog post](/blog/2024/release.html)
3271"#;
3272
3273 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3274 let result = rule.check(&ctx).unwrap();
3275
3276 assert_eq!(
3278 result.len(),
3279 2,
3280 "Should warn about both absolute links. Got: {result:?}"
3281 );
3282 assert!(
3283 result[0].message.contains("cannot be validated locally"),
3284 "Warning should explain why: {}",
3285 result[0].message
3286 );
3287 assert!(
3288 result[0].message.contains("/api/v1/users"),
3289 "Warning should include the link path"
3290 );
3291 }
3292
3293 #[test]
3294 fn test_absolute_links_warn_images() {
3295 let temp_dir = tempdir().unwrap();
3297 let base_path = temp_dir.path();
3298
3299 let config = MD057Config {
3300 absolute_links: AbsoluteLinksOption::Warn,
3301 ..Default::default()
3302 };
3303 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3304
3305 let content = r#"# Images
3306
3307
3308"#;
3309
3310 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3311 let result = rule.check(&ctx).unwrap();
3312
3313 assert_eq!(
3314 result.len(),
3315 1,
3316 "Should warn about absolute image path. Got: {result:?}"
3317 );
3318 assert!(
3319 result[0].message.contains("/assets/logo.png"),
3320 "Warning should include the image path"
3321 );
3322 }
3323
3324 #[test]
3325 fn test_absolute_links_warn_reference_definitions() {
3326 let temp_dir = tempdir().unwrap();
3328 let base_path = temp_dir.path();
3329
3330 let config = MD057Config {
3331 absolute_links: AbsoluteLinksOption::Warn,
3332 ..Default::default()
3333 };
3334 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3335
3336 let content = r#"# Reference
3337
3338See the [docs][ref].
3339
3340[ref]: /docs/reference.md
3341"#;
3342
3343 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3344 let result = rule.check(&ctx).unwrap();
3345
3346 assert_eq!(
3347 result.len(),
3348 1,
3349 "Should warn about absolute reference definition. Got: {result:?}"
3350 );
3351 assert!(
3352 result[0].message.contains("/docs/reference.md"),
3353 "Warning should include the reference path"
3354 );
3355 }
3356
3357 #[test]
3358 fn test_search_paths_inline_link() {
3359 let temp_dir = tempdir().unwrap();
3360 let base_path = temp_dir.path();
3361
3362 let assets_dir = base_path.join("assets");
3364 std::fs::create_dir_all(&assets_dir).unwrap();
3365 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3366
3367 let config = MD057Config {
3368 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3369 ..Default::default()
3370 };
3371 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3372
3373 let content = "# Test\n\n[Photo](photo.png)\n";
3374 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3375 let result = rule.check(&ctx).unwrap();
3376
3377 assert!(
3378 result.is_empty(),
3379 "Should find photo.png via search-paths. Got: {result:?}"
3380 );
3381 }
3382
3383 #[test]
3384 fn test_search_paths_image() {
3385 let temp_dir = tempdir().unwrap();
3386 let base_path = temp_dir.path();
3387
3388 let assets_dir = base_path.join("attachments");
3389 std::fs::create_dir_all(&assets_dir).unwrap();
3390 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3391
3392 let config = MD057Config {
3393 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3394 ..Default::default()
3395 };
3396 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3397
3398 let content = "# Test\n\n\n";
3399 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3400 let result = rule.check(&ctx).unwrap();
3401
3402 assert!(
3403 result.is_empty(),
3404 "Should find diagram.svg via search-paths. Got: {result:?}"
3405 );
3406 }
3407
3408 #[test]
3409 fn test_search_paths_reference_definition() {
3410 let temp_dir = tempdir().unwrap();
3411 let base_path = temp_dir.path();
3412
3413 let assets_dir = base_path.join("images");
3414 std::fs::create_dir_all(&assets_dir).unwrap();
3415 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3416
3417 let config = MD057Config {
3418 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3419 ..Default::default()
3420 };
3421 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3422
3423 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3424 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3425 let result = rule.check(&ctx).unwrap();
3426
3427 assert!(
3428 result.is_empty(),
3429 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3430 );
3431 }
3432
3433 #[test]
3434 fn test_search_paths_still_warns_when_truly_missing() {
3435 let temp_dir = tempdir().unwrap();
3436 let base_path = temp_dir.path();
3437
3438 let assets_dir = base_path.join("assets");
3439 std::fs::create_dir_all(&assets_dir).unwrap();
3440
3441 let config = MD057Config {
3442 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3443 ..Default::default()
3444 };
3445 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3446
3447 let content = "# Test\n\n\n";
3448 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3449 let result = rule.check(&ctx).unwrap();
3450
3451 assert_eq!(
3452 result.len(),
3453 1,
3454 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3455 );
3456 }
3457
3458 #[test]
3459 fn test_search_paths_nonexistent_directory() {
3460 let temp_dir = tempdir().unwrap();
3461 let base_path = temp_dir.path();
3462
3463 let config = MD057Config {
3464 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3465 ..Default::default()
3466 };
3467 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3468
3469 let content = "# Test\n\n\n";
3470 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3471 let result = rule.check(&ctx).unwrap();
3472
3473 assert_eq!(
3474 result.len(),
3475 1,
3476 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3477 );
3478 }
3479
3480 #[test]
3481 fn test_obsidian_attachment_folder_named() {
3482 let temp_dir = tempdir().unwrap();
3483 let vault = temp_dir.path().join("vault");
3484 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3485 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3486 std::fs::create_dir_all(vault.join("notes")).unwrap();
3487
3488 std::fs::write(
3489 vault.join(".obsidian/app.json"),
3490 r#"{"attachmentFolderPath": "Attachments"}"#,
3491 )
3492 .unwrap();
3493 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3494
3495 let notes_dir = vault.join("notes");
3496 let source_file = notes_dir.join("test.md");
3497 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3498
3499 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3500
3501 let content = "# Test\n\n\n";
3502 let ctx =
3503 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3504 let result = rule.check(&ctx).unwrap();
3505
3506 assert!(
3507 result.is_empty(),
3508 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3509 );
3510 }
3511
3512 #[test]
3513 fn test_obsidian_attachment_same_folder_as_file() {
3514 let temp_dir = tempdir().unwrap();
3515 let vault = temp_dir.path().join("vault-rf");
3516 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3517 std::fs::create_dir_all(vault.join("notes")).unwrap();
3518
3519 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3520
3521 let notes_dir = vault.join("notes");
3523 let source_file = notes_dir.join("test.md");
3524 std::fs::write(&source_file, "placeholder").unwrap();
3525 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3526
3527 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3528
3529 let content = "# Test\n\n\n";
3530 let ctx =
3531 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3532 let result = rule.check(&ctx).unwrap();
3533
3534 assert!(
3535 result.is_empty(),
3536 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3537 );
3538 }
3539
3540 #[test]
3541 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3542 let temp_dir = tempdir().unwrap();
3543 let vault = temp_dir.path().join("vault-nf");
3544 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3545 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3546 std::fs::create_dir_all(vault.join("notes")).unwrap();
3547
3548 std::fs::write(
3549 vault.join(".obsidian/app.json"),
3550 r#"{"attachmentFolderPath": "Attachments"}"#,
3551 )
3552 .unwrap();
3553 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3554
3555 let notes_dir = vault.join("notes");
3556 let source_file = notes_dir.join("test.md");
3557 std::fs::write(&source_file, "placeholder").unwrap();
3558
3559 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3560
3561 let content = "# Test\n\n\n";
3562 let ctx =
3564 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3565 let result = rule.check(&ctx).unwrap();
3566
3567 assert_eq!(
3568 result.len(),
3569 1,
3570 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3571 );
3572 }
3573
3574 #[test]
3575 fn test_search_paths_combined_with_obsidian() {
3576 let temp_dir = tempdir().unwrap();
3577 let vault = temp_dir.path().join("vault-combo");
3578 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3579 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3580 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3581 std::fs::create_dir_all(vault.join("notes")).unwrap();
3582
3583 std::fs::write(
3584 vault.join(".obsidian/app.json"),
3585 r#"{"attachmentFolderPath": "Attachments"}"#,
3586 )
3587 .unwrap();
3588 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3589 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3590
3591 let notes_dir = vault.join("notes");
3592 let source_file = notes_dir.join("test.md");
3593 std::fs::write(&source_file, "placeholder").unwrap();
3594
3595 let extra_assets_dir = vault.join("extra-assets");
3596 let config = MD057Config {
3597 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3598 ..Default::default()
3599 };
3600 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3601
3602 let content = "# Test\n\n\n\n\n";
3604 let ctx =
3605 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3606 let result = rule.check(&ctx).unwrap();
3607
3608 assert!(
3609 result.is_empty(),
3610 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3611 );
3612 }
3613
3614 #[test]
3615 fn test_obsidian_attachment_subfolder_under_file() {
3616 let temp_dir = tempdir().unwrap();
3617 let vault = temp_dir.path().join("vault-sub");
3618 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3619 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3620
3621 std::fs::write(
3622 vault.join(".obsidian/app.json"),
3623 r#"{"attachmentFolderPath": "./assets"}"#,
3624 )
3625 .unwrap();
3626 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3627
3628 let notes_dir = vault.join("notes");
3629 let source_file = notes_dir.join("test.md");
3630 std::fs::write(&source_file, "placeholder").unwrap();
3631
3632 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3633
3634 let content = "# Test\n\n\n";
3635 let ctx =
3636 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3637 let result = rule.check(&ctx).unwrap();
3638
3639 assert!(
3640 result.is_empty(),
3641 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3642 );
3643 }
3644
3645 #[test]
3646 fn test_obsidian_attachment_vault_root() {
3647 let temp_dir = tempdir().unwrap();
3648 let vault = temp_dir.path().join("vault-root");
3649 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3650 std::fs::create_dir_all(vault.join("notes")).unwrap();
3651
3652 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3654 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3655
3656 let notes_dir = vault.join("notes");
3657 let source_file = notes_dir.join("test.md");
3658 std::fs::write(&source_file, "placeholder").unwrap();
3659
3660 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3661
3662 let content = "# Test\n\n\n";
3663 let ctx =
3664 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3665 let result = rule.check(&ctx).unwrap();
3666
3667 assert!(
3668 result.is_empty(),
3669 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3670 );
3671 }
3672
3673 #[test]
3674 fn test_search_paths_multiple_directories() {
3675 let temp_dir = tempdir().unwrap();
3676 let base_path = temp_dir.path();
3677
3678 let dir_a = base_path.join("dir-a");
3679 let dir_b = base_path.join("dir-b");
3680 std::fs::create_dir_all(&dir_a).unwrap();
3681 std::fs::create_dir_all(&dir_b).unwrap();
3682 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3683 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3684
3685 let config = MD057Config {
3686 search_paths: vec![
3687 dir_a.to_string_lossy().into_owned(),
3688 dir_b.to_string_lossy().into_owned(),
3689 ],
3690 ..Default::default()
3691 };
3692 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3693
3694 let content = "# Test\n\n\n\n\n";
3695 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3696 let result = rule.check(&ctx).unwrap();
3697
3698 assert!(
3699 result.is_empty(),
3700 "Should find files across multiple search paths. Got: {result:?}"
3701 );
3702 }
3703
3704 #[test]
3713 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
3714 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
3715
3716 let temp_dir = tempdir().unwrap();
3717 let base_path = temp_dir.path();
3718
3719 let file_path = base_path.join("README.md");
3720 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
3721 std::fs::write(&file_path, content).unwrap();
3722
3723 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
3724
3725 let ctx = crate::lint_context::LintContext::new(
3726 content,
3727 crate::config::MarkdownFlavor::Standard,
3728 Some(file_path.clone()),
3729 );
3730 let per_file = rule.check(&ctx).unwrap();
3731 assert_eq!(
3732 per_file.len(),
3733 1,
3734 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
3735 );
3736
3737 let mut file_index = FileIndex::default();
3738 file_index.cross_file_links.push(CrossFileLinkIndex {
3739 target_path: "missing-guide.md".to_string(),
3740 fragment: String::new(),
3741 line: 3,
3742 column: 1,
3743 origin: LinkOrigin::Body,
3744 });
3745
3746 let result = rule
3747 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
3748 .unwrap();
3749
3750 assert!(
3751 result.is_empty(),
3752 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
3753 );
3754 }
3755
3756 #[test]
3757 fn test_check_clears_stale_cache() {
3758 let temp_dir = tempdir().unwrap();
3761 let base_path = temp_dir.path();
3762
3763 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3764
3765 let phantom_path = base_path.join("phantom.md");
3767 {
3768 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3769 cache.insert(phantom_path.clone(), true);
3770 }
3771
3772 let content = "[phantom](phantom.md)\n";
3773 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3774 let warnings = rule.check(&ctx).unwrap();
3775
3776 assert_eq!(
3778 warnings.len(),
3779 1,
3780 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3781 );
3782 assert!(warnings[0].message.contains("phantom.md"));
3783 }
3784
3785 #[test]
3786 fn test_check_does_not_carry_over_cache_between_runs() {
3787 let temp_dir = tempdir().unwrap();
3789 let base_path = temp_dir.path();
3790
3791 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3792
3793 let content = "[missing](nonexistent.md)\n";
3794 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3795
3796 let warnings_1 = rule.check(&ctx).unwrap();
3798 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3799
3800 let nonexistent_path = base_path.join("nonexistent.md");
3802 {
3803 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3804 cache.insert(nonexistent_path.clone(), true);
3805 }
3806
3807 let warnings_2 = rule.check(&ctx).unwrap();
3809 assert_eq!(
3810 warnings_2.len(),
3811 1,
3812 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3813 );
3814 }
3815
3816 #[test]
3822 fn test_no_duplicate_warnings_for_broken_relative_link() {
3823 use crate::workspace_index::WorkspaceIndex;
3824
3825 let temp_dir = tempdir().unwrap();
3826 let base_path = temp_dir.path();
3827
3828 let source_file = base_path.join("index.md");
3830 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3831
3832 let content = "[broken](does/not/exist.md)\n";
3833
3834 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3835
3836 let ctx = crate::lint_context::LintContext::new(
3838 content,
3839 crate::config::MarkdownFlavor::Standard,
3840 Some(source_file.clone()),
3841 );
3842 let check_warnings = rule.check(&ctx).unwrap();
3843
3844 let mut file_index = FileIndex::new();
3846 rule.contribute_to_index(&ctx, &mut file_index);
3847 let workspace_index = WorkspaceIndex::new();
3848 let cross_warnings = rule
3849 .cross_file_check(&source_file, &file_index, &workspace_index)
3850 .unwrap();
3851
3852 let total = check_warnings.len() + cross_warnings.len();
3853 assert_eq!(
3854 total, 1,
3855 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3856 check={check_warnings:?}, cross={cross_warnings:?}"
3857 );
3858 }
3859
3860 #[test]
3865 fn test_absolute_dir_link_accepted_relative_to_roots() {
3866 let temp_dir = tempdir().unwrap();
3867 let root = temp_dir.path();
3868
3869 let dir_d = root.join("d");
3871 std::fs::create_dir_all(&dir_d).unwrap();
3872 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3873
3874 let content = "\
3877[absolute dir](/d)\n\
3878[relative dir](d)\n\
3879[absolute file](/d/foo.md)\n\
3880[relative file](d/foo.md)\n";
3881
3882 let config = MD057Config {
3883 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3884 roots: vec![],
3885 ..Default::default()
3886 };
3887 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3888
3889 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3890 let result = rule.check(&ctx).unwrap();
3891
3892 assert!(
3893 result.is_empty(),
3894 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3895 );
3896 }
3897
3898 #[test]
3901 fn test_absolute_trailing_slash_dir_link_requires_index() {
3902 let temp_dir = tempdir().unwrap();
3903 let root = temp_dir.path();
3904
3905 let dir_d = root.join("d");
3907 std::fs::create_dir_all(&dir_d).unwrap();
3908 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3909
3910 let content = "[dir with slash](/d/)\n";
3912
3913 let config = MD057Config {
3914 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3915 roots: vec![],
3916 ..Default::default()
3917 };
3918 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3919
3920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3921 let result = rule.check(&ctx).unwrap();
3922
3923 assert_eq!(
3924 result.len(),
3925 1,
3926 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3927 );
3928 }
3929
3930 #[test]
3934 fn test_docs_dir_variant_still_enforces_index_md() {
3935 let temp_dir = tempdir().unwrap();
3936 let root = temp_dir.path();
3937
3938 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3940
3941 let docs_dir = root.join("docs");
3943 std::fs::create_dir_all(&docs_dir).unwrap();
3944 let section_dir = docs_dir.join("section");
3945 std::fs::create_dir_all(§ion_dir).unwrap();
3946 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3947
3948 let source_file = docs_dir.join("index.md");
3950 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3951
3952 let config = MD057Config {
3953 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3954 ..Default::default()
3955 };
3956 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3957
3958 let content = "[sec](/section)\n";
3959 let ctx = crate::lint_context::LintContext::new(
3960 content,
3961 crate::config::MarkdownFlavor::Standard,
3962 Some(source_file.clone()),
3963 );
3964 let result = rule.check(&ctx).unwrap();
3965
3966 assert_eq!(
3968 result.len(),
3969 1,
3970 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3971 );
3972 assert!(
3973 result[0].message.contains("index.md") || result[0].message.contains("section"),
3974 "Message should mention the directory or missing index.md: {}",
3975 result[0].message
3976 );
3977 }
3978
3979 #[test]
3985 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3986 let temp_dir = tempdir().unwrap();
3987 let root = temp_dir.path();
3988
3989 let guide_dir = root.join("guide");
3991 std::fs::create_dir_all(&guide_dir).unwrap();
3992 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3993
3994 let content = "[guide with fragment](/guide/#intro)\n";
3996
3997 let config = MD057Config {
3998 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3999 roots: vec![],
4000 ..Default::default()
4001 };
4002 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4003 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4004 let result = rule.check(&ctx).unwrap();
4005
4006 assert_eq!(
4007 result.len(),
4008 1,
4009 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4010 );
4011 }
4012}
4013
4014#[cfg(test)]
4015mod self_referential_links_tests {
4016 use super::*;
4017 use tempfile::tempdir;
4018
4019 fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4021 let source_file = dir.join(name);
4022 std::fs::write(&source_file, content).unwrap();
4023 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4024 let ctx =
4025 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4026 rule.check(&ctx).unwrap()
4027 }
4028
4029 fn enabled() -> MD057Config {
4030 MD057Config {
4031 self_referential_links: true,
4032 ..Default::default()
4033 }
4034 }
4035
4036 #[test]
4037 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4038 let temp_dir = tempdir().unwrap();
4039 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4040 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4041
4042 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4043 assert_eq!(
4044 result[0].message,
4045 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4046 );
4047 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4048 assert_eq!(fix.replacement, "#level-2-heading");
4049 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4050 }
4051
4052 #[test]
4053 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4054 let temp_dir = tempdir().unwrap();
4055 let content = "# Title\n\nSee [this file](test.md).\n";
4056 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4057
4058 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4059 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4060 assert!(
4061 result[0].fix.is_none(),
4062 "Dropping the link would change the document, so there is no fix"
4063 );
4064 }
4065
4066 #[test]
4067 fn test_the_check_is_off_by_default() {
4068 let temp_dir = tempdir().unwrap();
4069 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4070 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4071
4072 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4073 }
4074
4075 #[test]
4076 fn test_a_link_to_another_file_is_left_alone() {
4077 let temp_dir = tempdir().unwrap();
4078 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4079 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4080 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4081
4082 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4083 }
4084
4085 #[test]
4086 fn test_a_self_link_written_with_traversal_reports_once() {
4087 let temp_dir = tempdir().unwrap();
4088 let sub_dir = temp_dir.path().join("sub");
4089 std::fs::create_dir_all(&sub_dir).unwrap();
4090
4091 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4092 let config = MD057Config {
4093 self_referential_links: true,
4094 compact_paths: true,
4095 ..Default::default()
4096 };
4097 let result = check_as_file(&sub_dir, "test.md", content, config);
4098
4099 assert_eq!(
4100 result.len(),
4101 1,
4102 "A compacted path would still be a link back to this file. Got: {result:?}"
4103 );
4104 assert_eq!(
4105 result[0].message,
4106 "Relative link '../sub/test.md' points to the file it is in"
4107 );
4108 }
4109
4110 #[test]
4111 fn test_compact_paths_still_reports_a_link_to_another_file() {
4112 let temp_dir = tempdir().unwrap();
4113 let sub_dir = temp_dir.path().join("sub");
4114 std::fs::create_dir_all(&sub_dir).unwrap();
4115 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4116
4117 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4118 let config = MD057Config {
4119 self_referential_links: true,
4120 compact_paths: true,
4121 ..Default::default()
4122 };
4123 let result = check_as_file(&sub_dir, "test.md", content, config);
4124
4125 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4126 assert_eq!(
4127 result[0].message,
4128 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4129 );
4130 }
4131
4132 #[test]
4133 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4134 let temp_dir = tempdir().unwrap();
4135 let content = "# Title\n\nSee [this file](test#title).\n";
4136 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4137
4138 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4139 assert_eq!(
4140 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4141 Some("#title"),
4142 "Got: {result:?}"
4143 );
4144 }
4145
4146 #[test]
4147 fn test_a_reference_definition_pointing_at_its_own_file() {
4148 let temp_dir = tempdir().unwrap();
4149 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4150 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4151
4152 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4153 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4154 assert_eq!(fix.replacement, "#level-2-heading");
4155 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4156 }
4157
4158 #[test]
4159 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4160 let temp_dir = tempdir().unwrap();
4161 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4162 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4163
4164 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4165 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4166 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4169 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4170 .fix(&crate::lint_context::LintContext::new(
4171 content,
4172 crate::config::MarkdownFlavor::Standard,
4173 Some(temp_dir.path().join("test.md")),
4174 ))
4175 .unwrap();
4176 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4177 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4178 }
4179
4180 #[test]
4181 fn test_a_self_link_resolved_through_a_search_path() {
4182 let temp_dir = tempdir().unwrap();
4183 let guide_dir = temp_dir.path().join("docs/guide");
4184 std::fs::create_dir_all(&guide_dir).unwrap();
4185 let config = MD057Config {
4186 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4187 ..enabled()
4188 };
4189 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4190 let result = check_as_file(&guide_dir, "test.md", content, config);
4191
4192 assert_eq!(
4193 result.len(),
4194 1,
4195 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4196 );
4197 assert_eq!(
4198 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4199 Some("#title"),
4200 "Got: {result:?}"
4201 );
4202 }
4203
4204 #[test]
4205 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4206 let temp_dir = tempdir().unwrap();
4207 let guide_dir = temp_dir.path().join("docs/guide");
4208 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4209 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4210 let config = MD057Config {
4211 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4212 ..enabled()
4213 };
4214 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4215 let result = check_as_file(&guide_dir, "test.md", content, config);
4216
4217 assert!(
4218 result.is_empty(),
4219 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4220 );
4221 }
4222
4223 #[test]
4224 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4225 let temp_dir = tempdir().unwrap();
4226 let content = "# Title\n\n\n";
4227 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4228
4229 assert!(
4230 result.is_empty(),
4231 "An image is not a link the reader follows. Got: {result:?}"
4232 );
4233 }
4234
4235 #[test]
4236 fn test_a_query_string_is_reported_without_a_suggestion() {
4237 let temp_dir = tempdir().unwrap();
4238 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4239 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4240
4241 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4242 assert!(
4243 result[0].fix.is_none(),
4244 "A query does not survive losing its path. Got: {result:?}"
4245 );
4246 }
4247
4248 #[test]
4249 fn test_fix_rewrites_the_document_and_settles() {
4250 let temp_dir = tempdir().unwrap();
4251 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4252 let source_file = temp_dir.path().join("test.md");
4253 std::fs::write(&source_file, content).unwrap();
4254
4255 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4256 let ctx = crate::lint_context::LintContext::new(
4257 content,
4258 crate::config::MarkdownFlavor::Standard,
4259 Some(source_file.clone()),
4260 );
4261 let fixed = rule.fix(&ctx).unwrap();
4262 assert_eq!(
4263 fixed,
4264 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4265 );
4266
4267 let refixed =
4268 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4269 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4270 }
4271
4272 #[test]
4273 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4274 let unfixable = MD057ExistingRelativeLinks::default();
4275 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4276
4277 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4278 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4279 }
4280
4281 #[test]
4282 fn test_the_option_is_read_from_kebab_and_snake_case() {
4283 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4284 assert!(kebab.self_referential_links);
4285
4286 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4287 assert!(snake.self_referential_links);
4288 }
4289
4290 fn front_matter_checked() -> MD057Config {
4291 MD057Config {
4292 check_frontmatter: true,
4293 ..Default::default()
4294 }
4295 }
4296
4297 #[test]
4298 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4299 let temp_dir = tempdir().unwrap();
4300 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4301 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4302
4303 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4304 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4305 assert_eq!(result[0].line, 2);
4306 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4307 assert_eq!(result[0].end_column, 23);
4308 }
4309
4310 #[test]
4311 fn test_frontmatter_paths_are_not_checked_by_default() {
4312 let temp_dir = tempdir().unwrap();
4313 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4314 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4315
4316 assert!(
4317 result.is_empty(),
4318 "Frontmatter is only checked on request. Got: {result:?}"
4319 );
4320 }
4321
4322 #[test]
4323 fn test_an_existing_frontmatter_path_is_not_reported() {
4324 let temp_dir = tempdir().unwrap();
4325 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4326 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4327 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4328
4329 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4330 assert_eq!(result[0].line, 3);
4331 }
4332
4333 #[test]
4334 fn test_an_ignored_frontmatter_field_is_not_checked() {
4335 let temp_dir = tempdir().unwrap();
4336 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4337 let config = MD057Config {
4338 check_frontmatter: true,
4339 ignore_frontmatter_fields: vec!["Image".to_string()],
4340 ..Default::default()
4341 };
4342 let result = check_as_file(temp_dir.path(), "test.md", content, config);
4343
4344 assert_eq!(
4345 result.len(),
4346 1,
4347 "The ignored field is skipped and the other is not. Got: {result:?}"
4348 );
4349 assert_eq!(result[0].line, 3);
4350 }
4351
4352 #[test]
4353 fn test_an_external_frontmatter_url_is_not_reported() {
4354 let temp_dir = tempdir().unwrap();
4355 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4356 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4357
4358 assert!(
4359 result.is_empty(),
4360 "An external URL has no local target. Got: {result:?}"
4361 );
4362 }
4363
4364 #[test]
4365 fn test_a_frontmatter_fragment_is_left_to_md051() {
4366 let temp_dir = tempdir().unwrap();
4367 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4368 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4369
4370 assert!(
4371 result.is_empty(),
4372 "A fragment names a heading, not a file. Got: {result:?}"
4373 );
4374 }
4375
4376 #[test]
4377 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4378 let temp_dir = tempdir().unwrap();
4379 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4380
4381 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4382 assert!(
4383 ignored.is_empty(),
4384 "Absolute paths are ignored by default. Got: {ignored:?}"
4385 );
4386
4387 let warning_config = MD057Config {
4388 check_frontmatter: true,
4389 absolute_links: AbsoluteLinksOption::Warn,
4390 ..Default::default()
4391 };
4392 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4393 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4394 assert_eq!(
4395 warned[0].message,
4396 "Absolute link '/docs/guide.md' cannot be validated locally"
4397 );
4398 }
4399
4400 #[test]
4401 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4402 let temp_dir = tempdir().unwrap();
4403 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4404 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4405 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4406
4407 assert_eq!(
4408 result.len(),
4409 1,
4410 "A query names no file, so only the missing target is reported. Got: {result:?}"
4411 );
4412 assert_eq!(result[0].line, 2);
4413 assert_eq!(
4414 result[0].message,
4415 "Relative link 'docs/missing.md?raw=true' does not exist"
4416 );
4417 }
4418
4419 #[test]
4420 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4421 let temp_dir = tempdir().unwrap();
4422 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4423 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4424
4425 assert!(
4426 result.is_empty(),
4427 "Only path-shaped values are destinations. Got: {result:?}"
4428 );
4429 }
4430}