1use crate::rule::{
7 CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::workspace_index::{FileIndex, extract_cross_file_links};
10use regex::Regex;
11use std::collections::HashMap;
12use std::env;
13use std::path::{Path, PathBuf};
14use std::sync::LazyLock;
15use std::sync::{Arc, Mutex};
16
17mod md057_config;
18use crate::rule_config_serde::RuleConfig;
19use crate::utils::mkdocs_config::resolve_docs_dir;
20pub use md057_config::{AbsoluteLinksOption, MD057Config};
21
22static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
24 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
25
26fn reset_file_existence_cache() {
28 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
29 cache.clear();
30 }
31}
32
33fn file_exists_with_cache(path: &Path) -> bool {
35 match FILE_EXISTENCE_CACHE.lock() {
36 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
37 Err(_) => path.exists(), }
39}
40
41fn file_exists_or_markdown_extension(path: &Path) -> bool {
44 if file_exists_with_cache(path) {
46 return true;
47 }
48
49 if path.extension().is_none() {
51 for ext in MARKDOWN_EXTENSIONS {
52 let path_with_ext = path.with_extension(&ext[1..]);
54 if file_exists_with_cache(&path_with_ext) {
55 return true;
56 }
57 }
58 }
59
60 false
61}
62
63static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
65
66static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
70 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
71
72static URL_EXTRACT_REGEX: LazyLock<Regex> =
75 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
76
77static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
81 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
82
83static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
85
86#[inline]
89fn hex_digit_to_value(byte: u8) -> Option<u8> {
90 match byte {
91 b'0'..=b'9' => Some(byte - b'0'),
92 b'a'..=b'f' => Some(byte - b'a' + 10),
93 b'A'..=b'F' => Some(byte - b'A' + 10),
94 _ => None,
95 }
96}
97
98const MARKDOWN_EXTENSIONS: &[&str] = &[
100 ".md",
101 ".markdown",
102 ".mdx",
103 ".mkd",
104 ".mkdn",
105 ".mdown",
106 ".mdwn",
107 ".qmd",
108 ".rmd",
109];
110
111#[derive(Debug, Clone)]
113pub struct MD057ExistingRelativeLinks {
114 base_path: Arc<Mutex<Option<PathBuf>>>,
116 config: MD057Config,
118}
119
120impl Default for MD057ExistingRelativeLinks {
121 fn default() -> Self {
122 Self {
123 base_path: Arc::new(Mutex::new(None)),
124 config: MD057Config::default(),
125 }
126 }
127}
128
129impl MD057ExistingRelativeLinks {
130 pub fn new() -> Self {
132 Self::default()
133 }
134
135 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
137 let path = path.as_ref();
138 let dir_path = if path.is_file() {
139 path.parent().map(|p| p.to_path_buf())
140 } else {
141 Some(path.to_path_buf())
142 };
143
144 if let Ok(mut guard) = self.base_path.lock() {
145 *guard = dir_path;
146 }
147 self
148 }
149
150 pub fn from_config_struct(config: MD057Config) -> Self {
151 Self {
152 base_path: Arc::new(Mutex::new(None)),
153 config,
154 }
155 }
156
157 #[inline]
169 fn is_external_url(&self, url: &str) -> bool {
170 if url.is_empty() {
171 return false;
172 }
173
174 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
176 return true;
177 }
178
179 if url.starts_with("{{") || url.starts_with("{%") {
182 return true;
183 }
184
185 if url.contains('@') {
188 return true; }
190
191 if url.ends_with(".com") {
198 return true;
199 }
200
201 if url.starts_with('~') || url.starts_with('@') {
205 return true;
206 }
207
208 false
210 }
211
212 #[inline]
214 fn is_fragment_only_link(&self, url: &str) -> bool {
215 url.starts_with('#')
216 }
217
218 #[inline]
221 fn is_absolute_path(url: &str) -> bool {
222 url.starts_with('/')
223 }
224
225 fn url_decode(path: &str) -> String {
229 if !path.contains('%') {
231 return path.to_string();
232 }
233
234 let bytes = path.as_bytes();
235 let mut result = Vec::with_capacity(bytes.len());
236 let mut i = 0;
237
238 while i < bytes.len() {
239 if bytes[i] == b'%' && i + 2 < bytes.len() {
240 let hex1 = bytes[i + 1];
242 let hex2 = bytes[i + 2];
243 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
244 result.push(d1 * 16 + d2);
245 i += 3;
246 continue;
247 }
248 }
249 result.push(bytes[i]);
250 i += 1;
251 }
252
253 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
255 }
256
257 fn strip_query_and_fragment(url: &str) -> &str {
265 let query_pos = url.find('?');
268 let fragment_pos = url.find('#');
269
270 match (query_pos, fragment_pos) {
271 (Some(q), Some(f)) => {
272 &url[..q.min(f)]
274 }
275 (Some(q), None) => &url[..q],
276 (None, Some(f)) => &url[..f],
277 (None, None) => url,
278 }
279 }
280
281 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
283 base_path.join(link)
284 }
285
286 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
292 if !self.config.compact_paths {
293 return None;
294 }
295
296 let path_end = url
298 .find('?')
299 .unwrap_or(url.len())
300 .min(url.find('#').unwrap_or(url.len()));
301 let path_part = &url[..path_end];
302 let suffix = &url[path_end..];
303
304 let decoded_path = Self::url_decode(path_part);
306
307 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
308 }
309
310 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
315 let Some(docs_dir) = resolve_docs_dir(source_path) else {
316 return Some(format!(
318 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
319 ));
320 };
321
322 let relative_url = url.trim_start_matches('/');
324
325 let file_path = Self::strip_query_and_fragment(relative_url);
327 let decoded = Self::url_decode(file_path);
328 let resolved_path = docs_dir.join(&decoded);
329
330 let is_directory_link = url.ends_with('/') || decoded.is_empty();
335 if is_directory_link || resolved_path.is_dir() {
336 let index_path = resolved_path.join("index.md");
337 if file_exists_with_cache(&index_path) {
338 return None; }
340 if resolved_path.is_dir() {
342 return Some(format!(
343 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
344 resolved_path.display()
345 ));
346 }
347 }
348
349 if file_exists_or_markdown_extension(&resolved_path) {
351 return None; }
353
354 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
356 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
357 && let (Some(stem), Some(parent)) = (
358 resolved_path.file_stem().and_then(|s| s.to_str()),
359 resolved_path.parent(),
360 )
361 {
362 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
363 let source_path = parent.join(format!("{stem}{md_ext}"));
364 file_exists_with_cache(&source_path)
365 });
366 if has_md_source {
367 return None; }
369 }
370
371 Some(format!(
372 "Absolute link '{url}' resolves to '{}' which does not exist",
373 resolved_path.display()
374 ))
375 }
376}
377
378impl Rule for MD057ExistingRelativeLinks {
379 fn name(&self) -> &'static str {
380 "MD057"
381 }
382
383 fn description(&self) -> &'static str {
384 "Relative links should point to existing files"
385 }
386
387 fn category(&self) -> RuleCategory {
388 RuleCategory::Link
389 }
390
391 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
392 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
393 }
394
395 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
396 let content = ctx.content;
397
398 if content.is_empty() || !content.contains('[') {
400 return Ok(Vec::new());
401 }
402
403 if !content.contains("](") && !content.contains("]:") {
406 return Ok(Vec::new());
407 }
408
409 reset_file_existence_cache();
411
412 let mut warnings = Vec::new();
413
414 let base_path: Option<PathBuf> = {
418 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
420 if explicit_base.is_some() {
421 explicit_base
422 } else if let Some(ref source_file) = ctx.source_file {
423 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
427 resolved_file
428 .parent()
429 .map(|p| p.to_path_buf())
430 .or_else(|| Some(CURRENT_DIR.clone()))
431 } else {
432 None
434 }
435 };
436
437 let Some(base_path) = base_path else {
439 return Ok(warnings);
440 };
441
442 if !ctx.links.is_empty() {
444 let line_index = &ctx.line_index;
446
447 let lines = ctx.raw_lines();
449
450 let mut processed_lines = std::collections::HashSet::new();
453
454 for link in &ctx.links {
455 let line_idx = link.line - 1;
456 if line_idx >= lines.len() {
457 continue;
458 }
459
460 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
462 continue;
463 }
464
465 if !processed_lines.insert(line_idx) {
467 continue;
468 }
469
470 let line = lines[line_idx];
471
472 if !line.contains("](") {
474 continue;
475 }
476
477 for link_match in LINK_START_REGEX.find_iter(line) {
479 let start_pos = link_match.start();
480 let end_pos = link_match.end();
481
482 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
484 let absolute_start_pos = line_start_byte + start_pos;
485
486 if ctx.is_in_code_span_byte(absolute_start_pos) {
488 continue;
489 }
490
491 if ctx.is_in_math_span(absolute_start_pos) {
493 continue;
494 }
495
496 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
500 .captures_at(line, end_pos - 1)
501 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
502 .or_else(|| {
503 URL_EXTRACT_REGEX
504 .captures_at(line, end_pos - 1)
505 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
506 });
507
508 if let Some((caps, url_group)) = caps_and_url {
509 let url = url_group.as_str().trim();
510
511 if url.is_empty() {
513 continue;
514 }
515
516 if url.starts_with('`') && url.ends_with('`') {
520 continue;
521 }
522
523 if self.is_external_url(url) || self.is_fragment_only_link(url) {
525 continue;
526 }
527
528 if Self::is_absolute_path(url) {
530 match self.config.absolute_links {
531 AbsoluteLinksOption::Warn => {
532 let url_start = url_group.start();
533 let url_end = url_group.end();
534 warnings.push(LintWarning {
535 rule_name: Some(self.name().to_string()),
536 line: link.line,
537 column: url_start + 1,
538 end_line: link.line,
539 end_column: url_end + 1,
540 message: format!("Absolute link '{url}' cannot be validated locally"),
541 severity: Severity::Warning,
542 fix: None,
543 });
544 }
545 AbsoluteLinksOption::RelativeToDocs => {
546 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
547 let url_start = url_group.start();
548 let url_end = url_group.end();
549 warnings.push(LintWarning {
550 rule_name: Some(self.name().to_string()),
551 line: link.line,
552 column: url_start + 1,
553 end_line: link.line,
554 end_column: url_end + 1,
555 message: msg,
556 severity: Severity::Warning,
557 fix: None,
558 });
559 }
560 }
561 AbsoluteLinksOption::Ignore => {}
562 }
563 continue;
564 }
565
566 let full_url_for_compact = if let Some(frag) = caps.get(2) {
570 format!("{url}{}", frag.as_str())
571 } else {
572 url.to_string()
573 };
574 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
575 let url_start = url_group.start();
576 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
577 let fix_byte_start = line_start_byte + url_start;
578 let fix_byte_end = line_start_byte + url_end;
579 warnings.push(LintWarning {
580 rule_name: Some(self.name().to_string()),
581 line: link.line,
582 column: url_start + 1,
583 end_line: link.line,
584 end_column: url_end + 1,
585 message: format!(
586 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
587 ),
588 severity: Severity::Warning,
589 fix: Some(Fix {
590 range: fix_byte_start..fix_byte_end,
591 replacement: suggestion,
592 }),
593 });
594 }
595
596 let file_path = Self::strip_query_and_fragment(url);
598
599 let decoded_path = Self::url_decode(file_path);
601
602 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
604
605 if file_exists_or_markdown_extension(&resolved_path) {
607 continue; }
609
610 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
612 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
613 && let (Some(stem), Some(parent)) = (
614 resolved_path.file_stem().and_then(|s| s.to_str()),
615 resolved_path.parent(),
616 ) {
617 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
618 let source_path = parent.join(format!("{stem}{md_ext}"));
619 file_exists_with_cache(&source_path)
620 })
621 } else {
622 false
623 };
624
625 if has_md_source {
626 continue; }
628
629 let url_start = url_group.start();
633 let url_end = url_group.end();
634
635 warnings.push(LintWarning {
636 rule_name: Some(self.name().to_string()),
637 line: link.line,
638 column: url_start + 1, end_line: link.line,
640 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
642 severity: Severity::Error,
643 fix: None,
644 });
645 }
646 }
647 }
648 }
649
650 for image in &ctx.images {
652 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
654 continue;
655 }
656
657 let url = image.url.as_ref();
658
659 if url.is_empty() {
661 continue;
662 }
663
664 if self.is_external_url(url) || self.is_fragment_only_link(url) {
666 continue;
667 }
668
669 if Self::is_absolute_path(url) {
671 match self.config.absolute_links {
672 AbsoluteLinksOption::Warn => {
673 warnings.push(LintWarning {
674 rule_name: Some(self.name().to_string()),
675 line: image.line,
676 column: image.start_col + 1,
677 end_line: image.line,
678 end_column: image.start_col + 1 + url.len(),
679 message: format!("Absolute link '{url}' cannot be validated locally"),
680 severity: Severity::Warning,
681 fix: None,
682 });
683 }
684 AbsoluteLinksOption::RelativeToDocs => {
685 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
686 warnings.push(LintWarning {
687 rule_name: Some(self.name().to_string()),
688 line: image.line,
689 column: image.start_col + 1,
690 end_line: image.line,
691 end_column: image.start_col + 1 + url.len(),
692 message: msg,
693 severity: Severity::Warning,
694 fix: None,
695 });
696 }
697 }
698 AbsoluteLinksOption::Ignore => {}
699 }
700 continue;
701 }
702
703 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
705 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
708 let fix_byte_start = image.byte_offset + url_offset;
709 let fix_byte_end = fix_byte_start + url.len();
710 Fix {
711 range: fix_byte_start..fix_byte_end,
712 replacement: suggestion.clone(),
713 }
714 });
715
716 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
717 let url_col = fix
718 .as_ref()
719 .map_or(image.start_col + 1, |f| f.range.start - img_line_start_byte + 1);
720 warnings.push(LintWarning {
721 rule_name: Some(self.name().to_string()),
722 line: image.line,
723 column: url_col,
724 end_line: image.line,
725 end_column: url_col + url.len(),
726 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
727 severity: Severity::Warning,
728 fix,
729 });
730 }
731
732 let file_path = Self::strip_query_and_fragment(url);
734
735 let decoded_path = Self::url_decode(file_path);
737
738 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
740
741 if file_exists_or_markdown_extension(&resolved_path) {
743 continue; }
745
746 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
748 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
749 && let (Some(stem), Some(parent)) = (
750 resolved_path.file_stem().and_then(|s| s.to_str()),
751 resolved_path.parent(),
752 ) {
753 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
754 let source_path = parent.join(format!("{stem}{md_ext}"));
755 file_exists_with_cache(&source_path)
756 })
757 } else {
758 false
759 };
760
761 if has_md_source {
762 continue; }
764
765 warnings.push(LintWarning {
768 rule_name: Some(self.name().to_string()),
769 line: image.line,
770 column: image.start_col + 1,
771 end_line: image.line,
772 end_column: image.start_col + 1 + url.len(),
773 message: format!("Relative link '{url}' does not exist"),
774 severity: Severity::Error,
775 fix: None,
776 });
777 }
778
779 for ref_def in &ctx.reference_defs {
781 let url = &ref_def.url;
782
783 if url.is_empty() {
785 continue;
786 }
787
788 if self.is_external_url(url) || self.is_fragment_only_link(url) {
790 continue;
791 }
792
793 if Self::is_absolute_path(url) {
795 match self.config.absolute_links {
796 AbsoluteLinksOption::Warn => {
797 let line_idx = ref_def.line - 1;
798 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
799 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
800 });
801 warnings.push(LintWarning {
802 rule_name: Some(self.name().to_string()),
803 line: ref_def.line,
804 column,
805 end_line: ref_def.line,
806 end_column: column + url.len(),
807 message: format!("Absolute link '{url}' cannot be validated locally"),
808 severity: Severity::Warning,
809 fix: None,
810 });
811 }
812 AbsoluteLinksOption::RelativeToDocs => {
813 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
814 let line_idx = ref_def.line - 1;
815 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
816 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
817 });
818 warnings.push(LintWarning {
819 rule_name: Some(self.name().to_string()),
820 line: ref_def.line,
821 column,
822 end_line: ref_def.line,
823 end_column: column + url.len(),
824 message: msg,
825 severity: Severity::Warning,
826 fix: None,
827 });
828 }
829 }
830 AbsoluteLinksOption::Ignore => {}
831 }
832 continue;
833 }
834
835 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
837 let ref_line_idx = ref_def.line - 1;
838 let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
839 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
840 });
841 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
842 let fix_byte_start = ref_line_start_byte + col - 1;
843 let fix_byte_end = fix_byte_start + url.len();
844 warnings.push(LintWarning {
845 rule_name: Some(self.name().to_string()),
846 line: ref_def.line,
847 column: col,
848 end_line: ref_def.line,
849 end_column: col + url.len(),
850 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
851 severity: Severity::Warning,
852 fix: Some(Fix {
853 range: fix_byte_start..fix_byte_end,
854 replacement: suggestion,
855 }),
856 });
857 }
858
859 let file_path = Self::strip_query_and_fragment(url);
861
862 let decoded_path = Self::url_decode(file_path);
864
865 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
867
868 if file_exists_or_markdown_extension(&resolved_path) {
870 continue; }
872
873 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
875 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
876 && let (Some(stem), Some(parent)) = (
877 resolved_path.file_stem().and_then(|s| s.to_str()),
878 resolved_path.parent(),
879 ) {
880 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
881 let source_path = parent.join(format!("{stem}{md_ext}"));
882 file_exists_with_cache(&source_path)
883 })
884 } else {
885 false
886 };
887
888 if has_md_source {
889 continue; }
891
892 let line_idx = ref_def.line - 1;
895 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
896 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
898 });
899
900 warnings.push(LintWarning {
901 rule_name: Some(self.name().to_string()),
902 line: ref_def.line,
903 column,
904 end_line: ref_def.line,
905 end_column: column + url.len(),
906 message: format!("Relative link '{url}' does not exist"),
907 severity: Severity::Error,
908 fix: None,
909 });
910 }
911
912 Ok(warnings)
913 }
914
915 fn fix_capability(&self) -> FixCapability {
916 if self.config.compact_paths {
917 FixCapability::ConditionallyFixable
918 } else {
919 FixCapability::Unfixable
920 }
921 }
922
923 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
924 if !self.config.compact_paths {
925 return Ok(ctx.content.to_string());
926 }
927
928 let warnings = self.check(ctx)?;
929 let warnings =
930 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
931 let mut content = ctx.content.to_string();
932
933 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
935 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
936
937 for fix in fixes {
938 if fix.range.end <= content.len() {
939 content.replace_range(fix.range.clone(), &fix.replacement);
940 }
941 }
942
943 Ok(content)
944 }
945
946 fn as_any(&self) -> &dyn std::any::Any {
947 self
948 }
949
950 fn default_config_section(&self) -> Option<(String, toml::Value)> {
951 let default_config = MD057Config::default();
952 let json_value = serde_json::to_value(&default_config).ok()?;
953 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
954
955 if let toml::Value::Table(table) = toml_value {
956 if !table.is_empty() {
957 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
958 } else {
959 None
960 }
961 } else {
962 None
963 }
964 }
965
966 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
967 where
968 Self: Sized,
969 {
970 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
971 Box::new(Self::from_config_struct(rule_config))
972 }
973
974 fn cross_file_scope(&self) -> CrossFileScope {
975 CrossFileScope::Workspace
976 }
977
978 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
979 for link in extract_cross_file_links(ctx) {
982 index.add_cross_file_link(link);
983 }
984 }
985
986 fn cross_file_check(
987 &self,
988 file_path: &Path,
989 file_index: &FileIndex,
990 workspace_index: &crate::workspace_index::WorkspaceIndex,
991 ) -> LintResult {
992 let mut warnings = Vec::new();
993
994 let file_dir = file_path.parent();
996
997 for cross_link in &file_index.cross_file_links {
998 let decoded_target = Self::url_decode(&cross_link.target_path);
1001
1002 if decoded_target.starts_with('/') {
1006 continue;
1007 }
1008
1009 let target_path = if let Some(dir) = file_dir {
1011 dir.join(&decoded_target)
1012 } else {
1013 Path::new(&decoded_target).to_path_buf()
1014 };
1015
1016 let target_path = normalize_path(&target_path);
1018
1019 let file_exists =
1021 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1022
1023 if !file_exists {
1024 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1027 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1028 && let (Some(stem), Some(parent)) =
1029 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1030 {
1031 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1032 let source_path = parent.join(format!("{stem}{md_ext}"));
1033 workspace_index.contains_file(&source_path) || source_path.exists()
1034 })
1035 } else {
1036 false
1037 };
1038
1039 if !has_md_source {
1040 warnings.push(LintWarning {
1041 rule_name: Some(self.name().to_string()),
1042 line: cross_link.line,
1043 column: cross_link.column,
1044 end_line: cross_link.line,
1045 end_column: cross_link.column + cross_link.target_path.len(),
1046 message: format!("Relative link '{}' does not exist", cross_link.target_path),
1047 severity: Severity::Error,
1048 fix: None,
1049 });
1050 }
1051 }
1052 }
1053
1054 Ok(warnings)
1055 }
1056}
1057
1058fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1063 let from_components: Vec<_> = from_dir.components().collect();
1064 let to_components: Vec<_> = to_path.components().collect();
1065
1066 let common_len = from_components
1068 .iter()
1069 .zip(to_components.iter())
1070 .take_while(|(a, b)| a == b)
1071 .count();
1072
1073 let mut result = PathBuf::new();
1074
1075 for _ in common_len..from_components.len() {
1077 result.push("..");
1078 }
1079
1080 for component in &to_components[common_len..] {
1082 result.push(component);
1083 }
1084
1085 result
1086}
1087
1088fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1094 let link_path = Path::new(raw_link_path);
1095
1096 let has_traversal = link_path
1098 .components()
1099 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1100
1101 if !has_traversal {
1102 return None;
1103 }
1104
1105 let combined = source_dir.join(link_path);
1107 let normalized_target = normalize_path(&combined);
1108
1109 let normalized_source = normalize_path(source_dir);
1111 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1112
1113 if shortest != link_path {
1115 let compact = shortest.to_string_lossy().to_string();
1116 if compact.is_empty() {
1118 return None;
1119 }
1120 Some(compact.replace('\\', "/"))
1122 } else {
1123 None
1124 }
1125}
1126
1127fn normalize_path(path: &Path) -> PathBuf {
1129 let mut components = Vec::new();
1130
1131 for component in path.components() {
1132 match component {
1133 std::path::Component::ParentDir => {
1134 if !components.is_empty() {
1136 components.pop();
1137 }
1138 }
1139 std::path::Component::CurDir => {
1140 }
1142 _ => {
1143 components.push(component);
1144 }
1145 }
1146 }
1147
1148 components.iter().collect()
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154 use crate::workspace_index::CrossFileLinkIndex;
1155 use std::fs::File;
1156 use std::io::Write;
1157 use tempfile::tempdir;
1158
1159 #[test]
1160 fn test_strip_query_and_fragment() {
1161 assert_eq!(
1163 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1164 "file.png"
1165 );
1166 assert_eq!(
1167 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1168 "file.png"
1169 );
1170 assert_eq!(
1171 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1172 "file.png"
1173 );
1174
1175 assert_eq!(
1177 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1178 "file.md"
1179 );
1180 assert_eq!(
1181 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1182 "file.md"
1183 );
1184
1185 assert_eq!(
1187 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1188 "file.md"
1189 );
1190
1191 assert_eq!(
1193 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1194 "file.png"
1195 );
1196
1197 assert_eq!(
1199 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1200 "path/to/image.png"
1201 );
1202 assert_eq!(
1203 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1204 "path/to/image.png"
1205 );
1206
1207 assert_eq!(
1209 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1210 "file.md"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_url_decode() {
1216 assert_eq!(
1218 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1219 "penguin with space.jpg"
1220 );
1221
1222 assert_eq!(
1224 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1225 "assets/my file name.png"
1226 );
1227
1228 assert_eq!(
1230 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1231 "hello world!.md"
1232 );
1233
1234 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1236
1237 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1239
1240 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1242
1243 assert_eq!(
1245 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1246 "normal-file.md"
1247 );
1248
1249 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1251
1252 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1254
1255 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1257
1258 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1260
1261 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1263
1264 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1266
1267 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1269
1270 assert_eq!(
1272 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1273 "path/to/file.md"
1274 );
1275
1276 assert_eq!(
1278 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1279 "hello world/foo bar.md"
1280 );
1281
1282 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1284
1285 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1287 }
1288
1289 #[test]
1290 fn test_url_encoded_filenames() {
1291 let temp_dir = tempdir().unwrap();
1293 let base_path = temp_dir.path();
1294
1295 let file_with_spaces = base_path.join("penguin with space.jpg");
1297 File::create(&file_with_spaces)
1298 .unwrap()
1299 .write_all(b"image data")
1300 .unwrap();
1301
1302 let subdir = base_path.join("my images");
1304 std::fs::create_dir(&subdir).unwrap();
1305 let nested_file = subdir.join("photo 1.png");
1306 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1307
1308 let content = r#"
1310# Test Document with URL-Encoded Links
1311
1312
1313
1314
1315"#;
1316
1317 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1318
1319 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320 let result = rule.check(&ctx).unwrap();
1321
1322 assert_eq!(
1324 result.len(),
1325 1,
1326 "Should only warn about missing%20file.jpg. Got: {result:?}"
1327 );
1328 assert!(
1329 result[0].message.contains("missing%20file.jpg"),
1330 "Warning should mention the URL-encoded filename"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_external_urls() {
1336 let rule = MD057ExistingRelativeLinks::new();
1337
1338 assert!(rule.is_external_url("https://example.com"));
1340 assert!(rule.is_external_url("http://example.com"));
1341 assert!(rule.is_external_url("ftp://example.com"));
1342 assert!(rule.is_external_url("www.example.com"));
1343 assert!(rule.is_external_url("example.com"));
1344
1345 assert!(rule.is_external_url("file:///path/to/file"));
1347 assert!(rule.is_external_url("smb://server/share"));
1348 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1349 assert!(rule.is_external_url("mailto:user@example.com"));
1350 assert!(rule.is_external_url("tel:+1234567890"));
1351 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1352 assert!(rule.is_external_url("javascript:void(0)"));
1353 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1354 assert!(rule.is_external_url("git://github.com/repo.git"));
1355
1356 assert!(rule.is_external_url("user@example.com"));
1359 assert!(rule.is_external_url("steering@kubernetes.io"));
1360 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1361 assert!(rule.is_external_url("user_name@sub.domain.com"));
1362 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1363
1364 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"));
1375 assert!(!rule.is_external_url("/blog/2024/release.html"));
1376 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1377 assert!(!rule.is_external_url("/pkg/runtime"));
1378 assert!(!rule.is_external_url("/doc/go1compat"));
1379 assert!(!rule.is_external_url("/index.html"));
1380 assert!(!rule.is_external_url("/assets/logo.png"));
1381
1382 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1384 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1385 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1386 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1387 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1388
1389 assert!(rule.is_external_url("~/assets/image.png"));
1392 assert!(rule.is_external_url("~/components/Button.vue"));
1393 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1397 assert!(rule.is_external_url("@images/photo.jpg"));
1398 assert!(rule.is_external_url("@assets/styles.css"));
1399
1400 assert!(!rule.is_external_url("./relative/path.md"));
1402 assert!(!rule.is_external_url("relative/path.md"));
1403 assert!(!rule.is_external_url("../parent/path.md"));
1404 }
1405
1406 #[test]
1407 fn test_framework_path_aliases() {
1408 let temp_dir = tempdir().unwrap();
1410 let base_path = temp_dir.path();
1411
1412 let content = r#"
1414# Framework Path Aliases
1415
1416
1417
1418
1419
1420[Link](@/pages/about.md)
1421
1422This is a [real missing link](missing.md) that should be flagged.
1423"#;
1424
1425 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1426
1427 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428 let result = rule.check(&ctx).unwrap();
1429
1430 assert_eq!(
1432 result.len(),
1433 1,
1434 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1435 );
1436 assert!(
1437 result[0].message.contains("missing.md"),
1438 "Warning should be for missing.md"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_url_decode_security_path_traversal() {
1444 let temp_dir = tempdir().unwrap();
1447 let base_path = temp_dir.path();
1448
1449 let file_in_base = base_path.join("safe.md");
1451 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1452
1453 let content = r#"
1458[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1459[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1460[Safe link](safe.md)
1461"#;
1462
1463 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1464
1465 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466 let result = rule.check(&ctx).unwrap();
1467
1468 assert_eq!(
1471 result.len(),
1472 2,
1473 "Should have warnings for traversal attempts. Got: {result:?}"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_url_encoded_utf8_filenames() {
1479 let temp_dir = tempdir().unwrap();
1481 let base_path = temp_dir.path();
1482
1483 let cafe_file = base_path.join("café.md");
1485 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1486
1487 let content = r#"
1488[Café link](caf%C3%A9.md)
1489[Missing unicode](r%C3%A9sum%C3%A9.md)
1490"#;
1491
1492 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1493
1494 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let result = rule.check(&ctx).unwrap();
1496
1497 assert_eq!(
1499 result.len(),
1500 1,
1501 "Should only warn about missing résumé.md. Got: {result:?}"
1502 );
1503 assert!(
1504 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1505 "Warning should mention the URL-encoded filename"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_url_encoded_emoji_filenames() {
1511 let temp_dir = tempdir().unwrap();
1514 let base_path = temp_dir.path();
1515
1516 let emoji_dir = base_path.join("👤 Personal");
1518 std::fs::create_dir(&emoji_dir).unwrap();
1519
1520 let file_path = emoji_dir.join("TV Shows.md");
1522 File::create(&file_path)
1523 .unwrap()
1524 .write_all(b"# TV Shows\n\nContent here.")
1525 .unwrap();
1526
1527 let content = r#"
1530# Test Document
1531
1532[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1533[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1534"#;
1535
1536 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1537
1538 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540
1541 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1543 assert!(
1544 result[0].message.contains("Missing.md"),
1545 "Warning should be for Missing.md, got: {}",
1546 result[0].message
1547 );
1548 }
1549
1550 #[test]
1551 fn test_no_warnings_without_base_path() {
1552 let rule = MD057ExistingRelativeLinks::new();
1553 let content = "[Link](missing.md)";
1554
1555 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let result = rule.check(&ctx).unwrap();
1557 assert!(result.is_empty(), "Should have no warnings without base path");
1558 }
1559
1560 #[test]
1561 fn test_existing_and_missing_links() {
1562 let temp_dir = tempdir().unwrap();
1564 let base_path = temp_dir.path();
1565
1566 let exists_path = base_path.join("exists.md");
1568 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1569
1570 assert!(exists_path.exists(), "exists.md should exist for this test");
1572
1573 let content = r#"
1575# Test Document
1576
1577[Valid Link](exists.md)
1578[Invalid Link](missing.md)
1579[External Link](https://example.com)
1580[Media Link](image.jpg)
1581 "#;
1582
1583 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1585
1586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let result = rule.check(&ctx).unwrap();
1589
1590 assert_eq!(result.len(), 2);
1592 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1593 assert!(messages.iter().any(|m| m.contains("missing.md")));
1594 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1595 }
1596
1597 #[test]
1598 fn test_angle_bracket_links() {
1599 let temp_dir = tempdir().unwrap();
1601 let base_path = temp_dir.path();
1602
1603 let exists_path = base_path.join("exists.md");
1605 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1606
1607 let content = r#"
1609# Test Document
1610
1611[Valid Link](<exists.md>)
1612[Invalid Link](<missing.md>)
1613[External Link](<https://example.com>)
1614 "#;
1615
1616 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1618
1619 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let result = rule.check(&ctx).unwrap();
1621
1622 assert_eq!(result.len(), 1, "Should have exactly one warning");
1624 assert!(
1625 result[0].message.contains("missing.md"),
1626 "Warning should mention missing.md"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_angle_bracket_links_with_parens() {
1632 let temp_dir = tempdir().unwrap();
1634 let base_path = temp_dir.path();
1635
1636 let app_dir = base_path.join("app");
1638 std::fs::create_dir(&app_dir).unwrap();
1639 let upload_dir = app_dir.join("(upload)");
1640 std::fs::create_dir(&upload_dir).unwrap();
1641 let page_file = upload_dir.join("page.tsx");
1642 File::create(&page_file)
1643 .unwrap()
1644 .write_all(b"export default function Page() {}")
1645 .unwrap();
1646
1647 let content = r#"
1649# Test Document with Paths Containing Parens
1650
1651[Upload Page](<app/(upload)/page.tsx>)
1652[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1653[Missing](<app/(missing)/file.md>)
1654"#;
1655
1656 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1657
1658 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let result = rule.check(&ctx).unwrap();
1660
1661 assert_eq!(
1663 result.len(),
1664 1,
1665 "Should have exactly one warning for missing file. Got: {result:?}"
1666 );
1667 assert!(
1668 result[0].message.contains("app/(missing)/file.md"),
1669 "Warning should mention app/(missing)/file.md"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_all_file_types_checked() {
1675 let temp_dir = tempdir().unwrap();
1677 let base_path = temp_dir.path();
1678
1679 let content = r#"
1681[Image Link](image.jpg)
1682[Video Link](video.mp4)
1683[Markdown Link](document.md)
1684[PDF Link](file.pdf)
1685"#;
1686
1687 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1688
1689 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1690 let result = rule.check(&ctx).unwrap();
1691
1692 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1694 }
1695
1696 #[test]
1697 fn test_code_span_detection() {
1698 let rule = MD057ExistingRelativeLinks::new();
1699
1700 let temp_dir = tempdir().unwrap();
1702 let base_path = temp_dir.path();
1703
1704 let rule = rule.with_path(base_path);
1705
1706 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1708
1709 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1710 let result = rule.check(&ctx).unwrap();
1711
1712 assert_eq!(result.len(), 1, "Should only flag the real link");
1714 assert!(result[0].message.contains("nonexistent.md"));
1715 }
1716
1717 #[test]
1718 fn test_inline_code_spans() {
1719 let temp_dir = tempdir().unwrap();
1721 let base_path = temp_dir.path();
1722
1723 let content = r#"
1725# Test Document
1726
1727This is a normal link: [Link](missing.md)
1728
1729This is a code span with a link: `[Link](another-missing.md)`
1730
1731Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1732
1733 "#;
1734
1735 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1737
1738 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1740 let result = rule.check(&ctx).unwrap();
1741
1742 assert_eq!(result.len(), 1, "Should have exactly one warning");
1744 assert!(
1745 result[0].message.contains("missing.md"),
1746 "Warning should be for missing.md"
1747 );
1748 assert!(
1749 !result.iter().any(|w| w.message.contains("another-missing.md")),
1750 "Should not warn about link in code span"
1751 );
1752 assert!(
1753 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1754 "Should not warn about link in inline code"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_extensionless_link_resolution() {
1760 let temp_dir = tempdir().unwrap();
1762 let base_path = temp_dir.path();
1763
1764 let page_path = base_path.join("page.md");
1766 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1767
1768 let content = r#"
1770# Test Document
1771
1772[Link without extension](page)
1773[Link with extension](page.md)
1774[Missing link](nonexistent)
1775"#;
1776
1777 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1778
1779 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780 let result = rule.check(&ctx).unwrap();
1781
1782 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1785 assert!(
1786 result[0].message.contains("nonexistent"),
1787 "Warning should be for 'nonexistent' not 'page'"
1788 );
1789 }
1790
1791 #[test]
1793 fn test_cross_file_scope() {
1794 let rule = MD057ExistingRelativeLinks::new();
1795 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1796 }
1797
1798 #[test]
1799 fn test_contribute_to_index_extracts_markdown_links() {
1800 let rule = MD057ExistingRelativeLinks::new();
1801 let content = r#"
1802# Document
1803
1804[Link to docs](./docs/guide.md)
1805[Link with fragment](./other.md#section)
1806[External link](https://example.com)
1807[Image link](image.png)
1808[Media file](video.mp4)
1809"#;
1810
1811 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812 let mut index = FileIndex::new();
1813 rule.contribute_to_index(&ctx, &mut index);
1814
1815 assert_eq!(index.cross_file_links.len(), 2);
1817
1818 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1820 assert_eq!(index.cross_file_links[0].fragment, "");
1821
1822 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1824 assert_eq!(index.cross_file_links[1].fragment, "section");
1825 }
1826
1827 #[test]
1828 fn test_contribute_to_index_skips_external_and_anchors() {
1829 let rule = MD057ExistingRelativeLinks::new();
1830 let content = r#"
1831# Document
1832
1833[External](https://example.com)
1834[Another external](http://example.org)
1835[Fragment only](#section)
1836[FTP link](ftp://files.example.com)
1837[Mail link](mailto:test@example.com)
1838[WWW link](www.example.com)
1839"#;
1840
1841 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let mut index = FileIndex::new();
1843 rule.contribute_to_index(&ctx, &mut index);
1844
1845 assert_eq!(index.cross_file_links.len(), 0);
1847 }
1848
1849 #[test]
1850 fn test_cross_file_check_valid_link() {
1851 use crate::workspace_index::WorkspaceIndex;
1852
1853 let rule = MD057ExistingRelativeLinks::new();
1854
1855 let mut workspace_index = WorkspaceIndex::new();
1857 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1858
1859 let mut file_index = FileIndex::new();
1861 file_index.add_cross_file_link(CrossFileLinkIndex {
1862 target_path: "guide.md".to_string(),
1863 fragment: "".to_string(),
1864 line: 5,
1865 column: 1,
1866 });
1867
1868 let warnings = rule
1870 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1871 .unwrap();
1872
1873 assert!(warnings.is_empty());
1875 }
1876
1877 #[test]
1878 fn test_cross_file_check_missing_link() {
1879 use crate::workspace_index::WorkspaceIndex;
1880
1881 let rule = MD057ExistingRelativeLinks::new();
1882
1883 let workspace_index = WorkspaceIndex::new();
1885
1886 let mut file_index = FileIndex::new();
1888 file_index.add_cross_file_link(CrossFileLinkIndex {
1889 target_path: "missing.md".to_string(),
1890 fragment: "".to_string(),
1891 line: 5,
1892 column: 1,
1893 });
1894
1895 let warnings = rule
1897 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1898 .unwrap();
1899
1900 assert_eq!(warnings.len(), 1);
1902 assert!(warnings[0].message.contains("missing.md"));
1903 assert!(warnings[0].message.contains("does not exist"));
1904 }
1905
1906 #[test]
1907 fn test_cross_file_check_parent_path() {
1908 use crate::workspace_index::WorkspaceIndex;
1909
1910 let rule = MD057ExistingRelativeLinks::new();
1911
1912 let mut workspace_index = WorkspaceIndex::new();
1914 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1915
1916 let mut file_index = FileIndex::new();
1918 file_index.add_cross_file_link(CrossFileLinkIndex {
1919 target_path: "../readme.md".to_string(),
1920 fragment: "".to_string(),
1921 line: 5,
1922 column: 1,
1923 });
1924
1925 let warnings = rule
1927 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1928 .unwrap();
1929
1930 assert!(warnings.is_empty());
1932 }
1933
1934 #[test]
1935 fn test_cross_file_check_html_link_with_md_source() {
1936 use crate::workspace_index::WorkspaceIndex;
1939
1940 let rule = MD057ExistingRelativeLinks::new();
1941
1942 let mut workspace_index = WorkspaceIndex::new();
1944 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1945
1946 let mut file_index = FileIndex::new();
1948 file_index.add_cross_file_link(CrossFileLinkIndex {
1949 target_path: "guide.html".to_string(),
1950 fragment: "section".to_string(),
1951 line: 10,
1952 column: 5,
1953 });
1954
1955 let warnings = rule
1957 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1958 .unwrap();
1959
1960 assert!(
1962 warnings.is_empty(),
1963 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_cross_file_check_html_link_without_source() {
1969 use crate::workspace_index::WorkspaceIndex;
1971
1972 let rule = MD057ExistingRelativeLinks::new();
1973
1974 let workspace_index = WorkspaceIndex::new();
1976
1977 let mut file_index = FileIndex::new();
1979 file_index.add_cross_file_link(CrossFileLinkIndex {
1980 target_path: "missing.html".to_string(),
1981 fragment: "".to_string(),
1982 line: 10,
1983 column: 5,
1984 });
1985
1986 let warnings = rule
1988 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1989 .unwrap();
1990
1991 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1993 assert!(warnings[0].message.contains("missing.html"));
1994 }
1995
1996 #[test]
1997 fn test_normalize_path_function() {
1998 assert_eq!(
2000 normalize_path(Path::new("docs/guide.md")),
2001 PathBuf::from("docs/guide.md")
2002 );
2003
2004 assert_eq!(
2006 normalize_path(Path::new("./docs/guide.md")),
2007 PathBuf::from("docs/guide.md")
2008 );
2009
2010 assert_eq!(
2012 normalize_path(Path::new("docs/sub/../guide.md")),
2013 PathBuf::from("docs/guide.md")
2014 );
2015
2016 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2018 }
2019
2020 #[test]
2021 fn test_html_link_with_md_source() {
2022 let temp_dir = tempdir().unwrap();
2024 let base_path = temp_dir.path();
2025
2026 let md_file = base_path.join("guide.md");
2028 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2029
2030 let content = r#"
2031[Read the guide](guide.html)
2032[Also here](getting-started.html)
2033"#;
2034
2035 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2036 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let result = rule.check(&ctx).unwrap();
2038
2039 assert_eq!(
2041 result.len(),
2042 1,
2043 "Should only warn about missing source. Got: {result:?}"
2044 );
2045 assert!(result[0].message.contains("getting-started.html"));
2046 }
2047
2048 #[test]
2049 fn test_htm_link_with_md_source() {
2050 let temp_dir = tempdir().unwrap();
2052 let base_path = temp_dir.path();
2053
2054 let md_file = base_path.join("page.md");
2055 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2056
2057 let content = "[Page](page.htm)";
2058
2059 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2060 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2061 let result = rule.check(&ctx).unwrap();
2062
2063 assert!(
2064 result.is_empty(),
2065 "Should not warn when .md source exists for .htm link"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_html_link_finds_various_markdown_extensions() {
2071 let temp_dir = tempdir().unwrap();
2073 let base_path = temp_dir.path();
2074
2075 File::create(base_path.join("doc.md")).unwrap();
2076 File::create(base_path.join("tutorial.mdx")).unwrap();
2077 File::create(base_path.join("guide.markdown")).unwrap();
2078
2079 let content = r#"
2080[Doc](doc.html)
2081[Tutorial](tutorial.html)
2082[Guide](guide.html)
2083"#;
2084
2085 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2086 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let result = rule.check(&ctx).unwrap();
2088
2089 assert!(
2090 result.is_empty(),
2091 "Should find all markdown variants as source files. Got: {result:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_html_link_in_subdirectory() {
2097 let temp_dir = tempdir().unwrap();
2099 let base_path = temp_dir.path();
2100
2101 let docs_dir = base_path.join("docs");
2102 std::fs::create_dir(&docs_dir).unwrap();
2103 File::create(docs_dir.join("guide.md"))
2104 .unwrap()
2105 .write_all(b"# Guide")
2106 .unwrap();
2107
2108 let content = "[Guide](docs/guide.html)";
2109
2110 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2111 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112 let result = rule.check(&ctx).unwrap();
2113
2114 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2115 }
2116
2117 #[test]
2118 fn test_absolute_path_skipped_in_check() {
2119 let temp_dir = tempdir().unwrap();
2122 let base_path = temp_dir.path();
2123
2124 let content = r#"
2125# Test Document
2126
2127[Go Runtime](/pkg/runtime)
2128[Go Runtime with Fragment](/pkg/runtime#section)
2129[API Docs](/api/v1/users)
2130[Blog Post](/blog/2024/release.html)
2131[React Hook](/react/hooks/use-state.html)
2132"#;
2133
2134 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2135 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert!(
2140 result.is_empty(),
2141 "Absolute paths should be skipped. Got warnings: {result:?}"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_absolute_path_skipped_in_cross_file_check() {
2147 use crate::workspace_index::WorkspaceIndex;
2149
2150 let rule = MD057ExistingRelativeLinks::new();
2151
2152 let workspace_index = WorkspaceIndex::new();
2154
2155 let mut file_index = FileIndex::new();
2157 file_index.add_cross_file_link(CrossFileLinkIndex {
2158 target_path: "/pkg/runtime.md".to_string(),
2159 fragment: "".to_string(),
2160 line: 5,
2161 column: 1,
2162 });
2163 file_index.add_cross_file_link(CrossFileLinkIndex {
2164 target_path: "/api/v1/users.md".to_string(),
2165 fragment: "section".to_string(),
2166 line: 10,
2167 column: 1,
2168 });
2169
2170 let warnings = rule
2172 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2173 .unwrap();
2174
2175 assert!(
2177 warnings.is_empty(),
2178 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_protocol_relative_url_not_skipped() {
2184 let temp_dir = tempdir().unwrap();
2187 let base_path = temp_dir.path();
2188
2189 let content = r#"
2190# Test Document
2191
2192[External](//example.com/page)
2193[Another](//cdn.example.com/asset.js)
2194"#;
2195
2196 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2197 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198 let result = rule.check(&ctx).unwrap();
2199
2200 assert!(
2202 result.is_empty(),
2203 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2204 );
2205 }
2206
2207 #[test]
2208 fn test_email_addresses_skipped() {
2209 let temp_dir = tempdir().unwrap();
2212 let base_path = temp_dir.path();
2213
2214 let content = r#"
2215# Test Document
2216
2217[Contact](user@example.com)
2218[Steering](steering@kubernetes.io)
2219[Support](john.doe+filter@company.co.uk)
2220[User](user_name@sub.domain.com)
2221"#;
2222
2223 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2224 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2225 let result = rule.check(&ctx).unwrap();
2226
2227 assert!(
2229 result.is_empty(),
2230 "Email addresses should be skipped. Got warnings: {result:?}"
2231 );
2232 }
2233
2234 #[test]
2235 fn test_email_addresses_vs_file_paths() {
2236 let temp_dir = tempdir().unwrap();
2239 let base_path = temp_dir.path();
2240
2241 let content = r#"
2242# Test Document
2243
2244[Email](user@example.com) <!-- Should be skipped (email) -->
2245[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2246[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2247"#;
2248
2249 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2250 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2251 let result = rule.check(&ctx).unwrap();
2252
2253 assert!(
2255 result.is_empty(),
2256 "All email addresses should be skipped. Got: {result:?}"
2257 );
2258 }
2259
2260 #[test]
2261 fn test_diagnostic_position_accuracy() {
2262 let temp_dir = tempdir().unwrap();
2264 let base_path = temp_dir.path();
2265
2266 let content = "prefix [text](missing.md) suffix";
2269 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2273 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274 let result = rule.check(&ctx).unwrap();
2275
2276 assert_eq!(result.len(), 1, "Should have exactly one warning");
2277 assert_eq!(result[0].line, 1, "Should be on line 1");
2278 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2279 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2280 }
2281
2282 #[test]
2283 fn test_diagnostic_position_angle_brackets() {
2284 let temp_dir = tempdir().unwrap();
2286 let base_path = temp_dir.path();
2287
2288 let content = "[link](<missing.md>)";
2291 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2294 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295 let result = rule.check(&ctx).unwrap();
2296
2297 assert_eq!(result.len(), 1, "Should have exactly one warning");
2298 assert_eq!(result[0].line, 1, "Should be on line 1");
2299 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2300 }
2301
2302 #[test]
2303 fn test_diagnostic_position_multiline() {
2304 let temp_dir = tempdir().unwrap();
2306 let base_path = temp_dir.path();
2307
2308 let content = r#"# Title
2309Some text on line 2
2310[link on line 3](missing1.md)
2311More text
2312[link on line 5](missing2.md)"#;
2313
2314 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2315 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316 let result = rule.check(&ctx).unwrap();
2317
2318 assert_eq!(result.len(), 2, "Should have two warnings");
2319
2320 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2322 assert!(result[0].message.contains("missing1.md"));
2323
2324 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2326 assert!(result[1].message.contains("missing2.md"));
2327 }
2328
2329 #[test]
2330 fn test_diagnostic_position_with_spaces() {
2331 let temp_dir = tempdir().unwrap();
2333 let base_path = temp_dir.path();
2334
2335 let content = "[link]( missing.md )";
2336 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2341 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342 let result = rule.check(&ctx).unwrap();
2343
2344 assert_eq!(result.len(), 1, "Should have exactly one warning");
2345 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2347 }
2348
2349 #[test]
2350 fn test_diagnostic_position_image() {
2351 let temp_dir = tempdir().unwrap();
2353 let base_path = temp_dir.path();
2354
2355 let content = "";
2356
2357 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2358 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2359 let result = rule.check(&ctx).unwrap();
2360
2361 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2362 assert_eq!(result[0].line, 1);
2363 assert!(result[0].column > 0, "Should have valid column position");
2365 assert!(result[0].message.contains("missing.jpg"));
2366 }
2367
2368 #[test]
2369 fn test_wikilinks_skipped() {
2370 let temp_dir = tempdir().unwrap();
2373 let base_path = temp_dir.path();
2374
2375 let content = r#"# Test Document
2376
2377[[Microsoft#Windows OS]]
2378[[SomePage]]
2379[[Page With Spaces]]
2380[[path/to/page#section]]
2381[[page|Display Text]]
2382
2383This is a [real missing link](missing.md) that should be flagged.
2384"#;
2385
2386 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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!(
2392 result.len(),
2393 1,
2394 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2395 );
2396 assert!(
2397 result[0].message.contains("missing.md"),
2398 "Warning should be for missing.md, not wikilinks"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_wikilinks_not_added_to_index() {
2404 let temp_dir = tempdir().unwrap();
2406 let base_path = temp_dir.path();
2407
2408 let content = r#"# Test Document
2409
2410[[Microsoft#Windows OS]]
2411[[SomePage#section]]
2412[Regular Link](other.md)
2413"#;
2414
2415 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2416 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417
2418 let mut file_index = FileIndex::new();
2419 rule.contribute_to_index(&ctx, &mut file_index);
2420
2421 let cross_file_links = &file_index.cross_file_links;
2424 assert_eq!(
2425 cross_file_links.len(),
2426 1,
2427 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2428 );
2429 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2430 }
2431
2432 #[test]
2433 fn test_reference_definition_missing_file() {
2434 let temp_dir = tempdir().unwrap();
2436 let base_path = temp_dir.path();
2437
2438 let content = r#"# Test Document
2439
2440[test]: ./missing.md
2441[example]: ./nonexistent.html
2442
2443Use [test] and [example] here.
2444"#;
2445
2446 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2447 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448 let result = rule.check(&ctx).unwrap();
2449
2450 assert_eq!(
2452 result.len(),
2453 2,
2454 "Should have warnings for missing reference definition targets. Got: {result:?}"
2455 );
2456 assert!(
2457 result.iter().any(|w| w.message.contains("missing.md")),
2458 "Should warn about missing.md"
2459 );
2460 assert!(
2461 result.iter().any(|w| w.message.contains("nonexistent.html")),
2462 "Should warn about nonexistent.html"
2463 );
2464 }
2465
2466 #[test]
2467 fn test_reference_definition_existing_file() {
2468 let temp_dir = tempdir().unwrap();
2470 let base_path = temp_dir.path();
2471
2472 let exists_path = base_path.join("exists.md");
2474 File::create(&exists_path)
2475 .unwrap()
2476 .write_all(b"# Existing file")
2477 .unwrap();
2478
2479 let content = r#"# Test Document
2480
2481[test]: ./exists.md
2482
2483Use [test] here.
2484"#;
2485
2486 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2487 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2488 let result = rule.check(&ctx).unwrap();
2489
2490 assert!(
2492 result.is_empty(),
2493 "Should not warn about existing file. Got: {result:?}"
2494 );
2495 }
2496
2497 #[test]
2498 fn test_reference_definition_external_url_skipped() {
2499 let temp_dir = tempdir().unwrap();
2501 let base_path = temp_dir.path();
2502
2503 let content = r#"# Test Document
2504
2505[google]: https://google.com
2506[example]: http://example.org
2507[mail]: mailto:test@example.com
2508[ftp]: ftp://files.example.com
2509[local]: ./missing.md
2510
2511Use [google], [example], [mail], [ftp], [local] here.
2512"#;
2513
2514 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2515 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2516 let result = rule.check(&ctx).unwrap();
2517
2518 assert_eq!(
2520 result.len(),
2521 1,
2522 "Should only warn about local missing file. Got: {result:?}"
2523 );
2524 assert!(
2525 result[0].message.contains("missing.md"),
2526 "Warning should be for missing.md"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_reference_definition_fragment_only_skipped() {
2532 let temp_dir = tempdir().unwrap();
2534 let base_path = temp_dir.path();
2535
2536 let content = r#"# Test Document
2537
2538[section]: #my-section
2539
2540Use [section] here.
2541"#;
2542
2543 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2544 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2545 let result = rule.check(&ctx).unwrap();
2546
2547 assert!(
2549 result.is_empty(),
2550 "Should not warn about fragment-only reference. Got: {result:?}"
2551 );
2552 }
2553
2554 #[test]
2555 fn test_reference_definition_column_position() {
2556 let temp_dir = tempdir().unwrap();
2558 let base_path = temp_dir.path();
2559
2560 let content = "[ref]: ./missing.md";
2563 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2567 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2568 let result = rule.check(&ctx).unwrap();
2569
2570 assert_eq!(result.len(), 1, "Should have exactly one warning");
2571 assert_eq!(result[0].line, 1, "Should be on line 1");
2572 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2573 }
2574
2575 #[test]
2576 fn test_reference_definition_html_with_md_source() {
2577 let temp_dir = tempdir().unwrap();
2579 let base_path = temp_dir.path();
2580
2581 let md_file = base_path.join("guide.md");
2583 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2584
2585 let content = r#"# Test Document
2586
2587[guide]: ./guide.html
2588[missing]: ./missing.html
2589
2590Use [guide] and [missing] here.
2591"#;
2592
2593 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2594 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2595 let result = rule.check(&ctx).unwrap();
2596
2597 assert_eq!(
2599 result.len(),
2600 1,
2601 "Should only warn about missing source. Got: {result:?}"
2602 );
2603 assert!(result[0].message.contains("missing.html"));
2604 }
2605
2606 #[test]
2607 fn test_reference_definition_url_encoded() {
2608 let temp_dir = tempdir().unwrap();
2610 let base_path = temp_dir.path();
2611
2612 let file_with_spaces = base_path.join("file with spaces.md");
2614 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2615
2616 let content = r#"# Test Document
2617
2618[spaces]: ./file%20with%20spaces.md
2619[missing]: ./missing%20file.md
2620
2621Use [spaces] and [missing] here.
2622"#;
2623
2624 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2625 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2626 let result = rule.check(&ctx).unwrap();
2627
2628 assert_eq!(
2630 result.len(),
2631 1,
2632 "Should only warn about missing URL-encoded file. Got: {result:?}"
2633 );
2634 assert!(result[0].message.contains("missing%20file.md"));
2635 }
2636
2637 #[test]
2638 fn test_inline_and_reference_both_checked() {
2639 let temp_dir = tempdir().unwrap();
2641 let base_path = temp_dir.path();
2642
2643 let content = r#"# Test Document
2644
2645[inline link](./inline-missing.md)
2646[ref]: ./ref-missing.md
2647
2648Use [ref] here.
2649"#;
2650
2651 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2652 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2653 let result = rule.check(&ctx).unwrap();
2654
2655 assert_eq!(
2657 result.len(),
2658 2,
2659 "Should warn about both inline and reference links. Got: {result:?}"
2660 );
2661 assert!(
2662 result.iter().any(|w| w.message.contains("inline-missing.md")),
2663 "Should warn about inline-missing.md"
2664 );
2665 assert!(
2666 result.iter().any(|w| w.message.contains("ref-missing.md")),
2667 "Should warn about ref-missing.md"
2668 );
2669 }
2670
2671 #[test]
2672 fn test_footnote_definitions_not_flagged() {
2673 let rule = MD057ExistingRelativeLinks::default();
2676
2677 let content = r#"# Title
2678
2679A footnote[^1].
2680
2681[^1]: [link](https://www.google.com).
2682"#;
2683
2684 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2685 let result = rule.check(&ctx).unwrap();
2686
2687 assert!(
2688 result.is_empty(),
2689 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2690 );
2691 }
2692
2693 #[test]
2694 fn test_footnote_with_relative_link_inside() {
2695 let rule = MD057ExistingRelativeLinks::default();
2698
2699 let content = r#"# Title
2700
2701See the footnote[^1].
2702
2703[^1]: Check out [this file](./existing.md) for more info.
2704[^2]: Also see [missing](./does-not-exist.md).
2705"#;
2706
2707 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2708 let result = rule.check(&ctx).unwrap();
2709
2710 for warning in &result {
2715 assert!(
2716 !warning.message.contains("[this file]"),
2717 "Footnote content should not be treated as URL: {warning:?}"
2718 );
2719 assert!(
2720 !warning.message.contains("[missing]"),
2721 "Footnote content should not be treated as URL: {warning:?}"
2722 );
2723 }
2724 }
2725
2726 #[test]
2727 fn test_mixed_footnotes_and_reference_definitions() {
2728 let temp_dir = tempdir().unwrap();
2730 let base_path = temp_dir.path();
2731
2732 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2733
2734 let content = r#"# Title
2735
2736A footnote[^1] and a [ref link][myref].
2737
2738[^1]: This is a footnote with [link](https://example.com).
2739
2740[myref]: ./missing-file.md "This should be checked"
2741"#;
2742
2743 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2744 let result = rule.check(&ctx).unwrap();
2745
2746 assert_eq!(
2748 result.len(),
2749 1,
2750 "Should only warn about the regular reference definition. Got: {result:?}"
2751 );
2752 assert!(
2753 result[0].message.contains("missing-file.md"),
2754 "Should warn about missing-file.md in reference definition"
2755 );
2756 }
2757
2758 #[test]
2759 fn test_absolute_links_ignore_by_default() {
2760 let temp_dir = tempdir().unwrap();
2762 let base_path = temp_dir.path();
2763
2764 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2765
2766 let content = r#"# Links
2767
2768[API docs](/api/v1/users)
2769[Blog post](/blog/2024/release.html)
2770
2771
2772[ref]: /docs/reference.md
2773"#;
2774
2775 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2776 let result = rule.check(&ctx).unwrap();
2777
2778 assert!(
2780 result.is_empty(),
2781 "Absolute links should be ignored by default. Got: {result:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn test_absolute_links_warn_config() {
2787 let temp_dir = tempdir().unwrap();
2789 let base_path = temp_dir.path();
2790
2791 let config = MD057Config {
2792 absolute_links: AbsoluteLinksOption::Warn,
2793 ..Default::default()
2794 };
2795 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2796
2797 let content = r#"# Links
2798
2799[API docs](/api/v1/users)
2800[Blog post](/blog/2024/release.html)
2801"#;
2802
2803 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2804 let result = rule.check(&ctx).unwrap();
2805
2806 assert_eq!(
2808 result.len(),
2809 2,
2810 "Should warn about both absolute links. Got: {result:?}"
2811 );
2812 assert!(
2813 result[0].message.contains("cannot be validated locally"),
2814 "Warning should explain why: {}",
2815 result[0].message
2816 );
2817 assert!(
2818 result[0].message.contains("/api/v1/users"),
2819 "Warning should include the link path"
2820 );
2821 }
2822
2823 #[test]
2824 fn test_absolute_links_warn_images() {
2825 let temp_dir = tempdir().unwrap();
2827 let base_path = temp_dir.path();
2828
2829 let config = MD057Config {
2830 absolute_links: AbsoluteLinksOption::Warn,
2831 ..Default::default()
2832 };
2833 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2834
2835 let content = r#"# Images
2836
2837
2838"#;
2839
2840 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2841 let result = rule.check(&ctx).unwrap();
2842
2843 assert_eq!(
2844 result.len(),
2845 1,
2846 "Should warn about absolute image path. Got: {result:?}"
2847 );
2848 assert!(
2849 result[0].message.contains("/assets/logo.png"),
2850 "Warning should include the image path"
2851 );
2852 }
2853
2854 #[test]
2855 fn test_absolute_links_warn_reference_definitions() {
2856 let temp_dir = tempdir().unwrap();
2858 let base_path = temp_dir.path();
2859
2860 let config = MD057Config {
2861 absolute_links: AbsoluteLinksOption::Warn,
2862 ..Default::default()
2863 };
2864 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2865
2866 let content = r#"# Reference
2867
2868See the [docs][ref].
2869
2870[ref]: /docs/reference.md
2871"#;
2872
2873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2874 let result = rule.check(&ctx).unwrap();
2875
2876 assert_eq!(
2877 result.len(),
2878 1,
2879 "Should warn about absolute reference definition. Got: {result:?}"
2880 );
2881 assert!(
2882 result[0].message.contains("/docs/reference.md"),
2883 "Warning should include the reference path"
2884 );
2885 }
2886}