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};
12use regex::Regex;
13use std::collections::{HashMap, HashSet};
14use std::env;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17use std::sync::{Arc, Mutex};
18
19mod md057_config;
20use crate::rule_config_serde::RuleConfig;
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 flavor: crate::config::MarkdownFlavor,
155}
156
157impl Default for MD057ExistingRelativeLinks {
158 fn default() -> Self {
159 Self {
160 base_path: Arc::new(Mutex::new(None)),
161 config: MD057Config::default(),
162 flavor: crate::config::MarkdownFlavor::default(),
163 }
164 }
165}
166
167impl MD057ExistingRelativeLinks {
168 pub fn new() -> Self {
170 Self::default()
171 }
172
173 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
175 let path = path.as_ref();
176 let dir_path = if path.is_file() {
177 path.parent().map(std::path::Path::to_path_buf)
178 } else {
179 Some(path.to_path_buf())
180 };
181
182 if let Ok(mut guard) = self.base_path.lock() {
183 *guard = dir_path;
184 }
185 self
186 }
187
188 pub fn from_config_struct(config: MD057Config) -> Self {
189 Self {
190 base_path: Arc::new(Mutex::new(None)),
191 config,
192 flavor: crate::config::MarkdownFlavor::default(),
193 }
194 }
195
196 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
200 if Path::new(path_str).is_absolute() {
201 PathBuf::from(path_str)
202 } else {
203 project_root.join(path_str)
204 }
205 }
206
207 #[cfg(test)]
209 fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
210 self.flavor = flavor;
211 self
212 }
213
214 #[inline]
226 fn is_external_url(&self, url: &str) -> bool {
227 if url.is_empty() {
228 return false;
229 }
230
231 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
233 return true;
234 }
235
236 if url.starts_with("{{") || url.starts_with("{%") {
239 return true;
240 }
241
242 if url.contains('@') {
245 return true; }
247
248 if !url.contains('/') && url.ends_with(".com") {
258 return true;
259 }
260
261 if url.starts_with('~') || url.starts_with('@') {
265 return true;
266 }
267
268 false
270 }
271
272 #[inline]
274 fn is_fragment_only_link(&self, url: &str) -> bool {
275 url.starts_with('#')
276 }
277
278 #[inline]
281 fn is_absolute_path(url: &str) -> bool {
282 url.starts_with('/')
283 }
284
285 fn url_decode(path: &str) -> String {
289 if !path.contains('%') {
291 return path.to_string();
292 }
293
294 let bytes = path.as_bytes();
295 let mut result = Vec::with_capacity(bytes.len());
296 let mut i = 0;
297
298 while i < bytes.len() {
299 if bytes[i] == b'%' && i + 2 < bytes.len() {
300 let hex1 = bytes[i + 1];
302 let hex2 = bytes[i + 2];
303 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
304 result.push(d1 * 16 + d2);
305 i += 3;
306 continue;
307 }
308 }
309 result.push(bytes[i]);
310 i += 1;
311 }
312
313 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
315 }
316
317 fn strip_query_and_fragment(url: &str) -> &str {
325 let query_pos = url.find('?');
328 let fragment_pos = url.find('#');
329
330 match (query_pos, fragment_pos) {
331 (Some(q), Some(f)) => {
332 &url[..q.min(f)]
334 }
335 (Some(q), None) => &url[..q],
336 (None, Some(f)) => &url[..f],
337 (None, None) => url,
338 }
339 }
340
341 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
343 base_path.join(link)
344 }
345
346 fn compute_search_paths(
351 &self,
352 flavor: crate::config::MarkdownFlavor,
353 source_file: Option<&Path>,
354 base_path: &Path,
355 project_root: &Path,
356 ) -> Vec<PathBuf> {
357 let mut paths = Vec::new();
358
359 if flavor == crate::config::MarkdownFlavor::Obsidian
361 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
362 && attachment_dir != *base_path
363 {
364 paths.push(attachment_dir);
365 }
366
367 for search_path in &self.config.search_paths {
371 let resolved = Self::resolve_against_project_root(search_path, project_root);
372 if resolved != *base_path && !paths.contains(&resolved) {
373 paths.push(resolved);
374 }
375 }
376
377 paths
378 }
379
380 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
382 search_paths.iter().any(|dir| {
383 let candidate = dir.join(decoded_path);
384 file_exists_or_markdown_extension(&candidate)
385 })
386 }
387
388 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
394 if !self.config.compact_paths {
395 return None;
396 }
397
398 let path_end = url
400 .find('?')
401 .unwrap_or(url.len())
402 .min(url.find('#').unwrap_or(url.len()));
403 let path_part = &url[..path_end];
404 let suffix = &url[path_end..];
405
406 let decoded_path = Self::url_decode(path_part);
408
409 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
410 }
411
412 fn self_referential_link(
424 &self,
425 url: &str,
426 base_path: &Path,
427 search_paths: &[PathBuf],
428 source_file: Option<&Path>,
429 ) -> Option<SelfReferentialLink> {
430 if !self.config.self_referential_links {
431 return None;
432 }
433 let source_file = source_file?;
434
435 let path_part = Self::strip_query_and_fragment(url);
436 if path_part.is_empty() {
437 return None;
438 }
439 let suffix = &url[path_part.len()..];
440
441 let decoded_path = Self::url_decode(path_part);
442 let resolved = std::iter::once(base_path)
446 .chain(search_paths.iter().map(PathBuf::as_path))
447 .find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
448 if !Self::is_same_file(&resolved, source_file) {
449 return None;
450 }
451
452 match suffix.strip_prefix('#') {
456 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
457 _ => Some(SelfReferentialLink::WholeFile),
458 }
459 }
460
461 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
468 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
469 let label_end = Self::label_end(def)?;
470 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
471 title.saturating_sub(ref_def.byte_offset).min(def.len())
472 });
473 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
474 let start = ref_def.byte_offset + offset;
475 Some(start..start + ref_def.url.len())
476 }
477
478 fn label_end(def: &str) -> Option<usize> {
483 let bytes = def.as_bytes();
484 let mut i = 0;
485 while i < bytes.len() {
486 match bytes[i] {
487 b'\\' => i += 2,
488 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
489 _ => i += 1,
490 }
491 }
492 None
493 }
494
495 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
499 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
500 }
501
502 fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
505 match self.config.absolute_links {
506 AbsoluteLinksOption::Ignore => None,
507 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
508 AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
509 AbsoluteLinksOption::RelativeToRoots => {
510 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
511 }
512 }
513 }
514
515 fn check_front_matter(
522 &self,
523 ctx: &crate::lint_context::LintContext,
524 base_path: &Path,
525 search_paths: &[PathBuf],
526 project_root: &Path,
527 warnings: &mut Vec<LintWarning>,
528 ) {
529 if !self.config.check_frontmatter {
530 return;
531 }
532
533 let ignored: HashSet<String> = self
534 .config
535 .ignore_frontmatter_fields
536 .iter()
537 .map(|field| field.to_lowercase())
538 .collect();
539
540 for link in frontmatter_values::link_destinations(ctx, &ignored) {
541 let line = ctx.lines[link.line - 1].content(ctx.content);
542 let url = &line[link.range.clone()];
543
544 if self.is_external_url(url) || self.is_fragment_only_link(url) {
547 continue;
548 }
549
550 let column = byte_to_char_count(line, link.range.start);
551 let end_column = column + url.chars().count();
552
553 if Self::is_absolute_path(url) {
554 if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
555 warnings.push(LintWarning {
556 rule_name: Some(self.name().to_string()),
557 line: link.line,
558 column,
559 end_line: link.line,
560 end_column,
561 message,
562 severity: Severity::Warning,
563 fix: None,
564 });
565 }
566 continue;
567 }
568
569 if Self::relative_target_exists(url, base_path, search_paths) {
570 continue;
571 }
572
573 warnings.push(LintWarning {
574 rule_name: Some(self.name().to_string()),
575 line: link.line,
576 column,
577 end_line: link.line,
578 end_column,
579 message: format!("Relative link '{url}' does not exist"),
580 severity: Severity::Error,
581 fix: None,
582 });
583 }
584 }
585
586 fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
593 let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
594 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
595
596 if file_exists_or_markdown_extension(&resolved_path) {
598 return true;
599 }
600
601 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
602 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
603 && let (Some(stem), Some(parent)) = (
604 resolved_path.file_stem().and_then(|s| s.to_str()),
605 resolved_path.parent(),
606 )
607 && MARKDOWN_EXTENSIONS
608 .iter()
609 .any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
610 {
611 return true;
612 }
613
614 Self::exists_in_search_paths(&decoded_path, search_paths)
615 }
616
617 fn produces_fixes(&self) -> bool {
621 self.config.compact_paths || self.config.self_referential_links
622 }
623
624 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
626 match self_link {
627 SelfReferentialLink::Fragment(fragment) => {
628 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
629 }
630 SelfReferentialLink::WholeFile => {
631 format!("Relative link '{url}' points to the file it is in")
632 }
633 }
634 }
635
636 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
642 if resolved.file_name() != source_file.file_name() {
644 return false;
645 }
646 match (resolved.canonicalize(), source_file.canonicalize()) {
647 (Ok(link), Ok(source)) => link == source,
648 _ => normalize_path(resolved) == normalize_path(source_file),
649 }
650 }
651
652 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
658 let Some(docs_dir) = resolve_docs_dir(source_path) else {
659 return Some(format!(
660 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
661 ));
662 };
663
664 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
665
666 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
669 Resolution::Found => None,
670 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
671 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
672 resolved.display()
673 )),
674 Resolution::NotFound { resolved } => Some(format!(
675 "Absolute link '{url}' resolves to '{}' which does not exist",
676 resolved.display()
677 )),
678 }
679 }
680
681 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
690 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
691
692 for root in roots {
693 let root_path = Self::resolve_against_project_root(root, project_root);
694 if matches!(
697 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
698 Resolution::Found
699 ) {
700 return None;
701 }
702 }
703
704 if matches!(
705 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
707 Resolution::Found
708 ) {
709 return None;
710 }
711
712 let msg = if roots.is_empty() {
713 format!("Absolute link '{url}' was not found under the project root")
714 } else {
715 format!("Absolute link '{url}' was not found under any configured root or the project root")
716 };
717 Some(msg)
718 }
719
720 fn prepare_absolute_url(url: &str) -> (String, bool) {
724 let relative_url = url.trim_start_matches('/');
725 let file_path = Self::strip_query_and_fragment(relative_url);
726 let decoded = Self::url_decode(file_path);
727 let is_directory_link = url.ends_with('/') || decoded.is_empty();
728 (decoded, is_directory_link)
729 }
730
731 fn resolve_under_root_with_opts(
753 root_path: &Path,
754 decoded: &str,
755 is_directory_link: bool,
756 require_index_for_dirs: bool,
757 ) -> Resolution {
758 let resolved = root_path.join(decoded);
759
760 let is_dir = resolved.is_dir();
761
762 if is_directory_link || (require_index_for_dirs && is_dir) {
767 let index_path = resolved.join("index.md");
768 if file_exists_with_cache(&index_path) {
769 return Resolution::Found;
770 }
771 if is_dir {
772 return Resolution::DirectoryWithoutIndex { resolved };
773 }
774 }
775
776 let decoded_has_trailing_slash = decoded.ends_with('/');
782 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
783 return Resolution::Found;
784 }
785
786 if file_exists_or_markdown_extension(&resolved) {
787 return Resolution::Found;
788 }
789
790 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
793 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
794 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
795 {
796 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
797 let source_path = parent.join(format!("{stem}{md_ext}"));
798 file_exists_with_cache(&source_path)
799 });
800 if has_md_source {
801 return Resolution::Found;
802 }
803 }
804
805 Resolution::NotFound { resolved }
806 }
807}
808
809enum Resolution {
813 Found,
814 DirectoryWithoutIndex { resolved: PathBuf },
815 NotFound { resolved: PathBuf },
816}
817
818fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
829 let caps = re.captures_at(line, expected_start)?;
830 if caps.get(0)?.start() != expected_start {
831 return None;
832 }
833 Some(caps)
834}
835
836impl Rule for MD057ExistingRelativeLinks {
837 fn name(&self) -> &'static str {
838 "MD057"
839 }
840
841 fn description(&self) -> &'static str {
842 "Relative links should point to existing files"
843 }
844
845 fn category(&self) -> RuleCategory {
846 RuleCategory::Link
847 }
848
849 fn skippable_by_category(&self) -> bool {
850 !self.config.check_frontmatter
853 }
854
855 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
856 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
857 }
858
859 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
860 let content = ctx.content;
861
862 if content.is_empty() {
863 return Ok(Vec::new());
864 }
865
866 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
870 if !has_body_links && !self.checks_front_matter_of(ctx) {
871 return Ok(Vec::new());
872 }
873
874 reset_file_existence_cache();
876
877 let mut warnings = Vec::new();
878
879 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
883
884 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
888
889 let self_path: Option<PathBuf> = ctx
892 .source_file
893 .as_ref()
894 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.clone()));
895
896 let base_path: Option<PathBuf> = {
900 if explicit_base.is_some() {
901 explicit_base
902 } else if let Some(ref resolved_file) = self_path {
903 resolved_file
907 .parent()
908 .map(std::path::Path::to_path_buf)
909 .or_else(|| Some(CURRENT_DIR.clone()))
910 } else {
911 None
913 }
914 };
915
916 let Some(base_path) = base_path else {
918 return Ok(warnings);
919 };
920
921 let extra_search_paths =
923 self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
924
925 if !ctx.links.is_empty() {
927 let line_index = &ctx.line_index;
929
930 let lines = ctx.raw_lines();
932
933 let mut processed_lines = std::collections::HashSet::new();
936
937 for link in &ctx.links {
938 let line_idx = link.line - 1;
939 if line_idx >= lines.len() {
940 continue;
941 }
942
943 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
945 continue;
946 }
947
948 if !processed_lines.insert(line_idx) {
950 continue;
951 }
952
953 let line = lines[line_idx];
954
955 if !line.contains("](") {
957 continue;
958 }
959
960 for link_match in LINK_START_REGEX.find_iter(line) {
962 if link_match.as_str().starts_with('!') {
969 let escapes = line[..link_match.start()]
970 .bytes()
971 .rev()
972 .take_while(|&b| b == b'\\')
973 .count();
974 if escapes % 2 == 0 {
975 continue;
976 }
977 }
978
979 let start_pos = link_match.start();
980 let end_pos = link_match.end();
981
982 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
984 let absolute_start_pos = line_start_byte + start_pos;
985
986 if ctx.is_in_code_span_byte(absolute_start_pos) {
988 continue;
989 }
990
991 if ctx.is_in_math_span(absolute_start_pos) {
993 continue;
994 }
995
996 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1003 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1004 .or_else(|| {
1005 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1006 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1007 });
1008
1009 if let Some((caps, url_group)) = caps_and_url {
1010 let url = url_group.as_str().trim();
1011
1012 if url.is_empty() {
1014 continue;
1015 }
1016
1017 if url.starts_with('`') && url.ends_with('`') {
1021 continue;
1022 }
1023
1024 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1026 continue;
1027 }
1028
1029 if Self::is_absolute_path(url) {
1031 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1032 warnings.push(LintWarning {
1033 rule_name: Some(self.name().to_string()),
1034 line: link.line,
1035 column: byte_to_char_count(line, url_group.start()),
1036 end_line: link.line,
1037 end_column: byte_to_char_count(line, url_group.end()),
1038 message,
1039 severity: Severity::Warning,
1040 fix: None,
1041 });
1042 }
1043 continue;
1044 }
1045
1046 let full_url_for_compact = if let Some(frag) = caps.get(2) {
1050 format!("{url}{}", frag.as_str())
1051 } else {
1052 url.to_string()
1053 };
1054 if let Some(self_link) = self.self_referential_link(
1059 &full_url_for_compact,
1060 &base_path,
1061 &extra_search_paths,
1062 self_path.as_deref(),
1063 ) {
1064 let url_start = url_group.start();
1065 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1066 let fix_byte_start = line_start_byte + url_start;
1067 let fix_byte_end = line_start_byte + url_end;
1068 warnings.push(LintWarning {
1069 rule_name: Some(self.name().to_string()),
1070 line: link.line,
1071 column: byte_to_char_count(line, url_start),
1072 end_line: link.line,
1073 end_column: byte_to_char_count(line, url_end),
1074 message: Self::self_referential_message(&full_url_for_compact, &self_link),
1075 severity: Severity::Warning,
1076 fix: match &self_link {
1077 SelfReferentialLink::Fragment(fragment) => {
1078 Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1079 }
1080 SelfReferentialLink::WholeFile => None,
1081 },
1082 });
1083 continue;
1084 }
1085
1086 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1087 let url_start = url_group.start();
1088 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1089 let fix_byte_start = line_start_byte + url_start;
1090 let fix_byte_end = line_start_byte + url_end;
1091 warnings.push(LintWarning {
1092 rule_name: Some(self.name().to_string()),
1093 line: link.line,
1094 column: byte_to_char_count(line, url_start),
1095 end_line: link.line,
1096 end_column: byte_to_char_count(line, url_end),
1097 message: format!(
1098 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1099 ),
1100 severity: Severity::Warning,
1101 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1102 });
1103 }
1104
1105 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1106 continue;
1107 }
1108
1109 let url_start = url_group.start();
1113 let url_end = url_group.end();
1114
1115 warnings.push(LintWarning {
1116 rule_name: Some(self.name().to_string()),
1117 line: link.line,
1118 column: byte_to_char_count(line, url_start),
1119 end_line: link.line,
1120 end_column: byte_to_char_count(line, url_end),
1121 message: format!("Relative link '{url}' does not exist"),
1122 severity: Severity::Error,
1123 fix: None,
1124 });
1125 }
1126 }
1127 }
1128 }
1129
1130 for image in &ctx.images {
1132 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1134 continue;
1135 }
1136
1137 let url = image.url.as_ref();
1138
1139 if url.is_empty() {
1141 continue;
1142 }
1143
1144 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1146 continue;
1147 }
1148
1149 if Self::is_absolute_path(url) {
1151 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1152 warnings.push(LintWarning {
1153 rule_name: Some(self.name().to_string()),
1154 line: image.line,
1155 column: image.start_col + 1,
1156 end_line: image.line,
1157 end_column: image.start_col + 1 + url.chars().count(),
1158 message,
1159 severity: Severity::Warning,
1160 fix: None,
1161 });
1162 }
1163 continue;
1164 }
1165
1166 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1168 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1171 let fix_byte_start = image.byte_offset + url_offset;
1172 let fix_byte_end = fix_byte_start + url.len();
1173 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1174 });
1175
1176 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1177 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
1178 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1181 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1182 });
1183 warnings.push(LintWarning {
1184 rule_name: Some(self.name().to_string()),
1185 line: image.line,
1186 column: url_col,
1187 end_line: image.line,
1188 end_column: url_col + url.chars().count(),
1189 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1190 severity: Severity::Warning,
1191 fix,
1192 });
1193 }
1194
1195 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1196 continue;
1197 }
1198
1199 warnings.push(LintWarning {
1202 rule_name: Some(self.name().to_string()),
1203 line: image.line,
1204 column: image.start_col + 1,
1205 end_line: image.line,
1206 end_column: image.start_col + 1 + url.chars().count(),
1207 message: format!("Relative link '{url}' does not exist"),
1208 severity: Severity::Error,
1209 fix: None,
1210 });
1211 }
1212
1213 for ref_def in &ctx.reference_defs {
1215 let url = &ref_def.url;
1216
1217 if url.is_empty() {
1219 continue;
1220 }
1221
1222 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1224 continue;
1225 }
1226
1227 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1231 let (line, col) = url_range
1232 .as_ref()
1233 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1234 let end_col = col + url.chars().count();
1235
1236 if Self::is_absolute_path(url) {
1238 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1239 warnings.push(LintWarning {
1240 rule_name: Some(self.name().to_string()),
1241 line,
1242 column: col,
1243 end_line: line,
1244 end_column: end_col,
1245 message,
1246 severity: Severity::Warning,
1247 fix: None,
1248 });
1249 }
1250 continue;
1251 }
1252
1253 if let Some(self_link) =
1255 self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
1256 {
1257 warnings.push(LintWarning {
1258 rule_name: Some(self.name().to_string()),
1259 line,
1260 column: col,
1261 end_line: line,
1262 end_column: end_col,
1263 message: Self::self_referential_message(url, &self_link),
1264 severity: Severity::Warning,
1265 fix: match (&self_link, &url_range) {
1266 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1267 Some(Fix::new(range.clone(), fragment.clone()))
1268 }
1269 _ => None,
1270 },
1271 });
1272 continue;
1273 }
1274
1275 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1277 warnings.push(LintWarning {
1278 rule_name: Some(self.name().to_string()),
1279 line,
1280 column: col,
1281 end_line: line,
1282 end_column: end_col,
1283 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1284 severity: Severity::Warning,
1285 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1286 });
1287 }
1288
1289 if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1290 continue;
1291 }
1292
1293 warnings.push(LintWarning {
1295 rule_name: Some(self.name().to_string()),
1296 line,
1297 column: col,
1298 end_line: line,
1299 end_column: end_col,
1300 message: format!("Relative link '{url}' does not exist"),
1301 severity: Severity::Error,
1302 fix: None,
1303 });
1304 }
1305
1306 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1307
1308 Ok(warnings)
1309 }
1310
1311 fn fix_capability(&self) -> FixCapability {
1312 if self.produces_fixes() {
1313 FixCapability::ConditionallyFixable
1314 } else {
1315 FixCapability::Unfixable
1316 }
1317 }
1318
1319 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1320 if !self.produces_fixes() {
1321 return Ok(ctx.content.to_string());
1322 }
1323
1324 let warnings = self.check(ctx)?;
1325 let warnings =
1326 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1327 let mut content = ctx.content.to_string();
1328
1329 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1331 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1332
1333 let mut last_applied_start: Option<usize> = None;
1339 for fix in fixes {
1340 if let Some(prev_start) = last_applied_start
1341 && fix.range.end > prev_start
1342 {
1343 continue;
1344 }
1345 if fix.range.end <= content.len() {
1346 content.replace_range(fix.range.clone(), &fix.replacement);
1347 last_applied_start = Some(fix.range.start);
1348 }
1349 }
1350
1351 Ok(content)
1352 }
1353
1354 fn as_any(&self) -> &dyn std::any::Any {
1355 self
1356 }
1357
1358 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1359 let default_config = MD057Config::default();
1360 let json_value = serde_json::to_value(&default_config).ok()?;
1361 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1362
1363 if let toml::Value::Table(table) = toml_value {
1364 if !table.is_empty() {
1365 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1366 } else {
1367 None
1368 }
1369 } else {
1370 None
1371 }
1372 }
1373
1374 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1375 where
1376 Self: Sized,
1377 {
1378 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1379 let mut rule = Self::from_config_struct(rule_config);
1380 rule.flavor = config.global.flavor;
1381 Box::new(rule)
1382 }
1383
1384 fn cross_file_scope(&self) -> CrossFileScope {
1385 CrossFileScope::Workspace
1386 }
1387
1388 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1389 let links = extract_cross_file_links(ctx);
1392 for link in links.relative {
1393 index.add_cross_file_link(link);
1394 }
1395 for link in links.root_relative {
1398 index.add_root_relative_link(link);
1399 }
1400 }
1401
1402 fn cross_file_check(
1403 &self,
1404 _file_path: &Path,
1405 _file_index: &FileIndex,
1406 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1407 ) -> LintResult {
1408 Ok(Vec::new())
1418 }
1419}
1420
1421fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1426 let from_components: Vec<_> = from_dir.components().collect();
1427 let to_components: Vec<_> = to_path.components().collect();
1428
1429 let common_len = from_components
1431 .iter()
1432 .zip(to_components.iter())
1433 .take_while(|(a, b)| a == b)
1434 .count();
1435
1436 let mut result = PathBuf::new();
1437
1438 for _ in common_len..from_components.len() {
1440 result.push("..");
1441 }
1442
1443 for component in &to_components[common_len..] {
1445 result.push(component);
1446 }
1447
1448 result
1449}
1450
1451fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1457 let link_path = Path::new(raw_link_path);
1458
1459 let has_traversal = link_path
1461 .components()
1462 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1463
1464 if !has_traversal {
1465 return None;
1466 }
1467
1468 let combined = source_dir.join(link_path);
1470 let normalized_target = normalize_path(&combined);
1471
1472 let normalized_source = normalize_path(source_dir);
1474 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1475
1476 if shortest != link_path {
1478 let compact = shortest.to_string_lossy().to_string();
1479 if compact.is_empty() {
1481 return None;
1482 }
1483 Some(compact.replace('\\', "/"))
1485 } else {
1486 None
1487 }
1488}
1489
1490fn normalize_path(path: &Path) -> PathBuf {
1492 let mut components = Vec::new();
1493
1494 for component in path.components() {
1495 match component {
1496 std::path::Component::ParentDir => {
1497 if !components.is_empty() {
1499 components.pop();
1500 }
1501 }
1502 std::path::Component::CurDir => {
1503 }
1505 _ => {
1506 components.push(component);
1507 }
1508 }
1509 }
1510
1511 components.iter().collect()
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use crate::workspace_index::CrossFileLinkIndex;
1518 use std::fs::File;
1519 use std::io::Write;
1520 use tempfile::tempdir;
1521
1522 #[test]
1523 fn test_strip_query_and_fragment() {
1524 assert_eq!(
1526 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1527 "file.png"
1528 );
1529 assert_eq!(
1530 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1531 "file.png"
1532 );
1533 assert_eq!(
1534 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1535 "file.png"
1536 );
1537
1538 assert_eq!(
1540 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1541 "file.md"
1542 );
1543 assert_eq!(
1544 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1545 "file.md"
1546 );
1547
1548 assert_eq!(
1550 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1551 "file.md"
1552 );
1553
1554 assert_eq!(
1556 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1557 "file.png"
1558 );
1559
1560 assert_eq!(
1562 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1563 "path/to/image.png"
1564 );
1565 assert_eq!(
1566 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1567 "path/to/image.png"
1568 );
1569
1570 assert_eq!(
1572 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1573 "file.md"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_url_decode() {
1579 assert_eq!(
1581 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1582 "penguin with space.jpg"
1583 );
1584
1585 assert_eq!(
1587 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1588 "assets/my file name.png"
1589 );
1590
1591 assert_eq!(
1593 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1594 "hello world!.md"
1595 );
1596
1597 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1599
1600 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1602
1603 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1605
1606 assert_eq!(
1608 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1609 "normal-file.md"
1610 );
1611
1612 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1614
1615 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1617
1618 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1620
1621 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1623
1624 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1626
1627 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1629
1630 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1632
1633 assert_eq!(
1635 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1636 "path/to/file.md"
1637 );
1638
1639 assert_eq!(
1641 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1642 "hello world/foo bar.md"
1643 );
1644
1645 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1647
1648 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1650 }
1651
1652 #[test]
1653 fn test_url_encoded_filenames() {
1654 let temp_dir = tempdir().unwrap();
1656 let base_path = temp_dir.path();
1657
1658 let file_with_spaces = base_path.join("penguin with space.jpg");
1660 File::create(&file_with_spaces)
1661 .unwrap()
1662 .write_all(b"image data")
1663 .unwrap();
1664
1665 let subdir = base_path.join("my images");
1667 std::fs::create_dir(&subdir).unwrap();
1668 let nested_file = subdir.join("photo 1.png");
1669 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1670
1671 let content = r#"
1673# Test Document with URL-Encoded Links
1674
1675
1676
1677
1678"#;
1679
1680 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1681
1682 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let result = rule.check(&ctx).unwrap();
1684
1685 assert_eq!(
1687 result.len(),
1688 1,
1689 "Should only warn about missing%20file.jpg. Got: {result:?}"
1690 );
1691 assert!(
1692 result[0].message.contains("missing%20file.jpg"),
1693 "Warning should mention the URL-encoded filename"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_external_urls() {
1699 let rule = MD057ExistingRelativeLinks::new();
1700
1701 assert!(rule.is_external_url("https://example.com"));
1703 assert!(rule.is_external_url("http://example.com"));
1704 assert!(rule.is_external_url("ftp://example.com"));
1705 assert!(rule.is_external_url("www.example.com"));
1706 assert!(rule.is_external_url("example.com"));
1707
1708 assert!(rule.is_external_url("file:///path/to/file"));
1710 assert!(rule.is_external_url("smb://server/share"));
1711 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1712 assert!(rule.is_external_url("mailto:user@example.com"));
1713 assert!(rule.is_external_url("tel:+1234567890"));
1714 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1715 assert!(rule.is_external_url("javascript:void(0)"));
1716 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1717 assert!(rule.is_external_url("git://github.com/repo.git"));
1718
1719 assert!(rule.is_external_url("user@example.com"));
1722 assert!(rule.is_external_url("steering@kubernetes.io"));
1723 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1724 assert!(rule.is_external_url("user_name@sub.domain.com"));
1725 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1726
1727 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"));
1738 assert!(!rule.is_external_url("/blog/2024/release.html"));
1739 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1740 assert!(!rule.is_external_url("/pkg/runtime"));
1741 assert!(!rule.is_external_url("/doc/go1compat"));
1742 assert!(!rule.is_external_url("/index.html"));
1743 assert!(!rule.is_external_url("/assets/logo.png"));
1744
1745 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1747 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1748 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1749 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1750 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1751
1752 assert!(rule.is_external_url("~/assets/image.png"));
1755 assert!(rule.is_external_url("~/components/Button.vue"));
1756 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1760 assert!(rule.is_external_url("@images/photo.jpg"));
1761 assert!(rule.is_external_url("@assets/styles.css"));
1762
1763 assert!(!rule.is_external_url("./relative/path.md"));
1765 assert!(!rule.is_external_url("relative/path.md"));
1766 assert!(!rule.is_external_url("../parent/path.md"));
1767 }
1768
1769 #[test]
1770 fn test_dot_com_only_skips_bare_domains() {
1771 let rule = MD057ExistingRelativeLinks::new();
1772
1773 assert!(rule.is_external_url("example.com"));
1775 assert!(rule.is_external_url("sub.example.com"));
1776
1777 assert!(!rule.is_external_url("../../vendor.com"));
1781 assert!(!rule.is_external_url("./vendor.com"));
1782 assert!(!rule.is_external_url("docs/vendor.com"));
1783 }
1784
1785 #[test]
1786 fn test_framework_path_aliases() {
1787 let temp_dir = tempdir().unwrap();
1789 let base_path = temp_dir.path();
1790
1791 let content = r#"
1793# Framework Path Aliases
1794
1795
1796
1797
1798
1799[Link](@/pages/about.md)
1800
1801This is a [real missing link](missing.md) that should be flagged.
1802"#;
1803
1804 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1805
1806 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 let result = rule.check(&ctx).unwrap();
1808
1809 assert_eq!(
1811 result.len(),
1812 1,
1813 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1814 );
1815 assert!(
1816 result[0].message.contains("missing.md"),
1817 "Warning should be for missing.md"
1818 );
1819 }
1820
1821 #[test]
1822 fn test_url_decode_security_path_traversal() {
1823 let temp_dir = tempdir().unwrap();
1826 let base_path = temp_dir.path();
1827
1828 let file_in_base = base_path.join("safe.md");
1830 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1831
1832 let content = r#"
1837[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1838[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1839[Safe link](safe.md)
1840"#;
1841
1842 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1843
1844 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 let result = rule.check(&ctx).unwrap();
1846
1847 assert_eq!(
1850 result.len(),
1851 2,
1852 "Should have warnings for traversal attempts. Got: {result:?}"
1853 );
1854 }
1855
1856 #[test]
1857 fn test_url_encoded_utf8_filenames() {
1858 let temp_dir = tempdir().unwrap();
1860 let base_path = temp_dir.path();
1861
1862 let cafe_file = base_path.join("café.md");
1864 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1865
1866 let content = r#"
1867[Café link](caf%C3%A9.md)
1868[Missing unicode](r%C3%A9sum%C3%A9.md)
1869"#;
1870
1871 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1872
1873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let result = rule.check(&ctx).unwrap();
1875
1876 assert_eq!(
1878 result.len(),
1879 1,
1880 "Should only warn about missing résumé.md. Got: {result:?}"
1881 );
1882 assert!(
1883 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1884 "Warning should mention the URL-encoded filename"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_url_encoded_emoji_filenames() {
1890 let temp_dir = tempdir().unwrap();
1893 let base_path = temp_dir.path();
1894
1895 let emoji_dir = base_path.join("👤 Personal");
1897 std::fs::create_dir(&emoji_dir).unwrap();
1898
1899 let file_path = emoji_dir.join("TV Shows.md");
1901 File::create(&file_path)
1902 .unwrap()
1903 .write_all(b"# TV Shows\n\nContent here.")
1904 .unwrap();
1905
1906 let content = r#"
1909# Test Document
1910
1911[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1912[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1913"#;
1914
1915 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1916
1917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let result = rule.check(&ctx).unwrap();
1919
1920 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1922 assert!(
1923 result[0].message.contains("Missing.md"),
1924 "Warning should be for Missing.md, got: {}",
1925 result[0].message
1926 );
1927 }
1928
1929 #[test]
1930 fn test_no_warnings_without_base_path() {
1931 let rule = MD057ExistingRelativeLinks::new();
1932 let content = "[Link](missing.md)";
1933
1934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935 let result = rule.check(&ctx).unwrap();
1936 assert!(result.is_empty(), "Should have no warnings without base path");
1937 }
1938
1939 #[test]
1940 fn test_existing_and_missing_links() {
1941 let temp_dir = tempdir().unwrap();
1943 let base_path = temp_dir.path();
1944
1945 let exists_path = base_path.join("exists.md");
1947 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1948
1949 assert!(exists_path.exists(), "exists.md should exist for this test");
1951
1952 let content = r#"
1954# Test Document
1955
1956[Valid Link](exists.md)
1957[Invalid Link](missing.md)
1958[External Link](https://example.com)
1959[Media Link](image.jpg)
1960 "#;
1961
1962 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1964
1965 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967 let result = rule.check(&ctx).unwrap();
1968
1969 assert_eq!(result.len(), 2);
1971 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1972 assert!(messages.iter().any(|m| m.contains("missing.md")));
1973 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1974 }
1975
1976 #[test]
1977 fn test_angle_bracket_links() {
1978 let temp_dir = tempdir().unwrap();
1980 let base_path = temp_dir.path();
1981
1982 let exists_path = base_path.join("exists.md");
1984 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1985
1986 let content = r#"
1988# Test Document
1989
1990[Valid Link](<exists.md>)
1991[Invalid Link](<missing.md>)
1992[External Link](<https://example.com>)
1993 "#;
1994
1995 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1997
1998 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 let result = rule.check(&ctx).unwrap();
2000
2001 assert_eq!(result.len(), 1, "Should have exactly one warning");
2003 assert!(
2004 result[0].message.contains("missing.md"),
2005 "Warning should mention missing.md"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_angle_bracket_links_with_parens() {
2011 let temp_dir = tempdir().unwrap();
2013 let base_path = temp_dir.path();
2014
2015 let app_dir = base_path.join("app");
2017 std::fs::create_dir(&app_dir).unwrap();
2018 let upload_dir = app_dir.join("(upload)");
2019 std::fs::create_dir(&upload_dir).unwrap();
2020 let page_file = upload_dir.join("page.tsx");
2021 File::create(&page_file)
2022 .unwrap()
2023 .write_all(b"export default function Page() {}")
2024 .unwrap();
2025
2026 let content = r#"
2028# Test Document with Paths Containing Parens
2029
2030[Upload Page](<app/(upload)/page.tsx>)
2031[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2032[Missing](<app/(missing)/file.md>)
2033"#;
2034
2035 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2036
2037 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let result = rule.check(&ctx).unwrap();
2039
2040 assert_eq!(
2042 result.len(),
2043 1,
2044 "Should have exactly one warning for missing file. Got: {result:?}"
2045 );
2046 assert!(
2047 result[0].message.contains("app/(missing)/file.md"),
2048 "Warning should mention app/(missing)/file.md"
2049 );
2050 }
2051
2052 #[test]
2053 fn test_all_file_types_checked() {
2054 let temp_dir = tempdir().unwrap();
2056 let base_path = temp_dir.path();
2057
2058 let content = r#"
2060[Image Link](image.jpg)
2061[Video Link](video.mp4)
2062[Markdown Link](document.md)
2063[PDF Link](file.pdf)
2064"#;
2065
2066 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2067
2068 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let result = rule.check(&ctx).unwrap();
2070
2071 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2073 }
2074
2075 #[test]
2076 fn test_code_span_detection() {
2077 let rule = MD057ExistingRelativeLinks::new();
2078
2079 let temp_dir = tempdir().unwrap();
2081 let base_path = temp_dir.path();
2082
2083 let rule = rule.with_path(base_path);
2084
2085 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2087
2088 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2089 let result = rule.check(&ctx).unwrap();
2090
2091 assert_eq!(result.len(), 1, "Should only flag the real link");
2093 assert!(result[0].message.contains("nonexistent.md"));
2094 }
2095
2096 #[test]
2097 fn test_inline_code_spans() {
2098 let temp_dir = tempdir().unwrap();
2100 let base_path = temp_dir.path();
2101
2102 let content = r#"
2104# Test Document
2105
2106This is a normal link: [Link](missing.md)
2107
2108This is a code span with a link: `[Link](another-missing.md)`
2109
2110Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2111
2112 "#;
2113
2114 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2116
2117 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2119 let result = rule.check(&ctx).unwrap();
2120
2121 assert_eq!(result.len(), 1, "Should have exactly one warning");
2123 assert!(
2124 result[0].message.contains("missing.md"),
2125 "Warning should be for missing.md"
2126 );
2127 assert!(
2128 !result.iter().any(|w| w.message.contains("another-missing.md")),
2129 "Should not warn about link in code span"
2130 );
2131 assert!(
2132 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2133 "Should not warn about link in inline code"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_extensionless_link_resolution() {
2139 let temp_dir = tempdir().unwrap();
2141 let base_path = temp_dir.path();
2142
2143 let page_path = base_path.join("page.md");
2145 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2146
2147 let content = r#"
2149# Test Document
2150
2151[Link without extension](page)
2152[Link with extension](page.md)
2153[Missing link](nonexistent)
2154"#;
2155
2156 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2157
2158 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2159 let result = rule.check(&ctx).unwrap();
2160
2161 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2164 assert!(
2165 result[0].message.contains("nonexistent"),
2166 "Warning should be for 'nonexistent' not 'page'"
2167 );
2168 }
2169
2170 #[test]
2172 fn test_cross_file_scope() {
2173 let rule = MD057ExistingRelativeLinks::new();
2174 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2175 }
2176
2177 #[test]
2178 fn test_contribute_to_index_extracts_markdown_links() {
2179 let rule = MD057ExistingRelativeLinks::new();
2180 let content = r#"
2181# Document
2182
2183[Link to docs](./docs/guide.md)
2184[Link with fragment](./other.md#section)
2185[External link](https://example.com)
2186[Image link](image.png)
2187[Media file](video.mp4)
2188"#;
2189
2190 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2191 let mut index = FileIndex::new();
2192 rule.contribute_to_index(&ctx, &mut index);
2193
2194 assert_eq!(index.cross_file_links.len(), 2);
2196
2197 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2199 assert_eq!(index.cross_file_links[0].fragment, "");
2200
2201 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2203 assert_eq!(index.cross_file_links[1].fragment, "section");
2204 }
2205
2206 #[test]
2207 fn test_contribute_to_index_skips_external_and_anchors() {
2208 let rule = MD057ExistingRelativeLinks::new();
2209 let content = r#"
2210# Document
2211
2212[External](https://example.com)
2213[Another external](http://example.org)
2214[Fragment only](#section)
2215[FTP link](ftp://files.example.com)
2216[Mail link](mailto:test@example.com)
2217[WWW link](www.example.com)
2218"#;
2219
2220 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2221 let mut index = FileIndex::new();
2222 rule.contribute_to_index(&ctx, &mut index);
2223
2224 assert_eq!(index.cross_file_links.len(), 0);
2226 }
2227
2228 #[test]
2229 fn test_cross_file_check_valid_link() {
2230 use crate::workspace_index::WorkspaceIndex;
2231
2232 let rule = MD057ExistingRelativeLinks::new();
2233
2234 let mut workspace_index = WorkspaceIndex::new();
2236 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2237
2238 let mut file_index = FileIndex::new();
2240 file_index.add_cross_file_link(CrossFileLinkIndex {
2241 target_path: "guide.md".to_string(),
2242 fragment: "".to_string(),
2243 line: 5,
2244 column: 1,
2245 });
2246
2247 let warnings = rule
2249 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2250 .unwrap();
2251
2252 assert!(warnings.is_empty());
2254 }
2255
2256 #[test]
2257 fn test_cross_file_check_missing_link() {
2258 use crate::workspace_index::WorkspaceIndex;
2261
2262 let rule = MD057ExistingRelativeLinks::new();
2263 let workspace_index = WorkspaceIndex::new();
2264
2265 let mut file_index = FileIndex::new();
2266 file_index.add_cross_file_link(CrossFileLinkIndex {
2267 target_path: "missing.md".to_string(),
2268 fragment: "".to_string(),
2269 line: 5,
2270 column: 1,
2271 });
2272
2273 let warnings = rule
2274 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2275 .unwrap();
2276
2277 assert!(
2279 warnings.is_empty(),
2280 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2281 );
2282 }
2283
2284 #[test]
2285 fn test_cross_file_check_parent_path() {
2286 use crate::workspace_index::WorkspaceIndex;
2287
2288 let rule = MD057ExistingRelativeLinks::new();
2289
2290 let mut workspace_index = WorkspaceIndex::new();
2292 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2293
2294 let mut file_index = FileIndex::new();
2296 file_index.add_cross_file_link(CrossFileLinkIndex {
2297 target_path: "../readme.md".to_string(),
2298 fragment: "".to_string(),
2299 line: 5,
2300 column: 1,
2301 });
2302
2303 let warnings = rule
2305 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2306 .unwrap();
2307
2308 assert!(warnings.is_empty());
2310 }
2311
2312 #[test]
2313 fn test_cross_file_check_html_link_with_md_source() {
2314 use crate::workspace_index::WorkspaceIndex;
2317
2318 let rule = MD057ExistingRelativeLinks::new();
2319
2320 let mut workspace_index = WorkspaceIndex::new();
2322 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2323
2324 let mut file_index = FileIndex::new();
2326 file_index.add_cross_file_link(CrossFileLinkIndex {
2327 target_path: "guide.html".to_string(),
2328 fragment: "section".to_string(),
2329 line: 10,
2330 column: 5,
2331 });
2332
2333 let warnings = rule
2335 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2336 .unwrap();
2337
2338 assert!(
2340 warnings.is_empty(),
2341 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2342 );
2343 }
2344
2345 #[test]
2346 fn test_cross_file_check_html_link_without_source() {
2347 use crate::workspace_index::WorkspaceIndex;
2351
2352 let rule = MD057ExistingRelativeLinks::new();
2353 let workspace_index = WorkspaceIndex::new();
2354
2355 let mut file_index = FileIndex::new();
2356 file_index.add_cross_file_link(CrossFileLinkIndex {
2357 target_path: "missing.html".to_string(),
2358 fragment: "".to_string(),
2359 line: 10,
2360 column: 5,
2361 });
2362
2363 let warnings = rule
2364 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2365 .unwrap();
2366
2367 assert!(
2369 warnings.is_empty(),
2370 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2371 );
2372 }
2373
2374 #[test]
2375 fn test_normalize_path_function() {
2376 assert_eq!(
2378 normalize_path(Path::new("docs/guide.md")),
2379 PathBuf::from("docs/guide.md")
2380 );
2381
2382 assert_eq!(
2384 normalize_path(Path::new("./docs/guide.md")),
2385 PathBuf::from("docs/guide.md")
2386 );
2387
2388 assert_eq!(
2390 normalize_path(Path::new("docs/sub/../guide.md")),
2391 PathBuf::from("docs/guide.md")
2392 );
2393
2394 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2396 }
2397
2398 #[test]
2399 fn test_html_link_with_md_source() {
2400 let temp_dir = tempdir().unwrap();
2402 let base_path = temp_dir.path();
2403
2404 let md_file = base_path.join("guide.md");
2406 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2407
2408 let content = r#"
2409[Read the guide](guide.html)
2410[Also here](getting-started.html)
2411"#;
2412
2413 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2414 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415 let result = rule.check(&ctx).unwrap();
2416
2417 assert_eq!(
2419 result.len(),
2420 1,
2421 "Should only warn about missing source. Got: {result:?}"
2422 );
2423 assert!(result[0].message.contains("getting-started.html"));
2424 }
2425
2426 #[test]
2427 fn test_htm_link_with_md_source() {
2428 let temp_dir = tempdir().unwrap();
2430 let base_path = temp_dir.path();
2431
2432 let md_file = base_path.join("page.md");
2433 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2434
2435 let content = "[Page](page.htm)";
2436
2437 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2438 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2439 let result = rule.check(&ctx).unwrap();
2440
2441 assert!(
2442 result.is_empty(),
2443 "Should not warn when .md source exists for .htm link"
2444 );
2445 }
2446
2447 #[test]
2448 fn test_html_link_finds_various_markdown_extensions() {
2449 let temp_dir = tempdir().unwrap();
2451 let base_path = temp_dir.path();
2452
2453 File::create(base_path.join("doc.md")).unwrap();
2454 File::create(base_path.join("tutorial.mdx")).unwrap();
2455 File::create(base_path.join("guide.markdown")).unwrap();
2456
2457 let content = r#"
2458[Doc](doc.html)
2459[Tutorial](tutorial.html)
2460[Guide](guide.html)
2461"#;
2462
2463 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2464 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2465 let result = rule.check(&ctx).unwrap();
2466
2467 assert!(
2468 result.is_empty(),
2469 "Should find all markdown variants as source files. Got: {result:?}"
2470 );
2471 }
2472
2473 #[test]
2474 fn test_html_link_in_subdirectory() {
2475 let temp_dir = tempdir().unwrap();
2477 let base_path = temp_dir.path();
2478
2479 let docs_dir = base_path.join("docs");
2480 std::fs::create_dir(&docs_dir).unwrap();
2481 File::create(docs_dir.join("guide.md"))
2482 .unwrap()
2483 .write_all(b"# Guide")
2484 .unwrap();
2485
2486 let content = "[Guide](docs/guide.html)";
2487
2488 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2489 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2490 let result = rule.check(&ctx).unwrap();
2491
2492 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2493 }
2494
2495 #[test]
2496 fn test_absolute_path_skipped_in_check() {
2497 let temp_dir = tempdir().unwrap();
2500 let base_path = temp_dir.path();
2501
2502 let content = r#"
2503# Test Document
2504
2505[Go Runtime](/pkg/runtime)
2506[Go Runtime with Fragment](/pkg/runtime#section)
2507[API Docs](/api/v1/users)
2508[Blog Post](/blog/2024/release.html)
2509[React Hook](/react/hooks/use-state.html)
2510"#;
2511
2512 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2513 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2514 let result = rule.check(&ctx).unwrap();
2515
2516 assert!(
2518 result.is_empty(),
2519 "Absolute paths should be skipped. Got warnings: {result:?}"
2520 );
2521 }
2522
2523 #[test]
2524 fn test_absolute_path_skipped_in_cross_file_check() {
2525 use crate::workspace_index::WorkspaceIndex;
2527
2528 let rule = MD057ExistingRelativeLinks::new();
2529
2530 let workspace_index = WorkspaceIndex::new();
2532
2533 let mut file_index = FileIndex::new();
2535 file_index.add_cross_file_link(CrossFileLinkIndex {
2536 target_path: "/pkg/runtime.md".to_string(),
2537 fragment: "".to_string(),
2538 line: 5,
2539 column: 1,
2540 });
2541 file_index.add_cross_file_link(CrossFileLinkIndex {
2542 target_path: "/api/v1/users.md".to_string(),
2543 fragment: "section".to_string(),
2544 line: 10,
2545 column: 1,
2546 });
2547
2548 let warnings = rule
2550 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2551 .unwrap();
2552
2553 assert!(
2555 warnings.is_empty(),
2556 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2557 );
2558 }
2559
2560 #[test]
2561 fn test_protocol_relative_url_not_skipped() {
2562 let temp_dir = tempdir().unwrap();
2565 let base_path = temp_dir.path();
2566
2567 let content = r#"
2568# Test Document
2569
2570[External](//example.com/page)
2571[Another](//cdn.example.com/asset.js)
2572"#;
2573
2574 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2575 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576 let result = rule.check(&ctx).unwrap();
2577
2578 assert!(
2580 result.is_empty(),
2581 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2582 );
2583 }
2584
2585 #[test]
2586 fn test_email_addresses_skipped() {
2587 let temp_dir = tempdir().unwrap();
2590 let base_path = temp_dir.path();
2591
2592 let content = r#"
2593# Test Document
2594
2595[Contact](user@example.com)
2596[Steering](steering@kubernetes.io)
2597[Support](john.doe+filter@company.co.uk)
2598[User](user_name@sub.domain.com)
2599"#;
2600
2601 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2602 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2603 let result = rule.check(&ctx).unwrap();
2604
2605 assert!(
2607 result.is_empty(),
2608 "Email addresses should be skipped. Got warnings: {result:?}"
2609 );
2610 }
2611
2612 #[test]
2613 fn test_email_addresses_vs_file_paths() {
2614 let temp_dir = tempdir().unwrap();
2617 let base_path = temp_dir.path();
2618
2619 let content = r#"
2620# Test Document
2621
2622[Email](user@example.com) <!-- Should be skipped (email) -->
2623[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2624[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2625"#;
2626
2627 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2628 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2629 let result = rule.check(&ctx).unwrap();
2630
2631 assert!(
2633 result.is_empty(),
2634 "All email addresses should be skipped. Got: {result:?}"
2635 );
2636 }
2637
2638 #[test]
2639 fn test_diagnostic_position_accuracy() {
2640 let temp_dir = tempdir().unwrap();
2642 let base_path = temp_dir.path();
2643
2644 let content = "prefix [text](missing.md) suffix";
2647 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2651 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2652 let result = rule.check(&ctx).unwrap();
2653
2654 assert_eq!(result.len(), 1, "Should have exactly one warning");
2655 assert_eq!(result[0].line, 1, "Should be on line 1");
2656 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2657 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2658 }
2659
2660 #[test]
2661 fn test_diagnostic_position_non_ascii_link() {
2662 let temp_dir = tempdir().unwrap();
2665 let base_path = temp_dir.path();
2666
2667 let content = "你好你好[你好](not-exist.md) bar";
2671
2672 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2673 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2674 let result = rule.check(&ctx).unwrap();
2675
2676 assert_eq!(result.len(), 1, "Should have exactly one warning");
2677 assert_eq!(result[0].line, 1, "Should be on line 1");
2678 assert_eq!(
2679 result[0].column, 10,
2680 "Column must be a character offset, not a byte offset"
2681 );
2682 assert_eq!(result[0].end_column, 22, "End column must be character-based");
2683 }
2684
2685 #[test]
2686 fn test_diagnostic_position_angle_brackets() {
2687 let temp_dir = tempdir().unwrap();
2689 let base_path = temp_dir.path();
2690
2691 let content = "[link](<missing.md>)";
2694 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2697 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2698 let result = rule.check(&ctx).unwrap();
2699
2700 assert_eq!(result.len(), 1, "Should have exactly one warning");
2701 assert_eq!(result[0].line, 1, "Should be on line 1");
2702 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2703 }
2704
2705 #[test]
2706 fn test_diagnostic_position_multiline() {
2707 let temp_dir = tempdir().unwrap();
2709 let base_path = temp_dir.path();
2710
2711 let content = r#"# Title
2712Some text on line 2
2713[link on line 3](missing1.md)
2714More text
2715[link on line 5](missing2.md)"#;
2716
2717 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2718 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2719 let result = rule.check(&ctx).unwrap();
2720
2721 assert_eq!(result.len(), 2, "Should have two warnings");
2722
2723 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2725 assert!(result[0].message.contains("missing1.md"));
2726
2727 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2729 assert!(result[1].message.contains("missing2.md"));
2730 }
2731
2732 #[test]
2733 fn test_diagnostic_position_with_spaces() {
2734 let temp_dir = tempdir().unwrap();
2736 let base_path = temp_dir.path();
2737
2738 let content = "[link]( missing.md )";
2739 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2744 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2745 let result = rule.check(&ctx).unwrap();
2746
2747 assert_eq!(result.len(), 1, "Should have exactly one warning");
2748 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2750 }
2751
2752 #[test]
2753 fn test_diagnostic_position_image() {
2754 let temp_dir = tempdir().unwrap();
2756 let base_path = temp_dir.path();
2757
2758 let content = "";
2759
2760 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2761 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2762 let result = rule.check(&ctx).unwrap();
2763
2764 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2765 assert_eq!(result[0].line, 1);
2766 assert!(result[0].column > 0, "Should have valid column position");
2768 assert!(result[0].message.contains("missing.jpg"));
2769 }
2770
2771 #[test]
2772 fn test_diagnostic_position_non_ascii_image() {
2773 let temp_dir = tempdir().unwrap();
2775 let base_path = temp_dir.path();
2776
2777 let content = "你好你好";
2780
2781 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2782 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2783 let result = rule.check(&ctx).unwrap();
2784
2785 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2786 assert_eq!(result[0].line, 1, "Should be on line 1");
2787 assert_eq!(
2788 result[0].column, 5,
2789 "Column must be a character offset, not a byte offset"
2790 );
2791 assert!(result[0].message.contains("not-exist.png"));
2792 }
2793
2794 #[test]
2795 fn test_diagnostic_position_non_ascii_reference_def() {
2796 let temp_dir = tempdir().unwrap();
2800 let base_path = temp_dir.path();
2801
2802 let content = "[你好]: not-exist.md";
2805
2806 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2807 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808 let result = rule.check(&ctx).unwrap();
2809
2810 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2811 assert_eq!(result[0].line, 1, "Should be on line 1");
2812 assert_eq!(
2813 result[0].column, 7,
2814 "Column must be a character offset, not a byte offset"
2815 );
2816 assert_eq!(result[0].end_column, 19, "End column must be character-based");
2817 }
2818
2819 #[test]
2820 fn test_wikilinks_skipped() {
2821 let temp_dir = tempdir().unwrap();
2824 let base_path = temp_dir.path();
2825
2826 let content = r#"# Test Document
2827
2828[[Microsoft#Windows OS]]
2829[[SomePage]]
2830[[Page With Spaces]]
2831[[path/to/page#section]]
2832[[page|Display Text]]
2833
2834This is a [real missing link](missing.md) that should be flagged.
2835"#;
2836
2837 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2838 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2839 let result = rule.check(&ctx).unwrap();
2840
2841 assert_eq!(
2843 result.len(),
2844 1,
2845 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2846 );
2847 assert!(
2848 result[0].message.contains("missing.md"),
2849 "Warning should be for missing.md, not wikilinks"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_wikilinks_not_added_to_index() {
2855 let temp_dir = tempdir().unwrap();
2857 let base_path = temp_dir.path();
2858
2859 let content = r#"# Test Document
2860
2861[[Microsoft#Windows OS]]
2862[[SomePage#section]]
2863[Regular Link](other.md)
2864"#;
2865
2866 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2867 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2868
2869 let mut file_index = FileIndex::new();
2870 rule.contribute_to_index(&ctx, &mut file_index);
2871
2872 let cross_file_links = &file_index.cross_file_links;
2875 assert_eq!(
2876 cross_file_links.len(),
2877 1,
2878 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2879 );
2880 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2881 }
2882
2883 #[test]
2884 fn test_reference_definition_missing_file() {
2885 let temp_dir = tempdir().unwrap();
2887 let base_path = temp_dir.path();
2888
2889 let content = r#"# Test Document
2890
2891[test]: ./missing.md
2892[example]: ./nonexistent.html
2893
2894Use [test] and [example] here.
2895"#;
2896
2897 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2898 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899 let result = rule.check(&ctx).unwrap();
2900
2901 assert_eq!(
2903 result.len(),
2904 2,
2905 "Should have warnings for missing reference definition targets. Got: {result:?}"
2906 );
2907 assert!(
2908 result.iter().any(|w| w.message.contains("missing.md")),
2909 "Should warn about missing.md"
2910 );
2911 assert!(
2912 result.iter().any(|w| w.message.contains("nonexistent.html")),
2913 "Should warn about nonexistent.html"
2914 );
2915 }
2916
2917 #[test]
2918 fn test_reference_definition_existing_file() {
2919 let temp_dir = tempdir().unwrap();
2921 let base_path = temp_dir.path();
2922
2923 let exists_path = base_path.join("exists.md");
2925 File::create(&exists_path)
2926 .unwrap()
2927 .write_all(b"# Existing file")
2928 .unwrap();
2929
2930 let content = r#"# Test Document
2931
2932[test]: ./exists.md
2933
2934Use [test] here.
2935"#;
2936
2937 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2938 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2939 let result = rule.check(&ctx).unwrap();
2940
2941 assert!(
2943 result.is_empty(),
2944 "Should not warn about existing file. Got: {result:?}"
2945 );
2946 }
2947
2948 #[test]
2949 fn test_reference_definition_external_url_skipped() {
2950 let temp_dir = tempdir().unwrap();
2952 let base_path = temp_dir.path();
2953
2954 let content = r#"# Test Document
2955
2956[google]: https://google.com
2957[example]: http://example.org
2958[mail]: mailto:test@example.com
2959[ftp]: ftp://files.example.com
2960[local]: ./missing.md
2961
2962Use [google], [example], [mail], [ftp], [local] here.
2963"#;
2964
2965 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2966 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2967 let result = rule.check(&ctx).unwrap();
2968
2969 assert_eq!(
2971 result.len(),
2972 1,
2973 "Should only warn about local missing file. Got: {result:?}"
2974 );
2975 assert!(
2976 result[0].message.contains("missing.md"),
2977 "Warning should be for missing.md"
2978 );
2979 }
2980
2981 #[test]
2982 fn test_reference_definition_fragment_only_skipped() {
2983 let temp_dir = tempdir().unwrap();
2985 let base_path = temp_dir.path();
2986
2987 let content = r#"# Test Document
2988
2989[section]: #my-section
2990
2991Use [section] here.
2992"#;
2993
2994 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2995 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996 let result = rule.check(&ctx).unwrap();
2997
2998 assert!(
3000 result.is_empty(),
3001 "Should not warn about fragment-only reference. Got: {result:?}"
3002 );
3003 }
3004
3005 #[test]
3006 fn test_reference_definition_column_position() {
3007 let temp_dir = tempdir().unwrap();
3009 let base_path = temp_dir.path();
3010
3011 let content = "[ref]: ./missing.md";
3014 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3018 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3019 let result = rule.check(&ctx).unwrap();
3020
3021 assert_eq!(result.len(), 1, "Should have exactly one warning");
3022 assert_eq!(result[0].line, 1, "Should be on line 1");
3023 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3024 }
3025
3026 #[test]
3027 fn test_reference_definition_html_with_md_source() {
3028 let temp_dir = tempdir().unwrap();
3030 let base_path = temp_dir.path();
3031
3032 let md_file = base_path.join("guide.md");
3034 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3035
3036 let content = r#"# Test Document
3037
3038[guide]: ./guide.html
3039[missing]: ./missing.html
3040
3041Use [guide] and [missing] here.
3042"#;
3043
3044 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3045 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3046 let result = rule.check(&ctx).unwrap();
3047
3048 assert_eq!(
3050 result.len(),
3051 1,
3052 "Should only warn about missing source. Got: {result:?}"
3053 );
3054 assert!(result[0].message.contains("missing.html"));
3055 }
3056
3057 #[test]
3058 fn test_reference_definition_url_encoded() {
3059 let temp_dir = tempdir().unwrap();
3061 let base_path = temp_dir.path();
3062
3063 let file_with_spaces = base_path.join("file with spaces.md");
3065 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3066
3067 let content = r#"# Test Document
3068
3069[spaces]: ./file%20with%20spaces.md
3070[missing]: ./missing%20file.md
3071
3072Use [spaces] and [missing] here.
3073"#;
3074
3075 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3076 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3077 let result = rule.check(&ctx).unwrap();
3078
3079 assert_eq!(
3081 result.len(),
3082 1,
3083 "Should only warn about missing URL-encoded file. Got: {result:?}"
3084 );
3085 assert!(result[0].message.contains("missing%20file.md"));
3086 }
3087
3088 #[test]
3089 fn test_inline_and_reference_both_checked() {
3090 let temp_dir = tempdir().unwrap();
3092 let base_path = temp_dir.path();
3093
3094 let content = r#"# Test Document
3095
3096[inline link](./inline-missing.md)
3097[ref]: ./ref-missing.md
3098
3099Use [ref] here.
3100"#;
3101
3102 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3103 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3104 let result = rule.check(&ctx).unwrap();
3105
3106 assert_eq!(
3108 result.len(),
3109 2,
3110 "Should warn about both inline and reference links. Got: {result:?}"
3111 );
3112 assert!(
3113 result.iter().any(|w| w.message.contains("inline-missing.md")),
3114 "Should warn about inline-missing.md"
3115 );
3116 assert!(
3117 result.iter().any(|w| w.message.contains("ref-missing.md")),
3118 "Should warn about ref-missing.md"
3119 );
3120 }
3121
3122 #[test]
3123 fn test_footnote_definitions_not_flagged() {
3124 let rule = MD057ExistingRelativeLinks::default();
3127
3128 let content = r#"# Title
3129
3130A footnote[^1].
3131
3132[^1]: [link](https://www.google.com).
3133"#;
3134
3135 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3136 let result = rule.check(&ctx).unwrap();
3137
3138 assert!(
3139 result.is_empty(),
3140 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3141 );
3142 }
3143
3144 #[test]
3145 fn test_footnote_with_relative_link_inside() {
3146 let rule = MD057ExistingRelativeLinks::default();
3149
3150 let content = r#"# Title
3151
3152See the footnote[^1].
3153
3154[^1]: Check out [this file](./existing.md) for more info.
3155[^2]: Also see [missing](./does-not-exist.md).
3156"#;
3157
3158 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3159 let result = rule.check(&ctx).unwrap();
3160
3161 for warning in &result {
3166 assert!(
3167 !warning.message.contains("[this file]"),
3168 "Footnote content should not be treated as URL: {warning:?}"
3169 );
3170 assert!(
3171 !warning.message.contains("[missing]"),
3172 "Footnote content should not be treated as URL: {warning:?}"
3173 );
3174 }
3175 }
3176
3177 #[test]
3178 fn test_mixed_footnotes_and_reference_definitions() {
3179 let temp_dir = tempdir().unwrap();
3181 let base_path = temp_dir.path();
3182
3183 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3184
3185 let content = r#"# Title
3186
3187A footnote[^1] and a [ref link][myref].
3188
3189[^1]: This is a footnote with [link](https://example.com).
3190
3191[myref]: ./missing-file.md "This should be checked"
3192"#;
3193
3194 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3195 let result = rule.check(&ctx).unwrap();
3196
3197 assert_eq!(
3199 result.len(),
3200 1,
3201 "Should only warn about the regular reference definition. Got: {result:?}"
3202 );
3203 assert!(
3204 result[0].message.contains("missing-file.md"),
3205 "Should warn about missing-file.md in reference definition"
3206 );
3207 }
3208
3209 #[test]
3210 fn test_absolute_links_ignore_by_default() {
3211 let temp_dir = tempdir().unwrap();
3213 let base_path = temp_dir.path();
3214
3215 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3216
3217 let content = r#"# Links
3218
3219[API docs](/api/v1/users)
3220[Blog post](/blog/2024/release.html)
3221
3222
3223[ref]: /docs/reference.md
3224"#;
3225
3226 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3227 let result = rule.check(&ctx).unwrap();
3228
3229 assert!(
3231 result.is_empty(),
3232 "Absolute links should be ignored by default. Got: {result:?}"
3233 );
3234 }
3235
3236 #[test]
3237 fn test_absolute_links_warn_config() {
3238 let temp_dir = tempdir().unwrap();
3240 let base_path = temp_dir.path();
3241
3242 let config = MD057Config {
3243 absolute_links: AbsoluteLinksOption::Warn,
3244 ..Default::default()
3245 };
3246 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3247
3248 let content = r#"# Links
3249
3250[API docs](/api/v1/users)
3251[Blog post](/blog/2024/release.html)
3252"#;
3253
3254 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3255 let result = rule.check(&ctx).unwrap();
3256
3257 assert_eq!(
3259 result.len(),
3260 2,
3261 "Should warn about both absolute links. Got: {result:?}"
3262 );
3263 assert!(
3264 result[0].message.contains("cannot be validated locally"),
3265 "Warning should explain why: {}",
3266 result[0].message
3267 );
3268 assert!(
3269 result[0].message.contains("/api/v1/users"),
3270 "Warning should include the link path"
3271 );
3272 }
3273
3274 #[test]
3275 fn test_absolute_links_warn_images() {
3276 let temp_dir = tempdir().unwrap();
3278 let base_path = temp_dir.path();
3279
3280 let config = MD057Config {
3281 absolute_links: AbsoluteLinksOption::Warn,
3282 ..Default::default()
3283 };
3284 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3285
3286 let content = r#"# Images
3287
3288
3289"#;
3290
3291 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3292 let result = rule.check(&ctx).unwrap();
3293
3294 assert_eq!(
3295 result.len(),
3296 1,
3297 "Should warn about absolute image path. Got: {result:?}"
3298 );
3299 assert!(
3300 result[0].message.contains("/assets/logo.png"),
3301 "Warning should include the image path"
3302 );
3303 }
3304
3305 #[test]
3306 fn test_absolute_links_warn_reference_definitions() {
3307 let temp_dir = tempdir().unwrap();
3309 let base_path = temp_dir.path();
3310
3311 let config = MD057Config {
3312 absolute_links: AbsoluteLinksOption::Warn,
3313 ..Default::default()
3314 };
3315 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3316
3317 let content = r#"# Reference
3318
3319See the [docs][ref].
3320
3321[ref]: /docs/reference.md
3322"#;
3323
3324 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3325 let result = rule.check(&ctx).unwrap();
3326
3327 assert_eq!(
3328 result.len(),
3329 1,
3330 "Should warn about absolute reference definition. Got: {result:?}"
3331 );
3332 assert!(
3333 result[0].message.contains("/docs/reference.md"),
3334 "Warning should include the reference path"
3335 );
3336 }
3337
3338 #[test]
3339 fn test_search_paths_inline_link() {
3340 let temp_dir = tempdir().unwrap();
3341 let base_path = temp_dir.path();
3342
3343 let assets_dir = base_path.join("assets");
3345 std::fs::create_dir_all(&assets_dir).unwrap();
3346 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3347
3348 let config = MD057Config {
3349 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3350 ..Default::default()
3351 };
3352 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3353
3354 let content = "# Test\n\n[Photo](photo.png)\n";
3355 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3356 let result = rule.check(&ctx).unwrap();
3357
3358 assert!(
3359 result.is_empty(),
3360 "Should find photo.png via search-paths. Got: {result:?}"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_search_paths_image() {
3366 let temp_dir = tempdir().unwrap();
3367 let base_path = temp_dir.path();
3368
3369 let assets_dir = base_path.join("attachments");
3370 std::fs::create_dir_all(&assets_dir).unwrap();
3371 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3372
3373 let config = MD057Config {
3374 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3375 ..Default::default()
3376 };
3377 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3378
3379 let content = "# Test\n\n\n";
3380 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3381 let result = rule.check(&ctx).unwrap();
3382
3383 assert!(
3384 result.is_empty(),
3385 "Should find diagram.svg via search-paths. Got: {result:?}"
3386 );
3387 }
3388
3389 #[test]
3390 fn test_search_paths_reference_definition() {
3391 let temp_dir = tempdir().unwrap();
3392 let base_path = temp_dir.path();
3393
3394 let assets_dir = base_path.join("images");
3395 std::fs::create_dir_all(&assets_dir).unwrap();
3396 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3397
3398 let config = MD057Config {
3399 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3400 ..Default::default()
3401 };
3402 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3403
3404 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3405 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3406 let result = rule.check(&ctx).unwrap();
3407
3408 assert!(
3409 result.is_empty(),
3410 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3411 );
3412 }
3413
3414 #[test]
3415 fn test_search_paths_still_warns_when_truly_missing() {
3416 let temp_dir = tempdir().unwrap();
3417 let base_path = temp_dir.path();
3418
3419 let assets_dir = base_path.join("assets");
3420 std::fs::create_dir_all(&assets_dir).unwrap();
3421
3422 let config = MD057Config {
3423 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3424 ..Default::default()
3425 };
3426 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3427
3428 let content = "# Test\n\n\n";
3429 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3430 let result = rule.check(&ctx).unwrap();
3431
3432 assert_eq!(
3433 result.len(),
3434 1,
3435 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3436 );
3437 }
3438
3439 #[test]
3440 fn test_search_paths_nonexistent_directory() {
3441 let temp_dir = tempdir().unwrap();
3442 let base_path = temp_dir.path();
3443
3444 let config = MD057Config {
3445 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3446 ..Default::default()
3447 };
3448 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3449
3450 let content = "# Test\n\n\n";
3451 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3452 let result = rule.check(&ctx).unwrap();
3453
3454 assert_eq!(
3455 result.len(),
3456 1,
3457 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3458 );
3459 }
3460
3461 #[test]
3462 fn test_obsidian_attachment_folder_named() {
3463 let temp_dir = tempdir().unwrap();
3464 let vault = temp_dir.path().join("vault");
3465 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3466 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3467 std::fs::create_dir_all(vault.join("notes")).unwrap();
3468
3469 std::fs::write(
3470 vault.join(".obsidian/app.json"),
3471 r#"{"attachmentFolderPath": "Attachments"}"#,
3472 )
3473 .unwrap();
3474 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3475
3476 let notes_dir = vault.join("notes");
3477 let source_file = notes_dir.join("test.md");
3478 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3479
3480 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3481
3482 let content = "# Test\n\n\n";
3483 let ctx =
3484 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3485 let result = rule.check(&ctx).unwrap();
3486
3487 assert!(
3488 result.is_empty(),
3489 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3490 );
3491 }
3492
3493 #[test]
3494 fn test_obsidian_attachment_same_folder_as_file() {
3495 let temp_dir = tempdir().unwrap();
3496 let vault = temp_dir.path().join("vault-rf");
3497 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3498 std::fs::create_dir_all(vault.join("notes")).unwrap();
3499
3500 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3501
3502 let notes_dir = vault.join("notes");
3504 let source_file = notes_dir.join("test.md");
3505 std::fs::write(&source_file, "placeholder").unwrap();
3506 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3507
3508 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3509
3510 let content = "# Test\n\n\n";
3511 let ctx =
3512 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3513 let result = rule.check(&ctx).unwrap();
3514
3515 assert!(
3516 result.is_empty(),
3517 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3518 );
3519 }
3520
3521 #[test]
3522 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3523 let temp_dir = tempdir().unwrap();
3524 let vault = temp_dir.path().join("vault-nf");
3525 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3526 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3527 std::fs::create_dir_all(vault.join("notes")).unwrap();
3528
3529 std::fs::write(
3530 vault.join(".obsidian/app.json"),
3531 r#"{"attachmentFolderPath": "Attachments"}"#,
3532 )
3533 .unwrap();
3534 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3535
3536 let notes_dir = vault.join("notes");
3537 let source_file = notes_dir.join("test.md");
3538 std::fs::write(&source_file, "placeholder").unwrap();
3539
3540 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3541
3542 let content = "# Test\n\n\n";
3543 let ctx =
3545 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3546 let result = rule.check(&ctx).unwrap();
3547
3548 assert_eq!(
3549 result.len(),
3550 1,
3551 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3552 );
3553 }
3554
3555 #[test]
3556 fn test_search_paths_combined_with_obsidian() {
3557 let temp_dir = tempdir().unwrap();
3558 let vault = temp_dir.path().join("vault-combo");
3559 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3560 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3561 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3562 std::fs::create_dir_all(vault.join("notes")).unwrap();
3563
3564 std::fs::write(
3565 vault.join(".obsidian/app.json"),
3566 r#"{"attachmentFolderPath": "Attachments"}"#,
3567 )
3568 .unwrap();
3569 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3570 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3571
3572 let notes_dir = vault.join("notes");
3573 let source_file = notes_dir.join("test.md");
3574 std::fs::write(&source_file, "placeholder").unwrap();
3575
3576 let extra_assets_dir = vault.join("extra-assets");
3577 let config = MD057Config {
3578 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3579 ..Default::default()
3580 };
3581 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3582
3583 let content = "# Test\n\n\n\n\n";
3585 let ctx =
3586 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3587 let result = rule.check(&ctx).unwrap();
3588
3589 assert!(
3590 result.is_empty(),
3591 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3592 );
3593 }
3594
3595 #[test]
3596 fn test_obsidian_attachment_subfolder_under_file() {
3597 let temp_dir = tempdir().unwrap();
3598 let vault = temp_dir.path().join("vault-sub");
3599 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3600 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3601
3602 std::fs::write(
3603 vault.join(".obsidian/app.json"),
3604 r#"{"attachmentFolderPath": "./assets"}"#,
3605 )
3606 .unwrap();
3607 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3608
3609 let notes_dir = vault.join("notes");
3610 let source_file = notes_dir.join("test.md");
3611 std::fs::write(&source_file, "placeholder").unwrap();
3612
3613 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3614
3615 let content = "# Test\n\n\n";
3616 let ctx =
3617 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3618 let result = rule.check(&ctx).unwrap();
3619
3620 assert!(
3621 result.is_empty(),
3622 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3623 );
3624 }
3625
3626 #[test]
3627 fn test_obsidian_attachment_vault_root() {
3628 let temp_dir = tempdir().unwrap();
3629 let vault = temp_dir.path().join("vault-root");
3630 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3631 std::fs::create_dir_all(vault.join("notes")).unwrap();
3632
3633 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3635 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3636
3637 let notes_dir = vault.join("notes");
3638 let source_file = notes_dir.join("test.md");
3639 std::fs::write(&source_file, "placeholder").unwrap();
3640
3641 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3642
3643 let content = "# Test\n\n\n";
3644 let ctx =
3645 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3646 let result = rule.check(&ctx).unwrap();
3647
3648 assert!(
3649 result.is_empty(),
3650 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3651 );
3652 }
3653
3654 #[test]
3655 fn test_search_paths_multiple_directories() {
3656 let temp_dir = tempdir().unwrap();
3657 let base_path = temp_dir.path();
3658
3659 let dir_a = base_path.join("dir-a");
3660 let dir_b = base_path.join("dir-b");
3661 std::fs::create_dir_all(&dir_a).unwrap();
3662 std::fs::create_dir_all(&dir_b).unwrap();
3663 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3664 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3665
3666 let config = MD057Config {
3667 search_paths: vec![
3668 dir_a.to_string_lossy().into_owned(),
3669 dir_b.to_string_lossy().into_owned(),
3670 ],
3671 ..Default::default()
3672 };
3673 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3674
3675 let content = "# Test\n\n\n\n\n";
3676 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3677 let result = rule.check(&ctx).unwrap();
3678
3679 assert!(
3680 result.is_empty(),
3681 "Should find files across multiple search paths. Got: {result:?}"
3682 );
3683 }
3684
3685 #[test]
3686 fn test_cross_file_check_with_search_paths() {
3687 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3688
3689 let temp_dir = tempdir().unwrap();
3690 let base_path = temp_dir.path();
3691
3692 let docs_dir = base_path.join("docs");
3694 std::fs::create_dir_all(&docs_dir).unwrap();
3695 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3696
3697 let config = MD057Config {
3698 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3699 ..Default::default()
3700 };
3701 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3702
3703 let file_path = base_path.join("README.md");
3704 std::fs::write(&file_path, "# Readme\n").unwrap();
3705
3706 let mut file_index = FileIndex::default();
3707 file_index.cross_file_links.push(CrossFileLinkIndex {
3708 target_path: "guide.md".to_string(),
3709 fragment: String::new(),
3710 line: 3,
3711 column: 1,
3712 });
3713
3714 let workspace_index = WorkspaceIndex::new();
3715
3716 let result = rule
3717 .cross_file_check(&file_path, &file_index, &workspace_index)
3718 .unwrap();
3719
3720 assert!(
3721 result.is_empty(),
3722 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3723 );
3724 }
3725
3726 #[test]
3727 fn test_cross_file_check_with_obsidian_flavor() {
3728 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3729
3730 let temp_dir = tempdir().unwrap();
3731 let vault = temp_dir.path().join("vault-xf");
3732 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3733 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3734 std::fs::create_dir_all(vault.join("notes")).unwrap();
3735
3736 std::fs::write(
3737 vault.join(".obsidian/app.json"),
3738 r#"{"attachmentFolderPath": "Attachments"}"#,
3739 )
3740 .unwrap();
3741 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3742
3743 let notes_dir = vault.join("notes");
3744 let file_path = notes_dir.join("test.md");
3745 std::fs::write(&file_path, "placeholder").unwrap();
3746
3747 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3748 .with_path(¬es_dir)
3749 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3750
3751 let mut file_index = FileIndex::default();
3752 file_index.cross_file_links.push(CrossFileLinkIndex {
3753 target_path: "ref.md".to_string(),
3754 fragment: String::new(),
3755 line: 3,
3756 column: 1,
3757 });
3758
3759 let workspace_index = WorkspaceIndex::new();
3760
3761 let result = rule
3762 .cross_file_check(&file_path, &file_index, &workspace_index)
3763 .unwrap();
3764
3765 assert!(
3766 result.is_empty(),
3767 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3768 );
3769 }
3770
3771 #[test]
3772 fn test_check_clears_stale_cache() {
3773 let temp_dir = tempdir().unwrap();
3776 let base_path = temp_dir.path();
3777
3778 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3779
3780 let phantom_path = base_path.join("phantom.md");
3782 {
3783 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3784 cache.insert(phantom_path.clone(), true);
3785 }
3786
3787 let content = "[phantom](phantom.md)\n";
3788 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3789 let warnings = rule.check(&ctx).unwrap();
3790
3791 assert_eq!(
3793 warnings.len(),
3794 1,
3795 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3796 );
3797 assert!(warnings[0].message.contains("phantom.md"));
3798 }
3799
3800 #[test]
3801 fn test_check_does_not_carry_over_cache_between_runs() {
3802 let temp_dir = tempdir().unwrap();
3804 let base_path = temp_dir.path();
3805
3806 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3807
3808 let content = "[missing](nonexistent.md)\n";
3809 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3810
3811 let warnings_1 = rule.check(&ctx).unwrap();
3813 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3814
3815 let nonexistent_path = base_path.join("nonexistent.md");
3817 {
3818 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3819 cache.insert(nonexistent_path.clone(), true);
3820 }
3821
3822 let warnings_2 = rule.check(&ctx).unwrap();
3824 assert_eq!(
3825 warnings_2.len(),
3826 1,
3827 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3828 );
3829 }
3830
3831 #[test]
3837 fn test_no_duplicate_warnings_for_broken_relative_link() {
3838 use crate::workspace_index::WorkspaceIndex;
3839
3840 let temp_dir = tempdir().unwrap();
3841 let base_path = temp_dir.path();
3842
3843 let source_file = base_path.join("index.md");
3845 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3846
3847 let content = "[broken](does/not/exist.md)\n";
3848
3849 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3850
3851 let ctx = crate::lint_context::LintContext::new(
3853 content,
3854 crate::config::MarkdownFlavor::Standard,
3855 Some(source_file.clone()),
3856 );
3857 let check_warnings = rule.check(&ctx).unwrap();
3858
3859 let mut file_index = FileIndex::new();
3861 rule.contribute_to_index(&ctx, &mut file_index);
3862 let workspace_index = WorkspaceIndex::new();
3863 let cross_warnings = rule
3864 .cross_file_check(&source_file, &file_index, &workspace_index)
3865 .unwrap();
3866
3867 let total = check_warnings.len() + cross_warnings.len();
3868 assert_eq!(
3869 total, 1,
3870 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3871 check={check_warnings:?}, cross={cross_warnings:?}"
3872 );
3873 }
3874
3875 #[test]
3880 fn test_absolute_dir_link_accepted_relative_to_roots() {
3881 let temp_dir = tempdir().unwrap();
3882 let root = temp_dir.path();
3883
3884 let dir_d = root.join("d");
3886 std::fs::create_dir_all(&dir_d).unwrap();
3887 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3888
3889 let content = "\
3892[absolute dir](/d)\n\
3893[relative dir](d)\n\
3894[absolute file](/d/foo.md)\n\
3895[relative file](d/foo.md)\n";
3896
3897 let config = MD057Config {
3898 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3899 roots: vec![],
3900 ..Default::default()
3901 };
3902 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3903
3904 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3905 let result = rule.check(&ctx).unwrap();
3906
3907 assert!(
3908 result.is_empty(),
3909 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3910 );
3911 }
3912
3913 #[test]
3916 fn test_absolute_trailing_slash_dir_link_requires_index() {
3917 let temp_dir = tempdir().unwrap();
3918 let root = temp_dir.path();
3919
3920 let dir_d = root.join("d");
3922 std::fs::create_dir_all(&dir_d).unwrap();
3923 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3924
3925 let content = "[dir with slash](/d/)\n";
3927
3928 let config = MD057Config {
3929 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3930 roots: vec![],
3931 ..Default::default()
3932 };
3933 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3934
3935 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3936 let result = rule.check(&ctx).unwrap();
3937
3938 assert_eq!(
3939 result.len(),
3940 1,
3941 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3942 );
3943 }
3944
3945 #[test]
3949 fn test_docs_dir_variant_still_enforces_index_md() {
3950 let temp_dir = tempdir().unwrap();
3951 let root = temp_dir.path();
3952
3953 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3955
3956 let docs_dir = root.join("docs");
3958 std::fs::create_dir_all(&docs_dir).unwrap();
3959 let section_dir = docs_dir.join("section");
3960 std::fs::create_dir_all(§ion_dir).unwrap();
3961 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3962
3963 let source_file = docs_dir.join("index.md");
3965 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3966
3967 let config = MD057Config {
3968 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3969 ..Default::default()
3970 };
3971 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3972
3973 let content = "[sec](/section)\n";
3974 let ctx = crate::lint_context::LintContext::new(
3975 content,
3976 crate::config::MarkdownFlavor::Standard,
3977 Some(source_file.clone()),
3978 );
3979 let result = rule.check(&ctx).unwrap();
3980
3981 assert_eq!(
3983 result.len(),
3984 1,
3985 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3986 );
3987 assert!(
3988 result[0].message.contains("index.md") || result[0].message.contains("section"),
3989 "Message should mention the directory or missing index.md: {}",
3990 result[0].message
3991 );
3992 }
3993
3994 #[test]
4000 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4001 let temp_dir = tempdir().unwrap();
4002 let root = temp_dir.path();
4003
4004 let guide_dir = root.join("guide");
4006 std::fs::create_dir_all(&guide_dir).unwrap();
4007 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4008
4009 let content = "[guide with fragment](/guide/#intro)\n";
4011
4012 let config = MD057Config {
4013 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4014 roots: vec![],
4015 ..Default::default()
4016 };
4017 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4018 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4019 let result = rule.check(&ctx).unwrap();
4020
4021 assert_eq!(
4022 result.len(),
4023 1,
4024 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4025 );
4026 }
4027}
4028
4029#[cfg(test)]
4030mod self_referential_links_tests {
4031 use super::*;
4032 use tempfile::tempdir;
4033
4034 fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4036 let source_file = dir.join(name);
4037 std::fs::write(&source_file, content).unwrap();
4038 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4039 let ctx =
4040 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4041 rule.check(&ctx).unwrap()
4042 }
4043
4044 fn enabled() -> MD057Config {
4045 MD057Config {
4046 self_referential_links: true,
4047 ..Default::default()
4048 }
4049 }
4050
4051 #[test]
4052 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4053 let temp_dir = tempdir().unwrap();
4054 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4055 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4056
4057 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4058 assert_eq!(
4059 result[0].message,
4060 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4061 );
4062 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4063 assert_eq!(fix.replacement, "#level-2-heading");
4064 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4065 }
4066
4067 #[test]
4068 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4069 let temp_dir = tempdir().unwrap();
4070 let content = "# Title\n\nSee [this file](test.md).\n";
4071 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4072
4073 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4074 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4075 assert!(
4076 result[0].fix.is_none(),
4077 "Dropping the link would change the document, so there is no fix"
4078 );
4079 }
4080
4081 #[test]
4082 fn test_the_check_is_off_by_default() {
4083 let temp_dir = tempdir().unwrap();
4084 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4085 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4086
4087 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4088 }
4089
4090 #[test]
4091 fn test_a_link_to_another_file_is_left_alone() {
4092 let temp_dir = tempdir().unwrap();
4093 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4094 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4095 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4096
4097 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4098 }
4099
4100 #[test]
4101 fn test_a_self_link_written_with_traversal_reports_once() {
4102 let temp_dir = tempdir().unwrap();
4103 let sub_dir = temp_dir.path().join("sub");
4104 std::fs::create_dir_all(&sub_dir).unwrap();
4105
4106 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4107 let config = MD057Config {
4108 self_referential_links: true,
4109 compact_paths: true,
4110 ..Default::default()
4111 };
4112 let result = check_as_file(&sub_dir, "test.md", content, config);
4113
4114 assert_eq!(
4115 result.len(),
4116 1,
4117 "A compacted path would still be a link back to this file. Got: {result:?}"
4118 );
4119 assert_eq!(
4120 result[0].message,
4121 "Relative link '../sub/test.md' points to the file it is in"
4122 );
4123 }
4124
4125 #[test]
4126 fn test_compact_paths_still_reports_a_link_to_another_file() {
4127 let temp_dir = tempdir().unwrap();
4128 let sub_dir = temp_dir.path().join("sub");
4129 std::fs::create_dir_all(&sub_dir).unwrap();
4130 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4131
4132 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4133 let config = MD057Config {
4134 self_referential_links: true,
4135 compact_paths: true,
4136 ..Default::default()
4137 };
4138 let result = check_as_file(&sub_dir, "test.md", content, config);
4139
4140 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4141 assert_eq!(
4142 result[0].message,
4143 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4144 );
4145 }
4146
4147 #[test]
4148 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4149 let temp_dir = tempdir().unwrap();
4150 let content = "# Title\n\nSee [this file](test#title).\n";
4151 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4152
4153 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4154 assert_eq!(
4155 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4156 Some("#title"),
4157 "Got: {result:?}"
4158 );
4159 }
4160
4161 #[test]
4162 fn test_a_reference_definition_pointing_at_its_own_file() {
4163 let temp_dir = tempdir().unwrap();
4164 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4165 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4166
4167 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4168 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4169 assert_eq!(fix.replacement, "#level-2-heading");
4170 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4171 }
4172
4173 #[test]
4174 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4175 let temp_dir = tempdir().unwrap();
4176 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4177 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4178
4179 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4180 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4181 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4184 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4185 .fix(&crate::lint_context::LintContext::new(
4186 content,
4187 crate::config::MarkdownFlavor::Standard,
4188 Some(temp_dir.path().join("test.md")),
4189 ))
4190 .unwrap();
4191 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4192 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4193 }
4194
4195 #[test]
4196 fn test_a_self_link_resolved_through_a_search_path() {
4197 let temp_dir = tempdir().unwrap();
4198 let guide_dir = temp_dir.path().join("docs/guide");
4199 std::fs::create_dir_all(&guide_dir).unwrap();
4200 let config = MD057Config {
4201 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4202 ..enabled()
4203 };
4204 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4205 let result = check_as_file(&guide_dir, "test.md", content, config);
4206
4207 assert_eq!(
4208 result.len(),
4209 1,
4210 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4211 );
4212 assert_eq!(
4213 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4214 Some("#title"),
4215 "Got: {result:?}"
4216 );
4217 }
4218
4219 #[test]
4220 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4221 let temp_dir = tempdir().unwrap();
4222 let guide_dir = temp_dir.path().join("docs/guide");
4223 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4224 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4225 let config = MD057Config {
4226 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4227 ..enabled()
4228 };
4229 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4230 let result = check_as_file(&guide_dir, "test.md", content, config);
4231
4232 assert!(
4233 result.is_empty(),
4234 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4235 );
4236 }
4237
4238 #[test]
4239 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4240 let temp_dir = tempdir().unwrap();
4241 let content = "# Title\n\n\n";
4242 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4243
4244 assert!(
4245 result.is_empty(),
4246 "An image is not a link the reader follows. Got: {result:?}"
4247 );
4248 }
4249
4250 #[test]
4251 fn test_a_query_string_is_reported_without_a_suggestion() {
4252 let temp_dir = tempdir().unwrap();
4253 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4254 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4255
4256 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4257 assert!(
4258 result[0].fix.is_none(),
4259 "A query does not survive losing its path. Got: {result:?}"
4260 );
4261 }
4262
4263 #[test]
4264 fn test_fix_rewrites_the_document_and_settles() {
4265 let temp_dir = tempdir().unwrap();
4266 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4267 let source_file = temp_dir.path().join("test.md");
4268 std::fs::write(&source_file, content).unwrap();
4269
4270 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4271 let ctx = crate::lint_context::LintContext::new(
4272 content,
4273 crate::config::MarkdownFlavor::Standard,
4274 Some(source_file.clone()),
4275 );
4276 let fixed = rule.fix(&ctx).unwrap();
4277 assert_eq!(
4278 fixed,
4279 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4280 );
4281
4282 let refixed =
4283 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4284 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4285 }
4286
4287 #[test]
4288 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4289 let unfixable = MD057ExistingRelativeLinks::default();
4290 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4291
4292 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4293 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4294 }
4295
4296 #[test]
4297 fn test_the_option_is_read_from_kebab_and_snake_case() {
4298 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4299 assert!(kebab.self_referential_links);
4300
4301 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4302 assert!(snake.self_referential_links);
4303 }
4304
4305 fn front_matter_checked() -> MD057Config {
4306 MD057Config {
4307 check_frontmatter: true,
4308 ..Default::default()
4309 }
4310 }
4311
4312 #[test]
4313 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4314 let temp_dir = tempdir().unwrap();
4315 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4316 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4317
4318 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4319 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4320 assert_eq!(result[0].line, 2);
4321 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4322 assert_eq!(result[0].end_column, 23);
4323 }
4324
4325 #[test]
4326 fn test_frontmatter_paths_are_not_checked_by_default() {
4327 let temp_dir = tempdir().unwrap();
4328 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4329 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4330
4331 assert!(
4332 result.is_empty(),
4333 "Frontmatter is only checked on request. Got: {result:?}"
4334 );
4335 }
4336
4337 #[test]
4338 fn test_an_existing_frontmatter_path_is_not_reported() {
4339 let temp_dir = tempdir().unwrap();
4340 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4341 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4342 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4343
4344 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4345 assert_eq!(result[0].line, 3);
4346 }
4347
4348 #[test]
4349 fn test_an_ignored_frontmatter_field_is_not_checked() {
4350 let temp_dir = tempdir().unwrap();
4351 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4352 let config = MD057Config {
4353 check_frontmatter: true,
4354 ignore_frontmatter_fields: vec!["Image".to_string()],
4355 ..Default::default()
4356 };
4357 let result = check_as_file(temp_dir.path(), "test.md", content, config);
4358
4359 assert_eq!(
4360 result.len(),
4361 1,
4362 "The ignored field is skipped and the other is not. Got: {result:?}"
4363 );
4364 assert_eq!(result[0].line, 3);
4365 }
4366
4367 #[test]
4368 fn test_an_external_frontmatter_url_is_not_reported() {
4369 let temp_dir = tempdir().unwrap();
4370 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4371 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4372
4373 assert!(
4374 result.is_empty(),
4375 "An external URL has no local target. Got: {result:?}"
4376 );
4377 }
4378
4379 #[test]
4380 fn test_a_frontmatter_fragment_is_left_to_md051() {
4381 let temp_dir = tempdir().unwrap();
4382 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4383 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4384
4385 assert!(
4386 result.is_empty(),
4387 "A fragment names a heading, not a file. Got: {result:?}"
4388 );
4389 }
4390
4391 #[test]
4392 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4393 let temp_dir = tempdir().unwrap();
4394 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4395
4396 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4397 assert!(
4398 ignored.is_empty(),
4399 "Absolute paths are ignored by default. Got: {ignored:?}"
4400 );
4401
4402 let warning_config = MD057Config {
4403 check_frontmatter: true,
4404 absolute_links: AbsoluteLinksOption::Warn,
4405 ..Default::default()
4406 };
4407 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4408 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4409 assert_eq!(
4410 warned[0].message,
4411 "Absolute link '/docs/guide.md' cannot be validated locally"
4412 );
4413 }
4414
4415 #[test]
4416 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4417 let temp_dir = tempdir().unwrap();
4418 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4419 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4420 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4421
4422 assert_eq!(
4423 result.len(),
4424 1,
4425 "A query names no file, so only the missing target is reported. Got: {result:?}"
4426 );
4427 assert_eq!(result[0].line, 2);
4428 assert_eq!(
4429 result[0].message,
4430 "Relative link 'docs/missing.md?raw=true' does not exist"
4431 );
4432 }
4433
4434 #[test]
4435 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4436 let temp_dir = tempdir().unwrap();
4437 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4438 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4439
4440 assert!(
4441 result.is_empty(),
4442 "Only path-shaped values are destinations. Got: {result:?}"
4443 );
4444 }
4445}