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