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::{
12 FileIndex, LinkOrigin, Md057LinkTarget, extract_cross_file_links, normalize_relative_path,
13};
14use pulldown_cmark::LinkType;
15use regex::Regex;
16use std::collections::{HashMap, HashSet};
17use std::env;
18use std::path::{Path, PathBuf};
19use std::sync::LazyLock;
20use std::sync::{Arc, Mutex};
21
22mod md057_config;
23use crate::utils::mkdocs_config::resolve_docs_dir;
24use crate::utils::obsidian_config::resolve_attachment_folder;
25use crate::utils::project_root::discover_project_root_from;
26pub use md057_config::{AbsoluteLinksOption, MD057Config};
27
28static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
30 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
31
32fn reset_file_existence_cache() {
34 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
35 cache.clear();
36 }
37}
38
39fn file_exists_with_cache(path: &Path) -> bool {
41 match FILE_EXISTENCE_CACHE.lock() {
42 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
43 Err(_) => path.exists(), }
45}
46
47fn file_exists_or_markdown_extension(path: &Path) -> bool {
50 resolve_existing_target(path).is_some()
51}
52
53fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
60 if file_exists_with_cache(path) {
62 return Some(path.to_path_buf());
63 }
64
65 if path.extension().is_none() {
67 for ext in MARKDOWN_EXTENSIONS {
68 let path_with_ext = path.with_extension(&ext[1..]);
70 if file_exists_with_cache(&path_with_ext) {
71 return Some(path_with_ext);
72 }
73 }
74 }
75
76 None
77}
78
79static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
81
82static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
87
88static URL_EXTRACT_REGEX: LazyLock<Regex> =
91 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
92
93static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
98
99static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
101
102static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
108
109#[inline]
112fn hex_digit_to_value(byte: u8) -> Option<u8> {
113 match byte {
114 b'0'..=b'9' => Some(byte - b'0'),
115 b'a'..=b'f' => Some(byte - b'a' + 10),
116 b'A'..=b'F' => Some(byte - b'A' + 10),
117 _ => None,
118 }
119}
120
121const MARKDOWN_EXTENSIONS: &[&str] = &[
123 ".md",
124 ".markdown",
125 ".mdx",
126 ".mkd",
127 ".mkdn",
128 ".mdown",
129 ".mdwn",
130 ".qmd",
131 ".rmd",
132];
133
134#[derive(Debug, PartialEq, Eq)]
136enum SelfReferentialLink {
137 WholeFile,
140 Fragment(String),
143}
144
145#[cfg(feature = "blake3")]
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum DependencyPathState {
148 Missing,
149 File,
150 Directory,
151 Other,
152}
153
154#[derive(Debug, Clone)]
156pub struct MD057ExistingRelativeLinks {
157 base_path: Arc<Mutex<Option<PathBuf>>>,
162 config: MD057Config,
164}
165
166impl Default for MD057ExistingRelativeLinks {
167 fn default() -> Self {
168 Self {
169 base_path: Arc::new(Mutex::new(None)),
170 config: MD057Config::default(),
171 }
172 }
173}
174
175impl MD057ExistingRelativeLinks {
176 pub fn new() -> Self {
178 Self::default()
179 }
180
181 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
183 let path = path.as_ref();
184 let dir_path = if path.is_file() {
185 path.parent().map(std::path::Path::to_path_buf)
186 } else {
187 Some(path.to_path_buf())
188 };
189
190 if let Ok(mut guard) = self.base_path.lock() {
191 *guard = dir_path;
192 }
193 self
194 }
195
196 pub fn from_config_struct(config: MD057Config) -> Self {
197 Self {
198 base_path: Arc::new(Mutex::new(None)),
199 config,
200 }
201 }
202
203 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
207 if Path::new(path_str).is_absolute() {
208 PathBuf::from(path_str)
209 } else {
210 project_root.join(path_str)
211 }
212 }
213
214 #[inline]
226 fn is_external_url(&self, url: &str) -> bool {
227 if url.is_empty() {
228 return false;
229 }
230
231 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
233 return true;
234 }
235
236 if url.starts_with("{{") || url.starts_with("{%") {
239 return true;
240 }
241
242 if url.contains('@') {
245 return true; }
247
248 if !url.contains('/') && url.ends_with(".com") {
258 return true;
259 }
260
261 if url.starts_with('~') || url.starts_with('@') {
265 return true;
266 }
267
268 false
270 }
271
272 #[inline]
274 fn is_fragment_only_link(&self, url: &str) -> bool {
275 url.starts_with('#')
276 }
277
278 #[inline]
281 fn is_absolute_path(url: &str) -> bool {
282 url.starts_with('/')
283 }
284
285 fn url_decode(path: &str) -> String {
289 if !path.contains('%') {
291 return path.to_string();
292 }
293
294 let bytes = path.as_bytes();
295 let mut result = Vec::with_capacity(bytes.len());
296 let mut i = 0;
297
298 while i < bytes.len() {
299 if bytes[i] == b'%' && i + 2 < bytes.len() {
300 let hex1 = bytes[i + 1];
302 let hex2 = bytes[i + 2];
303 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
304 result.push(d1 * 16 + d2);
305 i += 3;
306 continue;
307 }
308 }
309 result.push(bytes[i]);
310 i += 1;
311 }
312
313 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
315 }
316
317 fn strip_query_and_fragment(url: &str) -> &str {
325 let query_pos = url.find('?');
328 let fragment_pos = url.find('#');
329
330 match (query_pos, fragment_pos) {
331 (Some(q), Some(f)) => {
332 &url[..q.min(f)]
334 }
335 (Some(q), None) => &url[..q],
336 (None, Some(f)) => &url[..f],
337 (None, None) => url,
338 }
339 }
340
341 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
343 base_path.join(link)
344 }
345
346 fn compute_search_paths(
351 &self,
352 flavor: crate::config::MarkdownFlavor,
353 source_file: Option<&Path>,
354 base_path: &Path,
355 project_root: &Path,
356 ) -> Vec<PathBuf> {
357 let mut paths = Vec::new();
358
359 if flavor == crate::config::MarkdownFlavor::Obsidian
361 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
362 && attachment_dir != *base_path
363 {
364 paths.push(attachment_dir);
365 }
366
367 for search_path in &self.config.search_paths {
371 let resolved = Self::resolve_against_project_root(search_path, project_root);
372 if resolved != *base_path && !paths.contains(&resolved) {
373 paths.push(resolved);
374 }
375 }
376
377 paths
378 }
379
380 fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
385 if !ctx.links().is_empty() {
386 let lines = ctx.raw_lines();
387 let mut processed_lines = HashSet::new();
388
389 for link in ctx.links() {
390 let line_index = link.line - 1;
391 if line_index >= lines.len()
392 || ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block)
393 || !processed_lines.insert(line_index)
394 {
395 continue;
396 }
397 let line = lines[line_index];
398 if !line.contains("](") {
399 continue;
400 }
401
402 let line_start_byte = ctx.line_start_byte(link.line).unwrap_or(0);
403 for link_match in LINK_START_REGEX.find_iter(line) {
404 if link_match.as_str().starts_with('!') {
405 let escapes = line[..link_match.start()]
406 .bytes()
407 .rev()
408 .take_while(|&byte| byte == b'\\')
409 .count();
410 if escapes % 2 == 0 {
411 continue;
412 }
413 }
414
415 let absolute_start = line_start_byte + link_match.start();
416 if ctx.is_in_code_span_byte(absolute_start)
417 || ctx.is_in_math_span(absolute_start)
418 || ctx.is_in_shortcode(absolute_start)
419 {
420 continue;
421 }
422 let expected_start = link_match.end() - 1;
423 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, expected_start)
424 .and_then(|caps| caps.get(1).map(|url| (caps, url)))
425 .or_else(|| {
426 extract_url_at(&URL_EXTRACT_REGEX, line, expected_start)
427 .and_then(|caps| caps.get(1).map(|url| (caps, url)))
428 });
429 let Some((_, url_match)) = caps_and_url else {
430 continue;
431 };
432 let url = url_match.as_str().trim();
433 if url.is_empty()
434 || (url.starts_with('`') && url.ends_with('`'))
435 || self.is_external_url(url)
436 || self.is_fragment_only_link(url)
437 {
438 continue;
439 }
440 index.add_md057_link_target(Md057LinkTarget {
441 target: url.to_string(),
442 origin: LinkOrigin::Body,
443 });
444 }
445 }
446 }
447
448 for image in ctx.images() {
449 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block)
450 || matches!(image.link_type, LinkType::WikiLink { .. })
451 || ctx.is_in_shortcode(image.byte_offset)
452 {
453 continue;
454 }
455 let url = image.url.as_ref();
456 if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
457 continue;
458 }
459 index.add_md057_link_target(Md057LinkTarget {
460 target: url.to_string(),
461 origin: LinkOrigin::Body,
462 });
463 }
464
465 for reference in ctx.reference_definitions() {
466 let url = reference.url.as_str();
467 if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
468 continue;
469 }
470 index.add_md057_link_target(Md057LinkTarget {
471 target: url.to_string(),
472 origin: LinkOrigin::Body,
473 });
474 }
475
476 for link in frontmatter_values::link_destinations(ctx) {
477 let line = ctx.lines[link.line - 1].content(ctx.content);
478 let url = &line[link.range];
479 if self.is_external_url(url) || self.is_fragment_only_link(url) {
480 continue;
481 }
482 index.add_md057_link_target(Md057LinkTarget {
483 target: url.to_string(),
484 origin: LinkOrigin::FrontMatter { field: link.field },
485 });
486 }
487 }
488
489 fn exists_in_search_paths(
491 decoded_path: &str,
492 search_paths: &[PathBuf],
493 policy: Option<&crate::lint_context::LinkTargetPolicy>,
494 ) -> bool {
495 search_paths.iter().any(|dir| {
496 let candidate = dir.join(decoded_path);
497 Self::target_exists(&candidate, policy)
498 })
499 }
500
501 fn target_exists(path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> bool {
502 Self::resolve_target(path, policy).is_some()
503 }
504
505 fn resolve_target(path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> Option<PathBuf> {
506 if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
507 return Some(supplied);
508 }
509 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
510 return None;
511 }
512 resolve_existing_target(path)
513 }
514
515 fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
516 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
517 format!("Relative link '{url}' target not in the supplied document set")
518 } else {
519 format!("Relative link '{url}' does not exist")
520 }
521 }
522
523 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
529 if !self.config.compact_paths {
530 return None;
531 }
532
533 let path_end = url
535 .find('?')
536 .unwrap_or(url.len())
537 .min(url.find('#').unwrap_or(url.len()));
538 let path_part = &url[..path_end];
539 let suffix = &url[path_end..];
540
541 let decoded_path = Self::url_decode(path_part);
543
544 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
545 }
546
547 fn self_referential_link(
559 &self,
560 url: &str,
561 base_path: &Path,
562 search_paths: &[PathBuf],
563 source_file: Option<&Path>,
564 policy: Option<&crate::lint_context::LinkTargetPolicy>,
565 ) -> Option<SelfReferentialLink> {
566 if !self.config.self_referential_links {
567 return None;
568 }
569 let source_file = source_file?;
570
571 let path_part = Self::strip_query_and_fragment(url);
572 if path_part.is_empty() {
573 return None;
574 }
575 let suffix = &url[path_part.len()..];
576
577 let decoded_path = Self::url_decode(path_part);
578 let resolved = std::iter::once(base_path)
582 .chain(search_paths.iter().map(PathBuf::as_path))
583 .find_map(|dir| Self::resolve_target(&Self::resolve_link_path_with_base(&decoded_path, dir), policy))?;
584 if !Self::is_same_file(&resolved, source_file) {
585 return None;
586 }
587
588 match suffix.strip_prefix('#') {
592 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
593 _ => Some(SelfReferentialLink::WholeFile),
594 }
595 }
596
597 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
604 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
605 let label_end = Self::label_end(def)?;
606 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
607 title.saturating_sub(ref_def.byte_offset).min(def.len())
608 });
609 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
610 let start = ref_def.byte_offset + offset;
611 Some(start..start + ref_def.url.len())
612 }
613
614 fn label_end(def: &str) -> Option<usize> {
619 let bytes = def.as_bytes();
620 let mut i = 0;
621 while i < bytes.len() {
622 match bytes[i] {
623 b'\\' => i += 2,
624 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
625 _ => i += 1,
626 }
627 }
628 None
629 }
630
631 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
635 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
636 }
637
638 fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
641 match self.config.absolute_links {
642 AbsoluteLinksOption::Ignore => None,
643 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
644 AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
645 AbsoluteLinksOption::RelativeToRoots => {
646 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
647 }
648 }
649 }
650
651 fn check_front_matter(
658 &self,
659 ctx: &crate::lint_context::LintContext,
660 base_path: &Path,
661 search_paths: &[PathBuf],
662 project_root: &Path,
663 warnings: &mut Vec<LintWarning>,
664 ) {
665 if !self.config.check_frontmatter {
666 return;
667 }
668
669 let ignored: HashSet<String> = self
670 .config
671 .ignore_frontmatter_fields
672 .iter()
673 .map(|field| field.to_lowercase())
674 .collect();
675
676 for link in frontmatter_values::link_destinations(ctx) {
677 if link.field_is_in(&ignored) {
678 continue;
679 }
680
681 let line = ctx.lines[link.line - 1].content(ctx.content);
682 let url = &line[link.range.clone()];
683
684 if self.is_external_url(url) || self.is_fragment_only_link(url) {
687 continue;
688 }
689
690 let column = byte_to_char_count(line, link.range.start);
691 let end_column = column + url.chars().count();
692
693 if Self::is_absolute_path(url) {
694 if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
695 warnings.push(LintWarning {
696 rule_name: Some(self.name().to_string()),
697 line: link.line,
698 column,
699 end_line: link.line,
700 end_column,
701 message,
702 severity: Severity::Warning,
703 fix: None,
704 });
705 }
706 continue;
707 }
708
709 if Self::relative_target_exists(url, base_path, search_paths, ctx.link_target_policy()) {
710 continue;
711 }
712
713 warnings.push(LintWarning {
714 rule_name: Some(self.name().to_string()),
715 line: link.line,
716 column,
717 end_line: link.line,
718 end_column,
719 message: Self::missing_relative_message(url, ctx.link_target_policy()),
720 severity: Severity::Error,
721 fix: None,
722 });
723 }
724 }
725
726 fn relative_target_exists(
733 url: &str,
734 base_path: &Path,
735 search_paths: &[PathBuf],
736 policy: Option<&crate::lint_context::LinkTargetPolicy>,
737 ) -> bool {
738 let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
739 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
740
741 if Self::target_exists(&resolved_path, policy) {
743 return true;
744 }
745
746 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
747 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
748 && let (Some(stem), Some(parent)) = (
749 resolved_path.file_stem().and_then(|s| s.to_str()),
750 resolved_path.parent(),
751 )
752 && MARKDOWN_EXTENSIONS
753 .iter()
754 .any(|md_ext| Self::target_exists(&parent.join(format!("{stem}{md_ext}")), policy))
755 {
756 return true;
757 }
758
759 Self::exists_in_search_paths(&decoded_path, search_paths, policy)
760 }
761
762 fn produces_fixes(&self) -> bool {
766 self.config.compact_paths || self.config.self_referential_links
767 }
768
769 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
771 match self_link {
772 SelfReferentialLink::Fragment(fragment) => {
773 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
774 }
775 SelfReferentialLink::WholeFile => {
776 format!("Relative link '{url}' points to the file it is in")
777 }
778 }
779 }
780
781 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
787 if resolved.file_name() != source_file.file_name() {
789 return false;
790 }
791 match (resolved.canonicalize(), source_file.canonicalize()) {
792 (Ok(link), Ok(source)) => link == source,
793 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
794 }
795 }
796
797 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
803 let Some(docs_dir) = resolve_docs_dir(source_path) else {
804 return Some(format!(
805 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
806 ));
807 };
808
809 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
810
811 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
814 Resolution::Found => None,
815 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
816 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
817 resolved.display()
818 )),
819 Resolution::NotFound { resolved } => Some(format!(
820 "Absolute link '{url}' resolves to '{}' which does not exist",
821 resolved.display()
822 )),
823 }
824 }
825
826 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
835 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
836
837 for root in roots {
838 let root_path = Self::resolve_against_project_root(root, project_root);
839 if matches!(
842 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
843 Resolution::Found
844 ) {
845 return None;
846 }
847 }
848
849 if matches!(
850 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
852 Resolution::Found
853 ) {
854 return None;
855 }
856
857 let msg = if roots.is_empty() {
858 format!("Absolute link '{url}' was not found under the project root")
859 } else {
860 format!("Absolute link '{url}' was not found under any configured root or the project root")
861 };
862 Some(msg)
863 }
864
865 fn prepare_absolute_url(url: &str) -> (String, bool) {
869 let relative_url = url.trim_start_matches('/');
870 let file_path = Self::strip_query_and_fragment(relative_url);
871 let decoded = Self::url_decode(file_path);
872 let is_directory_link = url.ends_with('/') || decoded.is_empty();
873 (decoded, is_directory_link)
874 }
875
876 fn resolve_under_root_with_opts(
898 root_path: &Path,
899 decoded: &str,
900 is_directory_link: bool,
901 require_index_for_dirs: bool,
902 ) -> Resolution {
903 let resolved = root_path.join(decoded);
904
905 let is_dir = resolved.is_dir();
906
907 if is_directory_link || (require_index_for_dirs && is_dir) {
912 let index_path = resolved.join("index.md");
913 if file_exists_with_cache(&index_path) {
914 return Resolution::Found;
915 }
916 if is_dir {
917 return Resolution::DirectoryWithoutIndex { resolved };
918 }
919 }
920
921 let decoded_has_trailing_slash = decoded.ends_with('/');
927 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
928 return Resolution::Found;
929 }
930
931 if file_exists_or_markdown_extension(&resolved) {
932 return Resolution::Found;
933 }
934
935 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
938 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
939 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
940 {
941 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
942 let source_path = parent.join(format!("{stem}{md_ext}"));
943 file_exists_with_cache(&source_path)
944 });
945 if has_md_source {
946 return Resolution::Found;
947 }
948 }
949
950 Resolution::NotFound { resolved }
951 }
952}
953
954#[cfg(feature = "blake3")]
958impl MD057ExistingRelativeLinks {
959 pub fn cache_dependency_fingerprint(
965 &self,
966 source_file: &Path,
967 flavor: crate::config::MarkdownFlavor,
968 file_index: &FileIndex,
969 ) -> String {
970 let mut hasher = blake3::Hasher::new();
971 hasher.update(b"rumdl-md057-dependencies-v1");
972 if file_index.md057_link_targets.is_empty() {
973 return hasher.finalize().to_hex().to_string();
974 }
975
976 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
977 let project_root = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
978 let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
979 let base_path = explicit_base.unwrap_or_else(|| {
980 resolved_source
981 .parent()
982 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
983 });
984 let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
985 let ignored_frontmatter_fields: HashSet<String> = self
986 .config
987 .ignore_frontmatter_fields
988 .iter()
989 .map(|field| field.to_lowercase())
990 .collect();
991
992 for dependency in &file_index.md057_link_targets {
993 if let LinkOrigin::FrontMatter { field } = &dependency.origin
994 && (!self.config.check_frontmatter
995 || field
996 .as_ref()
997 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
998 {
999 continue;
1000 }
1001
1002 let url = dependency.target.as_str();
1003 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1004 continue;
1005 }
1006
1007 Self::hash_bytes(&mut hasher, url.as_bytes());
1008 if Self::is_absolute_path(url) {
1009 match self.config.absolute_links {
1010 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1011 AbsoluteLinksOption::RelativeToDocs => {
1012 hasher.update(b"docs");
1013 if let Some(docs_dir) = resolve_docs_dir(source_file) {
1014 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1015 } else {
1016 hasher.update(b"no-docs-dir");
1017 }
1018 }
1019 AbsoluteLinksOption::RelativeToRoots => {
1020 hasher.update(b"roots");
1021 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1022 let mut found = false;
1023 for root in &self.config.roots {
1024 let root_path = Self::resolve_against_project_root(root, &project_root);
1025 if Self::observe_under_root(&mut hasher, &root_path, &decoded, is_directory_link, false) {
1026 found = true;
1027 break;
1028 }
1029 }
1030 if !found {
1031 Self::observe_under_root(&mut hasher, &project_root, &decoded, is_directory_link, false);
1032 }
1033 }
1034 }
1035 } else {
1036 hasher.update(b"relative");
1037 if self.config.self_referential_links
1038 && Self::observe_self_referential_resolution(
1039 &mut hasher,
1040 url,
1041 &base_path,
1042 &search_paths,
1043 &resolved_source,
1044 )
1045 {
1046 continue;
1047 }
1048 Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
1049 }
1050 }
1051
1052 hasher.finalize().to_hex().to_string()
1053 }
1054
1055 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1056 hasher.update(&(bytes.len() as u64).to_le_bytes());
1057 hasher.update(bytes);
1058 }
1059
1060 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1061 #[cfg(unix)]
1062 {
1063 use std::os::unix::ffi::OsStrExt;
1064 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1065 }
1066 #[cfg(windows)]
1067 {
1068 use std::os::windows::ffi::OsStrExt;
1069 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1070 Self::hash_bytes(hasher, &encoded);
1071 }
1072 #[cfg(not(any(unix, windows)))]
1073 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1074 }
1075
1076 fn observe_path(hasher: &mut blake3::Hasher, path: &Path) -> DependencyPathState {
1077 Self::hash_path(hasher, path);
1078 let state = match std::fs::metadata(path) {
1079 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1080 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1081 Ok(_) => DependencyPathState::Other,
1082 Err(_) => DependencyPathState::Missing,
1083 };
1084 hasher.update(&[match state {
1085 DependencyPathState::Missing => 0,
1086 DependencyPathState::File => 1,
1087 DependencyPathState::Directory => 2,
1088 DependencyPathState::Other => 3,
1089 }]);
1090 state
1091 }
1092
1093 fn observe_existing_target(hasher: &mut blake3::Hasher, path: &Path) -> Option<PathBuf> {
1094 if Self::observe_path(hasher, path) != DependencyPathState::Missing {
1095 return Some(path.to_path_buf());
1096 }
1097 if path.extension().is_none() {
1098 for extension in MARKDOWN_EXTENSIONS {
1099 let candidate = path.with_extension(&extension[1..]);
1100 if Self::observe_path(hasher, &candidate) != DependencyPathState::Missing {
1101 return Some(candidate);
1102 }
1103 }
1104 }
1105 None
1106 }
1107
1108 fn observe_self_referential_resolution(
1109 hasher: &mut blake3::Hasher,
1110 url: &str,
1111 base_path: &Path,
1112 search_paths: &[PathBuf],
1113 source_file: &Path,
1114 ) -> bool {
1115 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1116 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1117 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1118 if let Some(resolved) = Self::observe_existing_target(hasher, &candidate) {
1119 let canonical = resolved.canonicalize().unwrap_or(resolved);
1120 hasher.update(b"resolved-identity");
1121 Self::hash_path(hasher, &canonical);
1122 return Self::is_same_file(&canonical, source_file);
1123 }
1124 }
1125 false
1126 }
1127
1128 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1129 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1130 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1131 if Self::observe_existing_target(hasher, &resolved).is_some() {
1132 return;
1133 }
1134
1135 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1136 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1137 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1138 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1139 Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1140 })
1141 {
1142 return;
1143 }
1144
1145 for search_path in search_paths {
1146 if Self::observe_existing_target(hasher, &search_path.join(&decoded)).is_some() {
1147 return;
1148 }
1149 }
1150 }
1151
1152 fn observe_absolute_resolution(
1153 hasher: &mut blake3::Hasher,
1154 root: &Path,
1155 url: &str,
1156 require_index_for_dirs: bool,
1157 ) -> bool {
1158 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1159 Self::observe_under_root(hasher, root, &decoded, is_directory_link, require_index_for_dirs)
1160 }
1161
1162 fn observe_under_root(
1163 hasher: &mut blake3::Hasher,
1164 root: &Path,
1165 decoded: &str,
1166 is_directory_link: bool,
1167 require_index_for_dirs: bool,
1168 ) -> bool {
1169 let resolved = root.join(decoded);
1170 let resolved_state = Self::observe_path(hasher, &resolved);
1171 let is_dir = resolved_state == DependencyPathState::Directory;
1172
1173 if is_directory_link || (require_index_for_dirs && is_dir) {
1174 if Self::observe_path(hasher, &resolved.join("index.md")) != DependencyPathState::Missing {
1175 return true;
1176 }
1177 if is_dir {
1178 return false;
1179 }
1180 }
1181
1182 if !require_index_for_dirs && !is_directory_link && !decoded.ends_with('/') && is_dir {
1183 return true;
1184 }
1185 if resolved_state != DependencyPathState::Missing {
1186 return true;
1187 }
1188 if resolved.extension().is_none()
1189 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1190 Self::observe_path(hasher, &resolved.with_extension(&extension[1..])) != DependencyPathState::Missing
1191 })
1192 {
1193 return true;
1194 }
1195
1196 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1197 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1198 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1199 {
1200 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1201 Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1202 });
1203 }
1204
1205 false
1206 }
1207}
1208
1209enum Resolution {
1213 Found,
1214 DirectoryWithoutIndex { resolved: PathBuf },
1215 NotFound { resolved: PathBuf },
1216}
1217
1218fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1229 let caps = re.captures_at(line, expected_start)?;
1230 if caps.get(0)?.start() != expected_start {
1231 return None;
1232 }
1233 Some(caps)
1234}
1235
1236impl Rule for MD057ExistingRelativeLinks {
1237 fn name(&self) -> &'static str {
1238 "MD057"
1239 }
1240
1241 fn description(&self) -> &'static str {
1242 "Relative links should point to existing files"
1243 }
1244
1245 fn category(&self) -> RuleCategory {
1246 RuleCategory::Link
1247 }
1248
1249 fn skippable_by_category(&self) -> bool {
1250 !self.config.check_frontmatter
1253 }
1254
1255 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1256 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1257 }
1258
1259 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1260 let content = ctx.content;
1261
1262 if content.is_empty() {
1263 return Ok(Vec::new());
1264 }
1265
1266 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1270 if !has_body_links && !self.checks_front_matter_of(ctx) {
1271 return Ok(Vec::new());
1272 }
1273
1274 reset_file_existence_cache();
1276
1277 let mut warnings = Vec::new();
1278
1279 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1283
1284 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
1288
1289 let self_path: Option<PathBuf> = ctx
1292 .source_file()
1293 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
1294
1295 let base_path: Option<PathBuf> = {
1299 if explicit_base.is_some() {
1300 explicit_base
1301 } else if let Some(ref resolved_file) = self_path {
1302 resolved_file
1306 .parent()
1307 .map(std::path::Path::to_path_buf)
1308 .or_else(|| Some(CURRENT_DIR.clone()))
1309 } else {
1310 None
1312 }
1313 };
1314
1315 let Some(base_path) = base_path else {
1317 return Ok(warnings);
1318 };
1319
1320 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
1322
1323 if !ctx.links().is_empty() {
1325 let lines = ctx.raw_lines();
1329
1330 let mut processed_lines = std::collections::HashSet::new();
1333
1334 for link in ctx.links() {
1335 let line_idx = link.line - 1;
1336 if line_idx >= lines.len() {
1337 continue;
1338 }
1339
1340 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1342 continue;
1343 }
1344
1345 if !processed_lines.insert(line_idx) {
1347 continue;
1348 }
1349
1350 let line = lines[line_idx];
1351
1352 if !line.contains("](") {
1354 continue;
1355 }
1356
1357 for link_match in LINK_START_REGEX.find_iter(line) {
1359 if link_match.as_str().starts_with('!') {
1366 let escapes = line[..link_match.start()]
1367 .bytes()
1368 .rev()
1369 .take_while(|&b| b == b'\\')
1370 .count();
1371 if escapes % 2 == 0 {
1372 continue;
1373 }
1374 }
1375
1376 let start_pos = link_match.start();
1377 let end_pos = link_match.end();
1378
1379 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
1381 let absolute_start_pos = line_start_byte + start_pos;
1382
1383 if ctx.is_in_code_span_byte(absolute_start_pos) {
1385 continue;
1386 }
1387
1388 if ctx.is_in_math_span(absolute_start_pos) {
1390 continue;
1391 }
1392
1393 if ctx.is_in_shortcode(absolute_start_pos) {
1398 continue;
1399 }
1400
1401 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1408 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1409 .or_else(|| {
1410 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1411 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1412 });
1413
1414 if let Some((caps, url_group)) = caps_and_url {
1415 let url = url_group.as_str().trim();
1416
1417 if url.is_empty() {
1419 continue;
1420 }
1421
1422 if url.starts_with('`') && url.ends_with('`') {
1426 continue;
1427 }
1428
1429 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1431 continue;
1432 }
1433
1434 if Self::is_absolute_path(url) {
1436 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1437 warnings.push(LintWarning {
1438 rule_name: Some(self.name().to_string()),
1439 line: link.line,
1440 column: byte_to_char_count(line, url_group.start()),
1441 end_line: link.line,
1442 end_column: byte_to_char_count(line, url_group.end()),
1443 message,
1444 severity: Severity::Warning,
1445 fix: None,
1446 });
1447 }
1448 continue;
1449 }
1450
1451 let full_url_for_compact = if let Some(frag) = caps.get(2) {
1455 format!("{url}{}", frag.as_str())
1456 } else {
1457 url.to_string()
1458 };
1459 if let Some(self_link) = self.self_referential_link(
1464 &full_url_for_compact,
1465 &base_path,
1466 &extra_search_paths,
1467 self_path.as_deref(),
1468 ctx.link_target_policy(),
1469 ) {
1470 let url_start = url_group.start();
1471 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1472 let fix_byte_start = line_start_byte + url_start;
1473 let fix_byte_end = line_start_byte + url_end;
1474 warnings.push(LintWarning {
1475 rule_name: Some(self.name().to_string()),
1476 line: link.line,
1477 column: byte_to_char_count(line, url_start),
1478 end_line: link.line,
1479 end_column: byte_to_char_count(line, url_end),
1480 message: Self::self_referential_message(&full_url_for_compact, &self_link),
1481 severity: Severity::Warning,
1482 fix: match &self_link {
1483 SelfReferentialLink::Fragment(fragment) => {
1484 Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1485 }
1486 SelfReferentialLink::WholeFile => None,
1487 },
1488 });
1489 continue;
1490 }
1491
1492 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1493 let url_start = url_group.start();
1494 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1495 let fix_byte_start = line_start_byte + url_start;
1496 let fix_byte_end = line_start_byte + url_end;
1497 warnings.push(LintWarning {
1498 rule_name: Some(self.name().to_string()),
1499 line: link.line,
1500 column: byte_to_char_count(line, url_start),
1501 end_line: link.line,
1502 end_column: byte_to_char_count(line, url_end),
1503 message: format!(
1504 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1505 ),
1506 severity: Severity::Warning,
1507 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1508 });
1509 }
1510
1511 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy())
1512 {
1513 continue;
1514 }
1515
1516 let url_start = url_group.start();
1520 let url_end = url_group.end();
1521
1522 warnings.push(LintWarning {
1523 rule_name: Some(self.name().to_string()),
1524 line: link.line,
1525 column: byte_to_char_count(line, url_start),
1526 end_line: link.line,
1527 end_column: byte_to_char_count(line, url_end),
1528 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1529 severity: Severity::Error,
1530 fix: None,
1531 });
1532 }
1533 }
1534 }
1535 }
1536
1537 for image in ctx.images() {
1539 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1541 continue;
1542 }
1543
1544 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1548 continue;
1549 }
1550
1551 if ctx.is_in_shortcode(image.byte_offset) {
1554 continue;
1555 }
1556
1557 let url = image.url.as_ref();
1558
1559 if url.is_empty() {
1561 continue;
1562 }
1563
1564 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1566 continue;
1567 }
1568
1569 if Self::is_absolute_path(url) {
1571 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1572 warnings.push(LintWarning {
1573 rule_name: Some(self.name().to_string()),
1574 line: image.line,
1575 column: image.start_col + 1,
1576 end_line: image.line,
1577 end_column: image.start_col + 1 + url.chars().count(),
1578 message,
1579 severity: Severity::Warning,
1580 fix: None,
1581 });
1582 }
1583 continue;
1584 }
1585
1586 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1588 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1591 let fix_byte_start = image.byte_offset + url_offset;
1592 let fix_byte_end = fix_byte_start + url.len();
1593 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1594 });
1595
1596 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1597 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1598 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1601 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1602 });
1603 warnings.push(LintWarning {
1604 rule_name: Some(self.name().to_string()),
1605 line: image.line,
1606 column: url_col,
1607 end_line: image.line,
1608 end_column: url_col + url.chars().count(),
1609 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1610 severity: Severity::Warning,
1611 fix,
1612 });
1613 }
1614
1615 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1616 continue;
1617 }
1618
1619 warnings.push(LintWarning {
1622 rule_name: Some(self.name().to_string()),
1623 line: image.line,
1624 column: image.start_col + 1,
1625 end_line: image.line,
1626 end_column: image.start_col + 1 + url.chars().count(),
1627 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1628 severity: Severity::Error,
1629 fix: None,
1630 });
1631 }
1632
1633 for ref_def in ctx.reference_definitions() {
1635 let url = &ref_def.url;
1636
1637 if url.is_empty() {
1639 continue;
1640 }
1641
1642 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1644 continue;
1645 }
1646
1647 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1651 let (line, col) = url_range
1652 .as_ref()
1653 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1654 let end_col = col + url.chars().count();
1655
1656 if Self::is_absolute_path(url) {
1658 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1659 warnings.push(LintWarning {
1660 rule_name: Some(self.name().to_string()),
1661 line,
1662 column: col,
1663 end_line: line,
1664 end_column: end_col,
1665 message,
1666 severity: Severity::Warning,
1667 fix: None,
1668 });
1669 }
1670 continue;
1671 }
1672
1673 if let Some(self_link) = self.self_referential_link(
1675 url,
1676 &base_path,
1677 &extra_search_paths,
1678 self_path.as_deref(),
1679 ctx.link_target_policy(),
1680 ) {
1681 warnings.push(LintWarning {
1682 rule_name: Some(self.name().to_string()),
1683 line,
1684 column: col,
1685 end_line: line,
1686 end_column: end_col,
1687 message: Self::self_referential_message(url, &self_link),
1688 severity: Severity::Warning,
1689 fix: match (&self_link, &url_range) {
1690 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1691 Some(Fix::new(range.clone(), fragment.clone()))
1692 }
1693 _ => None,
1694 },
1695 });
1696 continue;
1697 }
1698
1699 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1701 warnings.push(LintWarning {
1702 rule_name: Some(self.name().to_string()),
1703 line,
1704 column: col,
1705 end_line: line,
1706 end_column: end_col,
1707 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1708 severity: Severity::Warning,
1709 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1710 });
1711 }
1712
1713 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1714 continue;
1715 }
1716
1717 warnings.push(LintWarning {
1719 rule_name: Some(self.name().to_string()),
1720 line,
1721 column: col,
1722 end_line: line,
1723 end_column: end_col,
1724 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1725 severity: Severity::Error,
1726 fix: None,
1727 });
1728 }
1729
1730 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1731
1732 Ok(warnings)
1733 }
1734
1735 fn fix_capability(&self) -> FixCapability {
1736 if self.produces_fixes() {
1737 FixCapability::ConditionallyFixable
1738 } else {
1739 FixCapability::Unfixable
1740 }
1741 }
1742
1743 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1744 if !self.produces_fixes() {
1745 return Ok(ctx.content.to_string());
1746 }
1747
1748 let warnings = self.check(ctx)?;
1749 let warnings =
1750 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1751 let mut content = ctx.content.to_string();
1752
1753 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1755 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1756
1757 let mut last_applied_start: Option<usize> = None;
1763 for fix in fixes {
1764 if let Some(prev_start) = last_applied_start
1765 && fix.range.end > prev_start
1766 {
1767 continue;
1768 }
1769 if fix.range.end <= content.len() {
1770 content.replace_range(fix.range.clone(), &fix.replacement);
1771 last_applied_start = Some(fix.range.start);
1772 }
1773 }
1774
1775 Ok(content)
1776 }
1777
1778 fn as_any(&self) -> &dyn std::any::Any {
1779 self
1780 }
1781
1782 crate::impl_rule_config_sections!(MD057Config);
1783
1784 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1785 where
1786 Self: Sized,
1787 {
1788 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1789 Box::new(Self::from_config_struct(rule_config))
1793 }
1794
1795 fn cross_file_scope(&self) -> CrossFileScope {
1796 CrossFileScope::Workspace
1797 }
1798
1799 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1800 self.contribute_dependency_targets(ctx, index);
1801
1802 let links = extract_cross_file_links(ctx);
1805 for link in links.relative {
1806 index.add_cross_file_link(link);
1807 }
1808 for link in links.root_relative {
1811 index.add_root_relative_link(link);
1812 }
1813 }
1814
1815 fn cross_file_check(
1816 &self,
1817 _file_path: &Path,
1818 _file_index: &FileIndex,
1819 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1820 ) -> LintResult {
1821 Ok(Vec::new())
1831 }
1832}
1833
1834fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1839 let from_components: Vec<_> = from_dir.components().collect();
1840 let to_components: Vec<_> = to_path.components().collect();
1841
1842 let common_len = from_components
1844 .iter()
1845 .zip(to_components.iter())
1846 .take_while(|(a, b)| a == b)
1847 .count();
1848
1849 let mut result = PathBuf::new();
1850
1851 for _ in common_len..from_components.len() {
1853 result.push("..");
1854 }
1855
1856 for component in &to_components[common_len..] {
1858 result.push(component);
1859 }
1860
1861 result
1862}
1863
1864fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1870 let link_path = Path::new(raw_link_path);
1871
1872 let has_traversal = link_path
1874 .components()
1875 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1876
1877 if !has_traversal {
1878 return None;
1879 }
1880
1881 let combined = source_dir.join(link_path);
1883 let normalized_target = normalize_relative_path(&combined);
1884
1885 let normalized_source = normalize_relative_path(source_dir);
1887 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1888
1889 if shortest != link_path {
1891 let compact = shortest.to_string_lossy().to_string();
1892 if compact.is_empty() {
1894 return None;
1895 }
1896 Some(compact.replace('\\', "/"))
1898 } else {
1899 None
1900 }
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use super::*;
1906 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1907 use std::fs::File;
1908 use std::io::Write;
1909 use tempfile::tempdir;
1910
1911 #[test]
1912 fn test_strip_query_and_fragment() {
1913 assert_eq!(
1915 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1916 "file.png"
1917 );
1918 assert_eq!(
1919 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1920 "file.png"
1921 );
1922 assert_eq!(
1923 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1924 "file.png"
1925 );
1926
1927 assert_eq!(
1929 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1930 "file.md"
1931 );
1932 assert_eq!(
1933 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1934 "file.md"
1935 );
1936
1937 assert_eq!(
1939 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1940 "file.md"
1941 );
1942
1943 assert_eq!(
1945 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1946 "file.png"
1947 );
1948
1949 assert_eq!(
1951 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1952 "path/to/image.png"
1953 );
1954 assert_eq!(
1955 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1956 "path/to/image.png"
1957 );
1958
1959 assert_eq!(
1961 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1962 "file.md"
1963 );
1964 }
1965
1966 #[test]
1967 fn test_url_decode() {
1968 assert_eq!(
1970 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1971 "penguin with space.jpg"
1972 );
1973
1974 assert_eq!(
1976 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1977 "assets/my file name.png"
1978 );
1979
1980 assert_eq!(
1982 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1983 "hello world!.md"
1984 );
1985
1986 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1988
1989 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1991
1992 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1994
1995 assert_eq!(
1997 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1998 "normal-file.md"
1999 );
2000
2001 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
2003
2004 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
2006
2007 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
2009
2010 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
2012
2013 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
2015
2016 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
2018
2019 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
2021
2022 assert_eq!(
2024 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
2025 "path/to/file.md"
2026 );
2027
2028 assert_eq!(
2030 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
2031 "hello world/foo bar.md"
2032 );
2033
2034 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
2036
2037 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
2039 }
2040
2041 #[test]
2042 fn test_url_encoded_filenames() {
2043 let temp_dir = tempdir().unwrap();
2045 let base_path = temp_dir.path();
2046
2047 let file_with_spaces = base_path.join("penguin with space.jpg");
2049 File::create(&file_with_spaces)
2050 .unwrap()
2051 .write_all(b"image data")
2052 .unwrap();
2053
2054 let subdir = base_path.join("my images");
2056 std::fs::create_dir(&subdir).unwrap();
2057 let nested_file = subdir.join("photo 1.png");
2058 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2059
2060 let content = r#"
2062# Test Document with URL-Encoded Links
2063
2064
2065
2066
2067"#;
2068
2069 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2070
2071 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072 let result = rule.check(&ctx).unwrap();
2073
2074 assert_eq!(
2076 result.len(),
2077 1,
2078 "Should only warn about missing%20file.jpg. Got: {result:?}"
2079 );
2080 assert!(
2081 result[0].message.contains("missing%20file.jpg"),
2082 "Warning should mention the URL-encoded filename"
2083 );
2084 }
2085
2086 #[test]
2087 fn test_external_urls() {
2088 let rule = MD057ExistingRelativeLinks::new();
2089
2090 assert!(rule.is_external_url("https://example.com"));
2092 assert!(rule.is_external_url("http://example.com"));
2093 assert!(rule.is_external_url("ftp://example.com"));
2094 assert!(rule.is_external_url("www.example.com"));
2095 assert!(rule.is_external_url("example.com"));
2096
2097 assert!(rule.is_external_url("file:///path/to/file"));
2099 assert!(rule.is_external_url("smb://server/share"));
2100 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
2101 assert!(rule.is_external_url("mailto:user@example.com"));
2102 assert!(rule.is_external_url("tel:+1234567890"));
2103 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
2104 assert!(rule.is_external_url("javascript:void(0)"));
2105 assert!(rule.is_external_url("ssh://git@github.com/repo"));
2106 assert!(rule.is_external_url("git://github.com/repo.git"));
2107
2108 assert!(rule.is_external_url("user@example.com"));
2111 assert!(rule.is_external_url("steering@kubernetes.io"));
2112 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
2113 assert!(rule.is_external_url("user_name@sub.domain.com"));
2114 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2115
2116 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"));
2127 assert!(!rule.is_external_url("/blog/2024/release.html"));
2128 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
2129 assert!(!rule.is_external_url("/pkg/runtime"));
2130 assert!(!rule.is_external_url("/doc/go1compat"));
2131 assert!(!rule.is_external_url("/index.html"));
2132 assert!(!rule.is_external_url("/assets/logo.png"));
2133
2134 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2136 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2137 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2138 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2139 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2140
2141 assert!(rule.is_external_url("~/assets/image.png"));
2144 assert!(rule.is_external_url("~/components/Button.vue"));
2145 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
2149 assert!(rule.is_external_url("@images/photo.jpg"));
2150 assert!(rule.is_external_url("@assets/styles.css"));
2151
2152 assert!(!rule.is_external_url("./relative/path.md"));
2154 assert!(!rule.is_external_url("relative/path.md"));
2155 assert!(!rule.is_external_url("../parent/path.md"));
2156 }
2157
2158 #[test]
2159 fn test_dot_com_only_skips_bare_domains() {
2160 let rule = MD057ExistingRelativeLinks::new();
2161
2162 assert!(rule.is_external_url("example.com"));
2164 assert!(rule.is_external_url("sub.example.com"));
2165
2166 assert!(!rule.is_external_url("../../vendor.com"));
2170 assert!(!rule.is_external_url("./vendor.com"));
2171 assert!(!rule.is_external_url("docs/vendor.com"));
2172 }
2173
2174 #[test]
2175 fn test_framework_path_aliases() {
2176 let temp_dir = tempdir().unwrap();
2178 let base_path = temp_dir.path();
2179
2180 let content = r#"
2182# Framework Path Aliases
2183
2184
2185
2186
2187
2188[Link](@/pages/about.md)
2189
2190This is a [real missing link](missing.md) that should be flagged.
2191"#;
2192
2193 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2194
2195 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2196 let result = rule.check(&ctx).unwrap();
2197
2198 assert_eq!(
2200 result.len(),
2201 1,
2202 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2203 );
2204 assert!(
2205 result[0].message.contains("missing.md"),
2206 "Warning should be for missing.md"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_url_decode_security_path_traversal() {
2212 let temp_dir = tempdir().unwrap();
2215 let base_path = temp_dir.path();
2216
2217 let file_in_base = base_path.join("safe.md");
2219 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2220
2221 let content = r#"
2226[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2227[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2228[Safe link](safe.md)
2229"#;
2230
2231 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2232
2233 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235
2236 assert_eq!(
2239 result.len(),
2240 2,
2241 "Should have warnings for traversal attempts. Got: {result:?}"
2242 );
2243 }
2244
2245 #[test]
2246 fn test_url_encoded_utf8_filenames() {
2247 let temp_dir = tempdir().unwrap();
2249 let base_path = temp_dir.path();
2250
2251 let cafe_file = base_path.join("café.md");
2253 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2254
2255 let content = r#"
2256[Café link](caf%C3%A9.md)
2257[Missing unicode](r%C3%A9sum%C3%A9.md)
2258"#;
2259
2260 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2261
2262 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2263 let result = rule.check(&ctx).unwrap();
2264
2265 assert_eq!(
2267 result.len(),
2268 1,
2269 "Should only warn about missing résumé.md. Got: {result:?}"
2270 );
2271 assert!(
2272 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2273 "Warning should mention the URL-encoded filename"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_url_encoded_emoji_filenames() {
2279 let temp_dir = tempdir().unwrap();
2282 let base_path = temp_dir.path();
2283
2284 let emoji_dir = base_path.join("👤 Personal");
2286 std::fs::create_dir(&emoji_dir).unwrap();
2287
2288 let file_path = emoji_dir.join("TV Shows.md");
2290 File::create(&file_path)
2291 .unwrap()
2292 .write_all(b"# TV Shows\n\nContent here.")
2293 .unwrap();
2294
2295 let content = r#"
2298# Test Document
2299
2300[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2301[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2302"#;
2303
2304 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2305
2306 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2307 let result = rule.check(&ctx).unwrap();
2308
2309 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2311 assert!(
2312 result[0].message.contains("Missing.md"),
2313 "Warning should be for Missing.md, got: {}",
2314 result[0].message
2315 );
2316 }
2317
2318 #[test]
2319 fn test_no_warnings_without_base_path() {
2320 let rule = MD057ExistingRelativeLinks::new();
2321 let content = "[Link](missing.md)";
2322
2323 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2324 let result = rule.check(&ctx).unwrap();
2325 assert!(result.is_empty(), "Should have no warnings without base path");
2326 }
2327
2328 #[test]
2329 fn test_existing_and_missing_links() {
2330 let temp_dir = tempdir().unwrap();
2332 let base_path = temp_dir.path();
2333
2334 let exists_path = base_path.join("exists.md");
2336 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2337
2338 assert!(exists_path.exists(), "exists.md should exist for this test");
2340
2341 let content = r#"
2343# Test Document
2344
2345[Valid Link](exists.md)
2346[Invalid Link](missing.md)
2347[External Link](https://example.com)
2348[Media Link](image.jpg)
2349 "#;
2350
2351 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2353
2354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2356 let result = rule.check(&ctx).unwrap();
2357
2358 assert_eq!(result.len(), 2);
2360 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2361 assert!(messages.iter().any(|m| m.contains("missing.md")));
2362 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2363 }
2364
2365 #[test]
2366 fn test_angle_bracket_links() {
2367 let temp_dir = tempdir().unwrap();
2369 let base_path = temp_dir.path();
2370
2371 let exists_path = base_path.join("exists.md");
2373 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2374
2375 let content = r#"
2377# Test Document
2378
2379[Valid Link](<exists.md>)
2380[Invalid Link](<missing.md>)
2381[External Link](<https://example.com>)
2382 "#;
2383
2384 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2386
2387 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388 let result = rule.check(&ctx).unwrap();
2389
2390 assert_eq!(result.len(), 1, "Should have exactly one warning");
2392 assert!(
2393 result[0].message.contains("missing.md"),
2394 "Warning should mention missing.md"
2395 );
2396 }
2397
2398 #[test]
2399 fn test_angle_bracket_links_with_parens() {
2400 let temp_dir = tempdir().unwrap();
2402 let base_path = temp_dir.path();
2403
2404 let app_dir = base_path.join("app");
2406 std::fs::create_dir(&app_dir).unwrap();
2407 let upload_dir = app_dir.join("(upload)");
2408 std::fs::create_dir(&upload_dir).unwrap();
2409 let page_file = upload_dir.join("page.tsx");
2410 File::create(&page_file)
2411 .unwrap()
2412 .write_all(b"export default function Page() {}")
2413 .unwrap();
2414
2415 let content = r#"
2417# Test Document with Paths Containing Parens
2418
2419[Upload Page](<app/(upload)/page.tsx>)
2420[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2421[Missing](<app/(missing)/file.md>)
2422"#;
2423
2424 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2425
2426 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2427 let result = rule.check(&ctx).unwrap();
2428
2429 assert_eq!(
2431 result.len(),
2432 1,
2433 "Should have exactly one warning for missing file. Got: {result:?}"
2434 );
2435 assert!(
2436 result[0].message.contains("app/(missing)/file.md"),
2437 "Warning should mention app/(missing)/file.md"
2438 );
2439 }
2440
2441 #[test]
2442 fn test_all_file_types_checked() {
2443 let temp_dir = tempdir().unwrap();
2445 let base_path = temp_dir.path();
2446
2447 let content = r#"
2449[Image Link](image.jpg)
2450[Video Link](video.mp4)
2451[Markdown Link](document.md)
2452[PDF Link](file.pdf)
2453"#;
2454
2455 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2456
2457 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2458 let result = rule.check(&ctx).unwrap();
2459
2460 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2462 }
2463
2464 #[test]
2465 fn test_code_span_detection() {
2466 let rule = MD057ExistingRelativeLinks::new();
2467
2468 let temp_dir = tempdir().unwrap();
2470 let base_path = temp_dir.path();
2471
2472 let rule = rule.with_path(base_path);
2473
2474 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2476
2477 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2478 let result = rule.check(&ctx).unwrap();
2479
2480 assert_eq!(result.len(), 1, "Should only flag the real link");
2482 assert!(result[0].message.contains("nonexistent.md"));
2483 }
2484
2485 #[test]
2486 fn test_inline_code_spans() {
2487 let temp_dir = tempdir().unwrap();
2489 let base_path = temp_dir.path();
2490
2491 let content = r#"
2493# Test Document
2494
2495This is a normal link: [Link](missing.md)
2496
2497This is a code span with a link: `[Link](another-missing.md)`
2498
2499Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2500
2501 "#;
2502
2503 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2505
2506 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508 let result = rule.check(&ctx).unwrap();
2509
2510 assert_eq!(result.len(), 1, "Should have exactly one warning");
2512 assert!(
2513 result[0].message.contains("missing.md"),
2514 "Warning should be for missing.md"
2515 );
2516 assert!(
2517 !result.iter().any(|w| w.message.contains("another-missing.md")),
2518 "Should not warn about link in code span"
2519 );
2520 assert!(
2521 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2522 "Should not warn about link in inline code"
2523 );
2524 }
2525
2526 #[test]
2527 fn test_extensionless_link_resolution() {
2528 let temp_dir = tempdir().unwrap();
2530 let base_path = temp_dir.path();
2531
2532 let page_path = base_path.join("page.md");
2534 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2535
2536 let content = r#"
2538# Test Document
2539
2540[Link without extension](page)
2541[Link with extension](page.md)
2542[Missing link](nonexistent)
2543"#;
2544
2545 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2546
2547 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2548 let result = rule.check(&ctx).unwrap();
2549
2550 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2553 assert!(
2554 result[0].message.contains("nonexistent"),
2555 "Warning should be for 'nonexistent' not 'page'"
2556 );
2557 }
2558
2559 #[test]
2561 fn test_cross_file_scope() {
2562 let rule = MD057ExistingRelativeLinks::new();
2563 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2564 }
2565
2566 #[test]
2567 fn test_contribute_to_index_extracts_markdown_links() {
2568 let rule = MD057ExistingRelativeLinks::new();
2569 let content = r#"
2570# Document
2571
2572[Link to docs](./docs/guide.md)
2573[Link with fragment](./other.md#section)
2574[External link](https://example.com)
2575[Image link](image.png)
2576[Media file](video.mp4)
2577"#;
2578
2579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2580 let mut index = FileIndex::new();
2581 rule.contribute_to_index(&ctx, &mut index);
2582
2583 assert_eq!(index.cross_file_links.len(), 2);
2585
2586 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2588 assert_eq!(index.cross_file_links[0].fragment, "");
2589
2590 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2592 assert_eq!(index.cross_file_links[1].fragment, "section");
2593 }
2594
2595 #[test]
2596 fn test_contribute_to_index_skips_external_and_anchors() {
2597 let rule = MD057ExistingRelativeLinks::new();
2598 let content = r#"
2599# Document
2600
2601[External](https://example.com)
2602[Another external](http://example.org)
2603[Fragment only](#section)
2604[FTP link](ftp://files.example.com)
2605[Mail link](mailto:test@example.com)
2606[WWW link](www.example.com)
2607"#;
2608
2609 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2610 let mut index = FileIndex::new();
2611 rule.contribute_to_index(&ctx, &mut index);
2612
2613 assert_eq!(index.cross_file_links.len(), 0);
2615 }
2616
2617 #[test]
2618 fn test_cross_file_check_valid_link() {
2619 use crate::workspace_index::WorkspaceIndex;
2620
2621 let rule = MD057ExistingRelativeLinks::new();
2622
2623 let mut workspace_index = WorkspaceIndex::new();
2625 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2626
2627 let mut file_index = FileIndex::new();
2629 file_index.add_cross_file_link(CrossFileLinkIndex {
2630 target_path: "guide.md".to_string(),
2631 fragment: "".to_string(),
2632 line: 5,
2633 column: 1,
2634 origin: LinkOrigin::Body,
2635 });
2636
2637 let warnings = rule
2639 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2640 .unwrap();
2641
2642 assert!(warnings.is_empty());
2644 }
2645
2646 #[test]
2647 fn test_cross_file_check_missing_link() {
2648 use crate::workspace_index::WorkspaceIndex;
2651
2652 let rule = MD057ExistingRelativeLinks::new();
2653 let workspace_index = WorkspaceIndex::new();
2654
2655 let mut file_index = FileIndex::new();
2656 file_index.add_cross_file_link(CrossFileLinkIndex {
2657 target_path: "missing.md".to_string(),
2658 fragment: "".to_string(),
2659 line: 5,
2660 column: 1,
2661 origin: LinkOrigin::Body,
2662 });
2663
2664 let warnings = rule
2665 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2666 .unwrap();
2667
2668 assert!(
2670 warnings.is_empty(),
2671 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2672 );
2673 }
2674
2675 #[test]
2676 fn test_cross_file_check_parent_path() {
2677 use crate::workspace_index::WorkspaceIndex;
2678
2679 let rule = MD057ExistingRelativeLinks::new();
2680
2681 let mut workspace_index = WorkspaceIndex::new();
2683 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2684
2685 let mut file_index = FileIndex::new();
2687 file_index.add_cross_file_link(CrossFileLinkIndex {
2688 target_path: "../readme.md".to_string(),
2689 fragment: "".to_string(),
2690 line: 5,
2691 column: 1,
2692 origin: LinkOrigin::Body,
2693 });
2694
2695 let warnings = rule
2697 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2698 .unwrap();
2699
2700 assert!(warnings.is_empty());
2702 }
2703
2704 #[test]
2705 fn test_cross_file_check_html_link_with_md_source() {
2706 use crate::workspace_index::WorkspaceIndex;
2709
2710 let rule = MD057ExistingRelativeLinks::new();
2711
2712 let mut workspace_index = WorkspaceIndex::new();
2714 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2715
2716 let mut file_index = FileIndex::new();
2718 file_index.add_cross_file_link(CrossFileLinkIndex {
2719 target_path: "guide.html".to_string(),
2720 fragment: "section".to_string(),
2721 line: 10,
2722 column: 5,
2723 origin: LinkOrigin::Body,
2724 });
2725
2726 let warnings = rule
2728 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2729 .unwrap();
2730
2731 assert!(
2733 warnings.is_empty(),
2734 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_cross_file_check_html_link_without_source() {
2740 use crate::workspace_index::WorkspaceIndex;
2744
2745 let rule = MD057ExistingRelativeLinks::new();
2746 let workspace_index = WorkspaceIndex::new();
2747
2748 let mut file_index = FileIndex::new();
2749 file_index.add_cross_file_link(CrossFileLinkIndex {
2750 target_path: "missing.html".to_string(),
2751 fragment: "".to_string(),
2752 line: 10,
2753 column: 5,
2754 origin: LinkOrigin::Body,
2755 });
2756
2757 let warnings = rule
2758 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2759 .unwrap();
2760
2761 assert!(
2763 warnings.is_empty(),
2764 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2765 );
2766 }
2767
2768 #[test]
2769 fn test_normalize_path_function() {
2770 assert_eq!(
2772 normalize_relative_path(Path::new("docs/guide.md")),
2773 PathBuf::from("docs/guide.md")
2774 );
2775
2776 assert_eq!(
2778 normalize_relative_path(Path::new("./docs/guide.md")),
2779 PathBuf::from("docs/guide.md")
2780 );
2781
2782 assert_eq!(
2784 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2785 PathBuf::from("docs/guide.md")
2786 );
2787
2788 assert_eq!(
2790 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2791 PathBuf::from("a/d.md")
2792 );
2793 }
2794
2795 #[test]
2796 fn test_html_link_with_md_source() {
2797 let temp_dir = tempdir().unwrap();
2799 let base_path = temp_dir.path();
2800
2801 let md_file = base_path.join("guide.md");
2803 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2804
2805 let content = r#"
2806[Read the guide](guide.html)
2807[Also here](getting-started.html)
2808"#;
2809
2810 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2811 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2812 let result = rule.check(&ctx).unwrap();
2813
2814 assert_eq!(
2816 result.len(),
2817 1,
2818 "Should only warn about missing source. Got: {result:?}"
2819 );
2820 assert!(result[0].message.contains("getting-started.html"));
2821 }
2822
2823 #[test]
2824 fn test_htm_link_with_md_source() {
2825 let temp_dir = tempdir().unwrap();
2827 let base_path = temp_dir.path();
2828
2829 let md_file = base_path.join("page.md");
2830 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2831
2832 let content = "[Page](page.htm)";
2833
2834 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2835 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2836 let result = rule.check(&ctx).unwrap();
2837
2838 assert!(
2839 result.is_empty(),
2840 "Should not warn when .md source exists for .htm link"
2841 );
2842 }
2843
2844 #[test]
2845 fn test_html_link_finds_various_markdown_extensions() {
2846 let temp_dir = tempdir().unwrap();
2848 let base_path = temp_dir.path();
2849
2850 File::create(base_path.join("doc.md")).unwrap();
2851 File::create(base_path.join("tutorial.mdx")).unwrap();
2852 File::create(base_path.join("guide.markdown")).unwrap();
2853
2854 let content = r#"
2855[Doc](doc.html)
2856[Tutorial](tutorial.html)
2857[Guide](guide.html)
2858"#;
2859
2860 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2861 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2862 let result = rule.check(&ctx).unwrap();
2863
2864 assert!(
2865 result.is_empty(),
2866 "Should find all markdown variants as source files. Got: {result:?}"
2867 );
2868 }
2869
2870 #[test]
2871 fn test_html_link_in_subdirectory() {
2872 let temp_dir = tempdir().unwrap();
2874 let base_path = temp_dir.path();
2875
2876 let docs_dir = base_path.join("docs");
2877 std::fs::create_dir(&docs_dir).unwrap();
2878 File::create(docs_dir.join("guide.md"))
2879 .unwrap()
2880 .write_all(b"# Guide")
2881 .unwrap();
2882
2883 let content = "[Guide](docs/guide.html)";
2884
2885 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2886 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2887 let result = rule.check(&ctx).unwrap();
2888
2889 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2890 }
2891
2892 #[test]
2893 fn test_absolute_path_skipped_in_check() {
2894 let temp_dir = tempdir().unwrap();
2897 let base_path = temp_dir.path();
2898
2899 let content = r#"
2900# Test Document
2901
2902[Go Runtime](/pkg/runtime)
2903[Go Runtime with Fragment](/pkg/runtime#section)
2904[API Docs](/api/v1/users)
2905[Blog Post](/blog/2024/release.html)
2906[React Hook](/react/hooks/use-state.html)
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 let result = rule.check(&ctx).unwrap();
2912
2913 assert!(
2915 result.is_empty(),
2916 "Absolute paths should be skipped. Got warnings: {result:?}"
2917 );
2918 }
2919
2920 #[test]
2921 fn test_absolute_path_skipped_in_cross_file_check() {
2922 use crate::workspace_index::WorkspaceIndex;
2924
2925 let rule = MD057ExistingRelativeLinks::new();
2926
2927 let workspace_index = WorkspaceIndex::new();
2929
2930 let mut file_index = FileIndex::new();
2932 file_index.add_cross_file_link(CrossFileLinkIndex {
2933 target_path: "/pkg/runtime.md".to_string(),
2934 fragment: "".to_string(),
2935 line: 5,
2936 column: 1,
2937 origin: LinkOrigin::Body,
2938 });
2939 file_index.add_cross_file_link(CrossFileLinkIndex {
2940 target_path: "/api/v1/users.md".to_string(),
2941 fragment: "section".to_string(),
2942 line: 10,
2943 column: 1,
2944 origin: LinkOrigin::Body,
2945 });
2946
2947 let warnings = rule
2949 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2950 .unwrap();
2951
2952 assert!(
2954 warnings.is_empty(),
2955 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2956 );
2957 }
2958
2959 #[test]
2960 fn test_protocol_relative_url_not_skipped() {
2961 let temp_dir = tempdir().unwrap();
2964 let base_path = temp_dir.path();
2965
2966 let content = r#"
2967# Test Document
2968
2969[External](//example.com/page)
2970[Another](//cdn.example.com/asset.js)
2971"#;
2972
2973 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2974 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2975 let result = rule.check(&ctx).unwrap();
2976
2977 assert!(
2979 result.is_empty(),
2980 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_email_addresses_skipped() {
2986 let temp_dir = tempdir().unwrap();
2989 let base_path = temp_dir.path();
2990
2991 let content = r#"
2992# Test Document
2993
2994[Contact](user@example.com)
2995[Steering](steering@kubernetes.io)
2996[Support](john.doe+filter@company.co.uk)
2997[User](user_name@sub.domain.com)
2998"#;
2999
3000 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3001 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3002 let result = rule.check(&ctx).unwrap();
3003
3004 assert!(
3006 result.is_empty(),
3007 "Email addresses should be skipped. Got warnings: {result:?}"
3008 );
3009 }
3010
3011 #[test]
3012 fn test_email_addresses_vs_file_paths() {
3013 let temp_dir = tempdir().unwrap();
3016 let base_path = temp_dir.path();
3017
3018 let content = r#"
3019# Test Document
3020
3021[Email](user@example.com) <!-- Should be skipped (email) -->
3022[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
3023[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
3024"#;
3025
3026 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3027 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3028 let result = rule.check(&ctx).unwrap();
3029
3030 assert!(
3032 result.is_empty(),
3033 "All email addresses should be skipped. Got: {result:?}"
3034 );
3035 }
3036
3037 #[test]
3038 fn test_diagnostic_position_accuracy() {
3039 let temp_dir = tempdir().unwrap();
3041 let base_path = temp_dir.path();
3042
3043 let content = "prefix [text](missing.md) suffix";
3046 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3050 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3051 let result = rule.check(&ctx).unwrap();
3052
3053 assert_eq!(result.len(), 1, "Should have exactly one warning");
3054 assert_eq!(result[0].line, 1, "Should be on line 1");
3055 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3056 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3057 }
3058
3059 #[test]
3060 fn test_diagnostic_position_non_ascii_link() {
3061 let temp_dir = tempdir().unwrap();
3064 let base_path = temp_dir.path();
3065
3066 let content = "你好你好[你好](not-exist.md) bar";
3070
3071 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3072 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3073 let result = rule.check(&ctx).unwrap();
3074
3075 assert_eq!(result.len(), 1, "Should have exactly one warning");
3076 assert_eq!(result[0].line, 1, "Should be on line 1");
3077 assert_eq!(
3078 result[0].column, 10,
3079 "Column must be a character offset, not a byte offset"
3080 );
3081 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3082 }
3083
3084 #[test]
3085 fn test_diagnostic_position_angle_brackets() {
3086 let temp_dir = tempdir().unwrap();
3088 let base_path = temp_dir.path();
3089
3090 let content = "[link](<missing.md>)";
3093 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3096 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3097 let result = rule.check(&ctx).unwrap();
3098
3099 assert_eq!(result.len(), 1, "Should have exactly one warning");
3100 assert_eq!(result[0].line, 1, "Should be on line 1");
3101 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3102 }
3103
3104 #[test]
3105 fn test_diagnostic_position_multiline() {
3106 let temp_dir = tempdir().unwrap();
3108 let base_path = temp_dir.path();
3109
3110 let content = r#"# Title
3111Some text on line 2
3112[link on line 3](missing1.md)
3113More text
3114[link on line 5](missing2.md)"#;
3115
3116 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3117 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3118 let result = rule.check(&ctx).unwrap();
3119
3120 assert_eq!(result.len(), 2, "Should have two warnings");
3121
3122 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3124 assert!(result[0].message.contains("missing1.md"));
3125
3126 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3128 assert!(result[1].message.contains("missing2.md"));
3129 }
3130
3131 #[test]
3132 fn test_diagnostic_position_with_spaces() {
3133 let temp_dir = tempdir().unwrap();
3135 let base_path = temp_dir.path();
3136
3137 let content = "[link]( missing.md )";
3138 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3143 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3144 let result = rule.check(&ctx).unwrap();
3145
3146 assert_eq!(result.len(), 1, "Should have exactly one warning");
3147 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3149 }
3150
3151 #[test]
3152 fn test_diagnostic_position_image() {
3153 let temp_dir = tempdir().unwrap();
3155 let base_path = temp_dir.path();
3156
3157 let content = "";
3158
3159 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3160 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3161 let result = rule.check(&ctx).unwrap();
3162
3163 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3164 assert_eq!(result[0].line, 1);
3165 assert!(result[0].column > 0, "Should have valid column position");
3167 assert!(result[0].message.contains("missing.jpg"));
3168 }
3169
3170 #[test]
3171 fn test_diagnostic_position_non_ascii_image() {
3172 let temp_dir = tempdir().unwrap();
3174 let base_path = temp_dir.path();
3175
3176 let content = "你好你好";
3179
3180 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3181 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3182 let result = rule.check(&ctx).unwrap();
3183
3184 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3185 assert_eq!(result[0].line, 1, "Should be on line 1");
3186 assert_eq!(
3187 result[0].column, 5,
3188 "Column must be a character offset, not a byte offset"
3189 );
3190 assert!(result[0].message.contains("not-exist.png"));
3191 }
3192
3193 #[test]
3194 fn test_diagnostic_position_non_ascii_reference_def() {
3195 let temp_dir = tempdir().unwrap();
3199 let base_path = temp_dir.path();
3200
3201 let content = "[你好]: not-exist.md";
3204
3205 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3206 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3207 let result = rule.check(&ctx).unwrap();
3208
3209 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3210 assert_eq!(result[0].line, 1, "Should be on line 1");
3211 assert_eq!(
3212 result[0].column, 7,
3213 "Column must be a character offset, not a byte offset"
3214 );
3215 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3216 }
3217
3218 #[test]
3219 fn test_wikilinks_skipped() {
3220 let temp_dir = tempdir().unwrap();
3223 let base_path = temp_dir.path();
3224
3225 let content = r#"# Test Document
3226
3227[[Microsoft#Windows OS]]
3228[[SomePage]]
3229[[Page With Spaces]]
3230[[path/to/page#section]]
3231[[page|Display Text]]
3232
3233This is a [real missing link](missing.md) that should be flagged.
3234"#;
3235
3236 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 missing.md, not wikilinks. Got: {result:?}"
3245 );
3246 assert!(
3247 result[0].message.contains("missing.md"),
3248 "Warning should be for missing.md, not wikilinks"
3249 );
3250 }
3251
3252 #[test]
3253 fn test_wiki_embeds_skipped() {
3254 let temp_dir = tempdir().unwrap();
3258 let base_path = temp_dir.path();
3259
3260 let content = r#"# Test Document
3261
3262![[diagram.png]]
3263![[subfolder/diagram.png]]
3264![[diagram.png|300]]
3265![[Some Note]]
3266
3267This is a [real missing link](missing.md) that should be flagged.
3268"#;
3269
3270 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3271 for flavor in [
3272 crate::config::MarkdownFlavor::Obsidian,
3273 crate::config::MarkdownFlavor::Standard,
3274 ] {
3275 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3276 let result = rule.check(&ctx).unwrap();
3277
3278 assert_eq!(
3279 result.len(),
3280 1,
3281 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3282 );
3283 assert!(result[0].message.contains("missing.md"));
3284 }
3285 }
3286
3287 #[test]
3288 fn test_wikilinks_not_added_to_index() {
3289 let temp_dir = tempdir().unwrap();
3291 let base_path = temp_dir.path();
3292
3293 let content = r#"# Test Document
3294
3295[[Microsoft#Windows OS]]
3296[[SomePage#section]]
3297[Regular Link](other.md)
3298"#;
3299
3300 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3301 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3302
3303 let mut file_index = FileIndex::new();
3304 rule.contribute_to_index(&ctx, &mut file_index);
3305
3306 let cross_file_links = &file_index.cross_file_links;
3309 assert_eq!(
3310 cross_file_links.len(),
3311 1,
3312 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3313 );
3314 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3315 }
3316
3317 #[test]
3318 fn test_reference_definition_missing_file() {
3319 let temp_dir = tempdir().unwrap();
3321 let base_path = temp_dir.path();
3322
3323 let content = r#"# Test Document
3324
3325[test]: ./missing.md
3326[example]: ./nonexistent.html
3327
3328Use [test] and [example] here.
3329"#;
3330
3331 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3332 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3333 let result = rule.check(&ctx).unwrap();
3334
3335 assert_eq!(
3337 result.len(),
3338 2,
3339 "Should have warnings for missing reference definition targets. Got: {result:?}"
3340 );
3341 assert!(
3342 result.iter().any(|w| w.message.contains("missing.md")),
3343 "Should warn about missing.md"
3344 );
3345 assert!(
3346 result.iter().any(|w| w.message.contains("nonexistent.html")),
3347 "Should warn about nonexistent.html"
3348 );
3349 }
3350
3351 #[test]
3352 fn test_reference_definition_existing_file() {
3353 let temp_dir = tempdir().unwrap();
3355 let base_path = temp_dir.path();
3356
3357 let exists_path = base_path.join("exists.md");
3359 File::create(&exists_path)
3360 .unwrap()
3361 .write_all(b"# Existing file")
3362 .unwrap();
3363
3364 let content = r#"# Test Document
3365
3366[test]: ./exists.md
3367
3368Use [test] here.
3369"#;
3370
3371 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3372 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3373 let result = rule.check(&ctx).unwrap();
3374
3375 assert!(
3377 result.is_empty(),
3378 "Should not warn about existing file. Got: {result:?}"
3379 );
3380 }
3381
3382 #[test]
3383 fn test_reference_definition_external_url_skipped() {
3384 let temp_dir = tempdir().unwrap();
3386 let base_path = temp_dir.path();
3387
3388 let content = r#"# Test Document
3389
3390[google]: https://google.com
3391[example]: http://example.org
3392[mail]: mailto:test@example.com
3393[ftp]: ftp://files.example.com
3394[local]: ./missing.md
3395
3396Use [google], [example], [mail], [ftp], [local] here.
3397"#;
3398
3399 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3400 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3401 let result = rule.check(&ctx).unwrap();
3402
3403 assert_eq!(
3405 result.len(),
3406 1,
3407 "Should only warn about local missing file. Got: {result:?}"
3408 );
3409 assert!(
3410 result[0].message.contains("missing.md"),
3411 "Warning should be for missing.md"
3412 );
3413 }
3414
3415 #[test]
3416 fn test_reference_definition_fragment_only_skipped() {
3417 let temp_dir = tempdir().unwrap();
3419 let base_path = temp_dir.path();
3420
3421 let content = r#"# Test Document
3422
3423[section]: #my-section
3424
3425Use [section] here.
3426"#;
3427
3428 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3429 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3430 let result = rule.check(&ctx).unwrap();
3431
3432 assert!(
3434 result.is_empty(),
3435 "Should not warn about fragment-only reference. Got: {result:?}"
3436 );
3437 }
3438
3439 #[test]
3440 fn test_reference_definition_column_position() {
3441 let temp_dir = tempdir().unwrap();
3443 let base_path = temp_dir.path();
3444
3445 let content = "[ref]: ./missing.md";
3448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3452 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3453 let result = rule.check(&ctx).unwrap();
3454
3455 assert_eq!(result.len(), 1, "Should have exactly one warning");
3456 assert_eq!(result[0].line, 1, "Should be on line 1");
3457 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3458 }
3459
3460 #[test]
3461 fn test_reference_definition_html_with_md_source() {
3462 let temp_dir = tempdir().unwrap();
3464 let base_path = temp_dir.path();
3465
3466 let md_file = base_path.join("guide.md");
3468 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3469
3470 let content = r#"# Test Document
3471
3472[guide]: ./guide.html
3473[missing]: ./missing.html
3474
3475Use [guide] and [missing] here.
3476"#;
3477
3478 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3479 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3480 let result = rule.check(&ctx).unwrap();
3481
3482 assert_eq!(
3484 result.len(),
3485 1,
3486 "Should only warn about missing source. Got: {result:?}"
3487 );
3488 assert!(result[0].message.contains("missing.html"));
3489 }
3490
3491 #[test]
3492 fn test_reference_definition_url_encoded() {
3493 let temp_dir = tempdir().unwrap();
3495 let base_path = temp_dir.path();
3496
3497 let file_with_spaces = base_path.join("file with spaces.md");
3499 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3500
3501 let content = r#"# Test Document
3502
3503[spaces]: ./file%20with%20spaces.md
3504[missing]: ./missing%20file.md
3505
3506Use [spaces] and [missing] here.
3507"#;
3508
3509 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3510 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3511 let result = rule.check(&ctx).unwrap();
3512
3513 assert_eq!(
3515 result.len(),
3516 1,
3517 "Should only warn about missing URL-encoded file. Got: {result:?}"
3518 );
3519 assert!(result[0].message.contains("missing%20file.md"));
3520 }
3521
3522 #[test]
3523 fn test_inline_and_reference_both_checked() {
3524 let temp_dir = tempdir().unwrap();
3526 let base_path = temp_dir.path();
3527
3528 let content = r#"# Test Document
3529
3530[inline link](./inline-missing.md)
3531[ref]: ./ref-missing.md
3532
3533Use [ref] here.
3534"#;
3535
3536 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3537 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3538 let result = rule.check(&ctx).unwrap();
3539
3540 assert_eq!(
3542 result.len(),
3543 2,
3544 "Should warn about both inline and reference links. Got: {result:?}"
3545 );
3546 assert!(
3547 result.iter().any(|w| w.message.contains("inline-missing.md")),
3548 "Should warn about inline-missing.md"
3549 );
3550 assert!(
3551 result.iter().any(|w| w.message.contains("ref-missing.md")),
3552 "Should warn about ref-missing.md"
3553 );
3554 }
3555
3556 #[test]
3557 fn test_footnote_definitions_not_flagged() {
3558 let rule = MD057ExistingRelativeLinks::default();
3561
3562 let content = r#"# Title
3563
3564A footnote[^1].
3565
3566[^1]: [link](https://www.google.com).
3567"#;
3568
3569 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3570 let result = rule.check(&ctx).unwrap();
3571
3572 assert!(
3573 result.is_empty(),
3574 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3575 );
3576 }
3577
3578 #[test]
3579 fn test_footnote_with_relative_link_inside() {
3580 let rule = MD057ExistingRelativeLinks::default();
3583
3584 let content = r#"# Title
3585
3586See the footnote[^1].
3587
3588[^1]: Check out [this file](./existing.md) for more info.
3589[^2]: Also see [missing](./does-not-exist.md).
3590"#;
3591
3592 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3593 let result = rule.check(&ctx).unwrap();
3594
3595 for warning in &result {
3600 assert!(
3601 !warning.message.contains("[this file]"),
3602 "Footnote content should not be treated as URL: {warning:?}"
3603 );
3604 assert!(
3605 !warning.message.contains("[missing]"),
3606 "Footnote content should not be treated as URL: {warning:?}"
3607 );
3608 }
3609 }
3610
3611 #[test]
3612 fn test_mixed_footnotes_and_reference_definitions() {
3613 let temp_dir = tempdir().unwrap();
3615 let base_path = temp_dir.path();
3616
3617 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3618
3619 let content = r#"# Title
3620
3621A footnote[^1] and a [ref link][myref].
3622
3623[^1]: This is a footnote with [link](https://example.com).
3624
3625[myref]: ./missing-file.md "This should be checked"
3626"#;
3627
3628 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3629 let result = rule.check(&ctx).unwrap();
3630
3631 assert_eq!(
3633 result.len(),
3634 1,
3635 "Should only warn about the regular reference definition. Got: {result:?}"
3636 );
3637 assert!(
3638 result[0].message.contains("missing-file.md"),
3639 "Should warn about missing-file.md in reference definition"
3640 );
3641 }
3642
3643 #[test]
3644 fn test_absolute_links_ignore_by_default() {
3645 let temp_dir = tempdir().unwrap();
3647 let base_path = temp_dir.path();
3648
3649 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3650
3651 let content = r#"# Links
3652
3653[API docs](/api/v1/users)
3654[Blog post](/blog/2024/release.html)
3655
3656
3657[ref]: /docs/reference.md
3658"#;
3659
3660 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3661 let result = rule.check(&ctx).unwrap();
3662
3663 assert!(
3665 result.is_empty(),
3666 "Absolute links should be ignored by default. Got: {result:?}"
3667 );
3668 }
3669
3670 #[test]
3671 fn test_absolute_links_warn_config() {
3672 let temp_dir = tempdir().unwrap();
3674 let base_path = temp_dir.path();
3675
3676 let config = MD057Config {
3677 absolute_links: AbsoluteLinksOption::Warn,
3678 ..Default::default()
3679 };
3680 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3681
3682 let content = r#"# Links
3683
3684[API docs](/api/v1/users)
3685[Blog post](/blog/2024/release.html)
3686"#;
3687
3688 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3689 let result = rule.check(&ctx).unwrap();
3690
3691 assert_eq!(
3693 result.len(),
3694 2,
3695 "Should warn about both absolute links. Got: {result:?}"
3696 );
3697 assert!(
3698 result[0].message.contains("cannot be validated locally"),
3699 "Warning should explain why: {}",
3700 result[0].message
3701 );
3702 assert!(
3703 result[0].message.contains("/api/v1/users"),
3704 "Warning should include the link path"
3705 );
3706 }
3707
3708 #[test]
3709 fn test_absolute_links_warn_images() {
3710 let temp_dir = tempdir().unwrap();
3712 let base_path = temp_dir.path();
3713
3714 let config = MD057Config {
3715 absolute_links: AbsoluteLinksOption::Warn,
3716 ..Default::default()
3717 };
3718 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3719
3720 let content = r#"# Images
3721
3722
3723"#;
3724
3725 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3726 let result = rule.check(&ctx).unwrap();
3727
3728 assert_eq!(
3729 result.len(),
3730 1,
3731 "Should warn about absolute image path. Got: {result:?}"
3732 );
3733 assert!(
3734 result[0].message.contains("/assets/logo.png"),
3735 "Warning should include the image path"
3736 );
3737 }
3738
3739 #[test]
3740 fn test_absolute_links_warn_reference_definitions() {
3741 let temp_dir = tempdir().unwrap();
3743 let base_path = temp_dir.path();
3744
3745 let config = MD057Config {
3746 absolute_links: AbsoluteLinksOption::Warn,
3747 ..Default::default()
3748 };
3749 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3750
3751 let content = r#"# Reference
3752
3753See the [docs][ref].
3754
3755[ref]: /docs/reference.md
3756"#;
3757
3758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3759 let result = rule.check(&ctx).unwrap();
3760
3761 assert_eq!(
3762 result.len(),
3763 1,
3764 "Should warn about absolute reference definition. Got: {result:?}"
3765 );
3766 assert!(
3767 result[0].message.contains("/docs/reference.md"),
3768 "Warning should include the reference path"
3769 );
3770 }
3771
3772 #[test]
3773 fn test_search_paths_inline_link() {
3774 let temp_dir = tempdir().unwrap();
3775 let base_path = temp_dir.path();
3776
3777 let assets_dir = base_path.join("assets");
3779 std::fs::create_dir_all(&assets_dir).unwrap();
3780 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3781
3782 let config = MD057Config {
3783 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3784 ..Default::default()
3785 };
3786 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3787
3788 let content = "# Test\n\n[Photo](photo.png)\n";
3789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3790 let result = rule.check(&ctx).unwrap();
3791
3792 assert!(
3793 result.is_empty(),
3794 "Should find photo.png via search-paths. Got: {result:?}"
3795 );
3796 }
3797
3798 #[test]
3799 fn test_search_paths_image() {
3800 let temp_dir = tempdir().unwrap();
3801 let base_path = temp_dir.path();
3802
3803 let assets_dir = base_path.join("attachments");
3804 std::fs::create_dir_all(&assets_dir).unwrap();
3805 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3806
3807 let config = MD057Config {
3808 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3809 ..Default::default()
3810 };
3811 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3812
3813 let content = "# Test\n\n\n";
3814 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3815 let result = rule.check(&ctx).unwrap();
3816
3817 assert!(
3818 result.is_empty(),
3819 "Should find diagram.svg via search-paths. Got: {result:?}"
3820 );
3821 }
3822
3823 #[test]
3824 fn test_search_paths_reference_definition() {
3825 let temp_dir = tempdir().unwrap();
3826 let base_path = temp_dir.path();
3827
3828 let assets_dir = base_path.join("images");
3829 std::fs::create_dir_all(&assets_dir).unwrap();
3830 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3831
3832 let config = MD057Config {
3833 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3834 ..Default::default()
3835 };
3836 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3837
3838 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3839 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3840 let result = rule.check(&ctx).unwrap();
3841
3842 assert!(
3843 result.is_empty(),
3844 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3845 );
3846 }
3847
3848 #[test]
3849 fn test_search_paths_still_warns_when_truly_missing() {
3850 let temp_dir = tempdir().unwrap();
3851 let base_path = temp_dir.path();
3852
3853 let assets_dir = base_path.join("assets");
3854 std::fs::create_dir_all(&assets_dir).unwrap();
3855
3856 let config = MD057Config {
3857 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3858 ..Default::default()
3859 };
3860 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3861
3862 let content = "# Test\n\n\n";
3863 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3864 let result = rule.check(&ctx).unwrap();
3865
3866 assert_eq!(
3867 result.len(),
3868 1,
3869 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3870 );
3871 }
3872
3873 #[test]
3874 fn test_search_paths_nonexistent_directory() {
3875 let temp_dir = tempdir().unwrap();
3876 let base_path = temp_dir.path();
3877
3878 let config = MD057Config {
3879 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3880 ..Default::default()
3881 };
3882 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3883
3884 let content = "# Test\n\n\n";
3885 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3886 let result = rule.check(&ctx).unwrap();
3887
3888 assert_eq!(
3889 result.len(),
3890 1,
3891 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3892 );
3893 }
3894
3895 #[test]
3896 fn test_obsidian_attachment_folder_named() {
3897 let temp_dir = tempdir().unwrap();
3898 let vault = temp_dir.path().join("vault");
3899 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3900 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3901 std::fs::create_dir_all(vault.join("notes")).unwrap();
3902
3903 std::fs::write(
3904 vault.join(".obsidian/app.json"),
3905 r#"{"attachmentFolderPath": "Attachments"}"#,
3906 )
3907 .unwrap();
3908 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3909
3910 let notes_dir = vault.join("notes");
3911 let source_file = notes_dir.join("test.md");
3912 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3913
3914 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3915
3916 let content = "# Test\n\n\n";
3917 let ctx =
3918 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3919 let result = rule.check(&ctx).unwrap();
3920
3921 assert!(
3922 result.is_empty(),
3923 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3924 );
3925 }
3926
3927 #[test]
3928 fn test_obsidian_attachment_same_folder_as_file() {
3929 let temp_dir = tempdir().unwrap();
3930 let vault = temp_dir.path().join("vault-rf");
3931 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3932 std::fs::create_dir_all(vault.join("notes")).unwrap();
3933
3934 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3935
3936 let notes_dir = vault.join("notes");
3938 let source_file = notes_dir.join("test.md");
3939 std::fs::write(&source_file, "placeholder").unwrap();
3940 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3941
3942 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3943
3944 let content = "# Test\n\n\n";
3945 let ctx =
3946 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3947 let result = rule.check(&ctx).unwrap();
3948
3949 assert!(
3950 result.is_empty(),
3951 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3952 );
3953 }
3954
3955 #[test]
3956 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3957 let temp_dir = tempdir().unwrap();
3958 let vault = temp_dir.path().join("vault-nf");
3959 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3960 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3961 std::fs::create_dir_all(vault.join("notes")).unwrap();
3962
3963 std::fs::write(
3964 vault.join(".obsidian/app.json"),
3965 r#"{"attachmentFolderPath": "Attachments"}"#,
3966 )
3967 .unwrap();
3968 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3969
3970 let notes_dir = vault.join("notes");
3971 let source_file = notes_dir.join("test.md");
3972 std::fs::write(&source_file, "placeholder").unwrap();
3973
3974 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3975
3976 let content = "# Test\n\n\n";
3977 let ctx =
3979 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3980 let result = rule.check(&ctx).unwrap();
3981
3982 assert_eq!(
3983 result.len(),
3984 1,
3985 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3986 );
3987 }
3988
3989 #[test]
3990 fn test_search_paths_combined_with_obsidian() {
3991 let temp_dir = tempdir().unwrap();
3992 let vault = temp_dir.path().join("vault-combo");
3993 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3994 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3995 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3996 std::fs::create_dir_all(vault.join("notes")).unwrap();
3997
3998 std::fs::write(
3999 vault.join(".obsidian/app.json"),
4000 r#"{"attachmentFolderPath": "Attachments"}"#,
4001 )
4002 .unwrap();
4003 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4004 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4005
4006 let notes_dir = vault.join("notes");
4007 let source_file = notes_dir.join("test.md");
4008 std::fs::write(&source_file, "placeholder").unwrap();
4009
4010 let extra_assets_dir = vault.join("extra-assets");
4011 let config = MD057Config {
4012 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4013 ..Default::default()
4014 };
4015 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
4016
4017 let content = "# Test\n\n\n\n\n";
4019 let ctx =
4020 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4021 let result = rule.check(&ctx).unwrap();
4022
4023 assert!(
4024 result.is_empty(),
4025 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4026 );
4027 }
4028
4029 #[test]
4030 fn test_obsidian_attachment_subfolder_under_file() {
4031 let temp_dir = tempdir().unwrap();
4032 let vault = temp_dir.path().join("vault-sub");
4033 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4034 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4035
4036 std::fs::write(
4037 vault.join(".obsidian/app.json"),
4038 r#"{"attachmentFolderPath": "./assets"}"#,
4039 )
4040 .unwrap();
4041 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4042
4043 let notes_dir = vault.join("notes");
4044 let source_file = notes_dir.join("test.md");
4045 std::fs::write(&source_file, "placeholder").unwrap();
4046
4047 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4048
4049 let content = "# Test\n\n\n";
4050 let ctx =
4051 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4052 let result = rule.check(&ctx).unwrap();
4053
4054 assert!(
4055 result.is_empty(),
4056 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4057 );
4058 }
4059
4060 #[test]
4061 fn test_obsidian_attachment_vault_root() {
4062 let temp_dir = tempdir().unwrap();
4063 let vault = temp_dir.path().join("vault-root");
4064 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4065 std::fs::create_dir_all(vault.join("notes")).unwrap();
4066
4067 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4069 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4070
4071 let notes_dir = vault.join("notes");
4072 let source_file = notes_dir.join("test.md");
4073 std::fs::write(&source_file, "placeholder").unwrap();
4074
4075 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4076
4077 let content = "# Test\n\n\n";
4078 let ctx =
4079 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4080 let result = rule.check(&ctx).unwrap();
4081
4082 assert!(
4083 result.is_empty(),
4084 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4085 );
4086 }
4087
4088 #[test]
4089 fn test_search_paths_multiple_directories() {
4090 let temp_dir = tempdir().unwrap();
4091 let base_path = temp_dir.path();
4092
4093 let dir_a = base_path.join("dir-a");
4094 let dir_b = base_path.join("dir-b");
4095 std::fs::create_dir_all(&dir_a).unwrap();
4096 std::fs::create_dir_all(&dir_b).unwrap();
4097 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4098 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4099
4100 let config = MD057Config {
4101 search_paths: vec![
4102 dir_a.to_string_lossy().into_owned(),
4103 dir_b.to_string_lossy().into_owned(),
4104 ],
4105 ..Default::default()
4106 };
4107 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4108
4109 let content = "# Test\n\n\n\n\n";
4110 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4111 let result = rule.check(&ctx).unwrap();
4112
4113 assert!(
4114 result.is_empty(),
4115 "Should find files across multiple search paths. Got: {result:?}"
4116 );
4117 }
4118
4119 #[test]
4128 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4129 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4130
4131 let temp_dir = tempdir().unwrap();
4132 let base_path = temp_dir.path();
4133
4134 let file_path = base_path.join("README.md");
4135 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4136 std::fs::write(&file_path, content).unwrap();
4137
4138 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4139
4140 let ctx = crate::lint_context::LintContext::new(
4141 content,
4142 crate::config::MarkdownFlavor::Standard,
4143 Some(file_path.clone()),
4144 );
4145 let per_file = rule.check(&ctx).unwrap();
4146 assert_eq!(
4147 per_file.len(),
4148 1,
4149 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4150 );
4151
4152 let mut file_index = FileIndex::default();
4153 file_index.cross_file_links.push(CrossFileLinkIndex {
4154 target_path: "missing-guide.md".to_string(),
4155 fragment: String::new(),
4156 line: 3,
4157 column: 1,
4158 origin: LinkOrigin::Body,
4159 });
4160
4161 let result = rule
4162 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4163 .unwrap();
4164
4165 assert!(
4166 result.is_empty(),
4167 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4168 );
4169 }
4170
4171 #[test]
4172 fn test_check_clears_stale_cache() {
4173 let temp_dir = tempdir().unwrap();
4176 let base_path = temp_dir.path();
4177
4178 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4179
4180 let phantom_path = base_path.join("phantom.md");
4182 {
4183 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4184 cache.insert(phantom_path.clone(), true);
4185 }
4186
4187 let content = "[phantom](phantom.md)\n";
4188 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4189 let warnings = rule.check(&ctx).unwrap();
4190
4191 assert_eq!(
4193 warnings.len(),
4194 1,
4195 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4196 );
4197 assert!(warnings[0].message.contains("phantom.md"));
4198 }
4199
4200 #[test]
4201 fn test_check_does_not_carry_over_cache_between_runs() {
4202 let temp_dir = tempdir().unwrap();
4204 let base_path = temp_dir.path();
4205
4206 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4207
4208 let content = "[missing](nonexistent.md)\n";
4209 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4210
4211 let warnings_1 = rule.check(&ctx).unwrap();
4213 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4214
4215 let nonexistent_path = base_path.join("nonexistent.md");
4217 {
4218 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4219 cache.insert(nonexistent_path.clone(), true);
4220 }
4221
4222 let warnings_2 = rule.check(&ctx).unwrap();
4224 assert_eq!(
4225 warnings_2.len(),
4226 1,
4227 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4228 );
4229 }
4230
4231 #[test]
4237 fn test_no_duplicate_warnings_for_broken_relative_link() {
4238 use crate::workspace_index::WorkspaceIndex;
4239
4240 let temp_dir = tempdir().unwrap();
4241 let base_path = temp_dir.path();
4242
4243 let source_file = base_path.join("index.md");
4245 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4246
4247 let content = "[broken](does/not/exist.md)\n";
4248
4249 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4250
4251 let ctx = crate::lint_context::LintContext::new(
4253 content,
4254 crate::config::MarkdownFlavor::Standard,
4255 Some(source_file.clone()),
4256 );
4257 let check_warnings = rule.check(&ctx).unwrap();
4258
4259 let mut file_index = FileIndex::new();
4261 rule.contribute_to_index(&ctx, &mut file_index);
4262 let workspace_index = WorkspaceIndex::new();
4263 let cross_warnings = rule
4264 .cross_file_check(&source_file, &file_index, &workspace_index)
4265 .unwrap();
4266
4267 let total = check_warnings.len() + cross_warnings.len();
4268 assert_eq!(
4269 total, 1,
4270 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4271 check={check_warnings:?}, cross={cross_warnings:?}"
4272 );
4273 }
4274
4275 #[test]
4280 fn test_absolute_dir_link_accepted_relative_to_roots() {
4281 let temp_dir = tempdir().unwrap();
4282 let root = temp_dir.path();
4283
4284 let dir_d = root.join("d");
4286 std::fs::create_dir_all(&dir_d).unwrap();
4287 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4288
4289 let content = "\
4292[absolute dir](/d)\n\
4293[relative dir](d)\n\
4294[absolute file](/d/foo.md)\n\
4295[relative file](d/foo.md)\n";
4296
4297 let config = MD057Config {
4298 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4299 roots: vec![],
4300 ..Default::default()
4301 };
4302 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4303
4304 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4305 let result = rule.check(&ctx).unwrap();
4306
4307 assert!(
4308 result.is_empty(),
4309 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4310 );
4311 }
4312
4313 #[test]
4316 fn test_absolute_trailing_slash_dir_link_requires_index() {
4317 let temp_dir = tempdir().unwrap();
4318 let root = temp_dir.path();
4319
4320 let dir_d = root.join("d");
4322 std::fs::create_dir_all(&dir_d).unwrap();
4323 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4324
4325 let content = "[dir with slash](/d/)\n";
4327
4328 let config = MD057Config {
4329 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4330 roots: vec![],
4331 ..Default::default()
4332 };
4333 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4334
4335 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4336 let result = rule.check(&ctx).unwrap();
4337
4338 assert_eq!(
4339 result.len(),
4340 1,
4341 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
4342 );
4343 }
4344
4345 #[test]
4349 fn test_docs_dir_variant_still_enforces_index_md() {
4350 let temp_dir = tempdir().unwrap();
4351 let root = temp_dir.path();
4352
4353 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4355
4356 let docs_dir = root.join("docs");
4358 std::fs::create_dir_all(&docs_dir).unwrap();
4359 let section_dir = docs_dir.join("section");
4360 std::fs::create_dir_all(§ion_dir).unwrap();
4361 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4362
4363 let source_file = docs_dir.join("index.md");
4365 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4366
4367 let config = MD057Config {
4368 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4369 ..Default::default()
4370 };
4371 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4372
4373 let content = "[sec](/section)\n";
4374 let ctx = crate::lint_context::LintContext::new(
4375 content,
4376 crate::config::MarkdownFlavor::Standard,
4377 Some(source_file.clone()),
4378 );
4379 let result = rule.check(&ctx).unwrap();
4380
4381 assert_eq!(
4383 result.len(),
4384 1,
4385 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4386 );
4387 assert!(
4388 result[0].message.contains("index.md") || result[0].message.contains("section"),
4389 "Message should mention the directory or missing index.md: {}",
4390 result[0].message
4391 );
4392 }
4393
4394 #[test]
4400 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4401 let temp_dir = tempdir().unwrap();
4402 let root = temp_dir.path();
4403
4404 let guide_dir = root.join("guide");
4406 std::fs::create_dir_all(&guide_dir).unwrap();
4407 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4408
4409 let content = "[guide with fragment](/guide/#intro)\n";
4411
4412 let config = MD057Config {
4413 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4414 roots: vec![],
4415 ..Default::default()
4416 };
4417 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4418 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4419 let result = rule.check(&ctx).unwrap();
4420
4421 assert_eq!(
4422 result.len(),
4423 1,
4424 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4425 );
4426 }
4427}
4428
4429#[cfg(test)]
4430mod self_referential_links_tests {
4431 use super::*;
4432 use tempfile::tempdir;
4433
4434 fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4436 let source_file = dir.join(name);
4437 std::fs::write(&source_file, content).unwrap();
4438 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4439 let ctx =
4440 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4441 rule.check(&ctx).unwrap()
4442 }
4443
4444 fn enabled() -> MD057Config {
4445 MD057Config {
4446 self_referential_links: true,
4447 ..Default::default()
4448 }
4449 }
4450
4451 #[test]
4452 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4453 let temp_dir = tempdir().unwrap();
4454 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4455 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4456
4457 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4458 assert_eq!(
4459 result[0].message,
4460 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4461 );
4462 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4463 assert_eq!(fix.replacement, "#level-2-heading");
4464 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4465 }
4466
4467 #[test]
4468 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4469 let temp_dir = tempdir().unwrap();
4470 let content = "# Title\n\nSee [this file](test.md).\n";
4471 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4472
4473 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4474 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4475 assert!(
4476 result[0].fix.is_none(),
4477 "Dropping the link would change the document, so there is no fix"
4478 );
4479 }
4480
4481 #[test]
4482 fn test_the_check_is_off_by_default() {
4483 let temp_dir = tempdir().unwrap();
4484 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4485 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4486
4487 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4488 }
4489
4490 #[test]
4491 fn test_a_link_to_another_file_is_left_alone() {
4492 let temp_dir = tempdir().unwrap();
4493 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4494 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4495 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4496
4497 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4498 }
4499
4500 #[test]
4501 fn test_a_self_link_written_with_traversal_reports_once() {
4502 let temp_dir = tempdir().unwrap();
4503 let sub_dir = temp_dir.path().join("sub");
4504 std::fs::create_dir_all(&sub_dir).unwrap();
4505
4506 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4507 let config = MD057Config {
4508 self_referential_links: true,
4509 compact_paths: true,
4510 ..Default::default()
4511 };
4512 let result = check_as_file(&sub_dir, "test.md", content, config);
4513
4514 assert_eq!(
4515 result.len(),
4516 1,
4517 "A compacted path would still be a link back to this file. Got: {result:?}"
4518 );
4519 assert_eq!(
4520 result[0].message,
4521 "Relative link '../sub/test.md' points to the file it is in"
4522 );
4523 }
4524
4525 #[test]
4526 fn test_compact_paths_still_reports_a_link_to_another_file() {
4527 let temp_dir = tempdir().unwrap();
4528 let sub_dir = temp_dir.path().join("sub");
4529 std::fs::create_dir_all(&sub_dir).unwrap();
4530 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4531
4532 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4533 let config = MD057Config {
4534 self_referential_links: true,
4535 compact_paths: true,
4536 ..Default::default()
4537 };
4538 let result = check_as_file(&sub_dir, "test.md", content, config);
4539
4540 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4541 assert_eq!(
4542 result[0].message,
4543 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4544 );
4545 }
4546
4547 #[test]
4548 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4549 let temp_dir = tempdir().unwrap();
4550 let content = "# Title\n\nSee [this file](test#title).\n";
4551 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4552
4553 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4554 assert_eq!(
4555 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4556 Some("#title"),
4557 "Got: {result:?}"
4558 );
4559 }
4560
4561 #[test]
4562 fn test_a_reference_definition_pointing_at_its_own_file() {
4563 let temp_dir = tempdir().unwrap();
4564 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4565 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4566
4567 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4568 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4569 assert_eq!(fix.replacement, "#level-2-heading");
4570 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4571 }
4572
4573 #[test]
4574 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4575 let temp_dir = tempdir().unwrap();
4576 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4577 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4578
4579 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4580 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4581 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4584 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4585 .fix(&crate::lint_context::LintContext::new(
4586 content,
4587 crate::config::MarkdownFlavor::Standard,
4588 Some(temp_dir.path().join("test.md")),
4589 ))
4590 .unwrap();
4591 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4592 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4593 }
4594
4595 #[test]
4596 fn test_a_self_link_resolved_through_a_search_path() {
4597 let temp_dir = tempdir().unwrap();
4598 let guide_dir = temp_dir.path().join("docs/guide");
4599 std::fs::create_dir_all(&guide_dir).unwrap();
4600 let config = MD057Config {
4601 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4602 ..enabled()
4603 };
4604 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4605 let result = check_as_file(&guide_dir, "test.md", content, config);
4606
4607 assert_eq!(
4608 result.len(),
4609 1,
4610 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4611 );
4612 assert_eq!(
4613 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4614 Some("#title"),
4615 "Got: {result:?}"
4616 );
4617 }
4618
4619 #[test]
4620 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4621 let temp_dir = tempdir().unwrap();
4622 let guide_dir = temp_dir.path().join("docs/guide");
4623 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4624 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4625 let config = MD057Config {
4626 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4627 ..enabled()
4628 };
4629 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4630 let result = check_as_file(&guide_dir, "test.md", content, config);
4631
4632 assert!(
4633 result.is_empty(),
4634 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4635 );
4636 }
4637
4638 #[test]
4639 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4640 let temp_dir = tempdir().unwrap();
4641 let content = "# Title\n\n\n";
4642 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4643
4644 assert!(
4645 result.is_empty(),
4646 "An image is not a link the reader follows. Got: {result:?}"
4647 );
4648 }
4649
4650 #[test]
4651 fn test_a_query_string_is_reported_without_a_suggestion() {
4652 let temp_dir = tempdir().unwrap();
4653 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4654 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4655
4656 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4657 assert!(
4658 result[0].fix.is_none(),
4659 "A query does not survive losing its path. Got: {result:?}"
4660 );
4661 }
4662
4663 #[test]
4664 fn test_fix_rewrites_the_document_and_settles() {
4665 let temp_dir = tempdir().unwrap();
4666 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4667 let source_file = temp_dir.path().join("test.md");
4668 std::fs::write(&source_file, content).unwrap();
4669
4670 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4671 let ctx = crate::lint_context::LintContext::new(
4672 content,
4673 crate::config::MarkdownFlavor::Standard,
4674 Some(source_file.clone()),
4675 );
4676 let fixed = rule.fix(&ctx).unwrap();
4677 assert_eq!(
4678 fixed,
4679 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4680 );
4681
4682 let refixed =
4683 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4684 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4685 }
4686
4687 #[test]
4688 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4689 let unfixable = MD057ExistingRelativeLinks::default();
4690 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4691
4692 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4693 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4694 }
4695
4696 #[test]
4697 fn test_the_option_is_read_from_kebab_and_snake_case() {
4698 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4699 assert!(kebab.self_referential_links);
4700
4701 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4702 assert!(snake.self_referential_links);
4703 }
4704
4705 fn front_matter_checked() -> MD057Config {
4706 MD057Config {
4707 check_frontmatter: true,
4708 ..Default::default()
4709 }
4710 }
4711
4712 #[test]
4713 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4714 let temp_dir = tempdir().unwrap();
4715 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4716 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4717
4718 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4719 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4720 assert_eq!(result[0].line, 2);
4721 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4722 assert_eq!(result[0].end_column, 23);
4723 }
4724
4725 #[test]
4726 fn test_frontmatter_paths_are_not_checked_by_default() {
4727 let temp_dir = tempdir().unwrap();
4728 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4729 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4730
4731 assert!(
4732 result.is_empty(),
4733 "Frontmatter is only checked on request. Got: {result:?}"
4734 );
4735 }
4736
4737 #[test]
4738 fn test_an_existing_frontmatter_path_is_not_reported() {
4739 let temp_dir = tempdir().unwrap();
4740 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4741 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4742 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4743
4744 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4745 assert_eq!(result[0].line, 3);
4746 }
4747
4748 #[test]
4749 fn test_an_ignored_frontmatter_field_is_not_checked() {
4750 let temp_dir = tempdir().unwrap();
4751 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4752 let config = MD057Config {
4753 check_frontmatter: true,
4754 ignore_frontmatter_fields: vec!["Image".to_string()],
4755 ..Default::default()
4756 };
4757 let result = check_as_file(temp_dir.path(), "test.md", content, config);
4758
4759 assert_eq!(
4760 result.len(),
4761 1,
4762 "The ignored field is skipped and the other is not. Got: {result:?}"
4763 );
4764 assert_eq!(result[0].line, 3);
4765 }
4766
4767 #[test]
4768 fn test_an_external_frontmatter_url_is_not_reported() {
4769 let temp_dir = tempdir().unwrap();
4770 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4771 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4772
4773 assert!(
4774 result.is_empty(),
4775 "An external URL has no local target. Got: {result:?}"
4776 );
4777 }
4778
4779 #[test]
4780 fn test_a_frontmatter_fragment_is_left_to_md051() {
4781 let temp_dir = tempdir().unwrap();
4782 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4783 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4784
4785 assert!(
4786 result.is_empty(),
4787 "A fragment names a heading, not a file. Got: {result:?}"
4788 );
4789 }
4790
4791 #[test]
4792 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4793 let temp_dir = tempdir().unwrap();
4794 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4795
4796 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4797 assert!(
4798 ignored.is_empty(),
4799 "Absolute paths are ignored by default. Got: {ignored:?}"
4800 );
4801
4802 let warning_config = MD057Config {
4803 check_frontmatter: true,
4804 absolute_links: AbsoluteLinksOption::Warn,
4805 ..Default::default()
4806 };
4807 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4808 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4809 assert_eq!(
4810 warned[0].message,
4811 "Absolute link '/docs/guide.md' cannot be validated locally"
4812 );
4813 }
4814
4815 #[test]
4816 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4817 let temp_dir = tempdir().unwrap();
4818 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4819 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4820 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4821
4822 assert_eq!(
4823 result.len(),
4824 1,
4825 "A query names no file, so only the missing target is reported. Got: {result:?}"
4826 );
4827 assert_eq!(result[0].line, 2);
4828 assert_eq!(
4829 result[0].message,
4830 "Relative link 'docs/missing.md?raw=true' does not exist"
4831 );
4832 }
4833
4834 #[test]
4835 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4836 let temp_dir = tempdir().unwrap();
4837 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4838 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4839
4840 assert!(
4841 result.is_empty(),
4842 "Only path-shaped values are destinations. Got: {result:?}"
4843 );
4844 }
4845}