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