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