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) {
466 continue;
467 }
468
469 if !processed_lines.insert(line_idx) {
471 continue;
472 }
473
474 let line = lines[line_idx];
475
476 if !line.contains("](") {
478 continue;
479 }
480
481 for link_match in LINK_START_REGEX.find_iter(line) {
483 let start_pos = link_match.start();
484 let end_pos = link_match.end();
485
486 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
488 let absolute_start_pos = line_start_byte + start_pos;
489
490 if element_cache.is_in_code_span(absolute_start_pos) {
492 continue;
493 }
494
495 if ctx.is_in_math_span(absolute_start_pos) {
497 continue;
498 }
499
500 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
504 .captures_at(line, end_pos - 1)
505 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
506 .or_else(|| {
507 URL_EXTRACT_REGEX
508 .captures_at(line, end_pos - 1)
509 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
510 });
511
512 if let Some((caps, url_group)) = caps_and_url {
513 let url = url_group.as_str().trim();
514
515 if url.is_empty() {
517 continue;
518 }
519
520 if url.starts_with('`') && url.ends_with('`') {
524 continue;
525 }
526
527 if self.is_external_url(url) || self.is_fragment_only_link(url) {
529 continue;
530 }
531
532 if Self::is_absolute_path(url) {
534 match self.config.absolute_links {
535 AbsoluteLinksOption::Warn => {
536 let url_start = url_group.start();
537 let url_end = url_group.end();
538 warnings.push(LintWarning {
539 rule_name: Some(self.name().to_string()),
540 line: link.line,
541 column: url_start + 1,
542 end_line: link.line,
543 end_column: url_end + 1,
544 message: format!("Absolute link '{url}' cannot be validated locally"),
545 severity: Severity::Warning,
546 fix: None,
547 });
548 }
549 AbsoluteLinksOption::RelativeToDocs => {
550 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
551 let url_start = url_group.start();
552 let url_end = url_group.end();
553 warnings.push(LintWarning {
554 rule_name: Some(self.name().to_string()),
555 line: link.line,
556 column: url_start + 1,
557 end_line: link.line,
558 end_column: url_end + 1,
559 message: msg,
560 severity: Severity::Warning,
561 fix: None,
562 });
563 }
564 }
565 AbsoluteLinksOption::Ignore => {}
566 }
567 continue;
568 }
569
570 let full_url_for_compact = if let Some(frag) = caps.get(2) {
574 format!("{url}{}", frag.as_str())
575 } else {
576 url.to_string()
577 };
578 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
579 let url_start = url_group.start();
580 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
581 let fix_byte_start = line_start_byte + url_start;
582 let fix_byte_end = line_start_byte + url_end;
583 warnings.push(LintWarning {
584 rule_name: Some(self.name().to_string()),
585 line: link.line,
586 column: url_start + 1,
587 end_line: link.line,
588 end_column: url_end + 1,
589 message: format!(
590 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
591 ),
592 severity: Severity::Warning,
593 fix: Some(Fix {
594 range: fix_byte_start..fix_byte_end,
595 replacement: suggestion,
596 }),
597 });
598 }
599
600 let file_path = Self::strip_query_and_fragment(url);
602
603 let decoded_path = Self::url_decode(file_path);
605
606 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
608
609 if file_exists_or_markdown_extension(&resolved_path) {
611 continue; }
613
614 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
616 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
617 && let (Some(stem), Some(parent)) = (
618 resolved_path.file_stem().and_then(|s| s.to_str()),
619 resolved_path.parent(),
620 ) {
621 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
622 let source_path = parent.join(format!("{stem}{md_ext}"));
623 file_exists_with_cache(&source_path)
624 })
625 } else {
626 false
627 };
628
629 if has_md_source {
630 continue; }
632
633 let url_start = url_group.start();
637 let url_end = url_group.end();
638
639 warnings.push(LintWarning {
640 rule_name: Some(self.name().to_string()),
641 line: link.line,
642 column: url_start + 1, end_line: link.line,
644 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
646 severity: Severity::Error,
647 fix: None,
648 });
649 }
650 }
651 }
652 }
653
654 for image in &ctx.images {
656 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
658 continue;
659 }
660
661 let url = image.url.as_ref();
662
663 if url.is_empty() {
665 continue;
666 }
667
668 if self.is_external_url(url) || self.is_fragment_only_link(url) {
670 continue;
671 }
672
673 if Self::is_absolute_path(url) {
675 match self.config.absolute_links {
676 AbsoluteLinksOption::Warn => {
677 warnings.push(LintWarning {
678 rule_name: Some(self.name().to_string()),
679 line: image.line,
680 column: image.start_col + 1,
681 end_line: image.line,
682 end_column: image.start_col + 1 + url.len(),
683 message: format!("Absolute link '{url}' cannot be validated locally"),
684 severity: Severity::Warning,
685 fix: None,
686 });
687 }
688 AbsoluteLinksOption::RelativeToDocs => {
689 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
690 warnings.push(LintWarning {
691 rule_name: Some(self.name().to_string()),
692 line: image.line,
693 column: image.start_col + 1,
694 end_line: image.line,
695 end_column: image.start_col + 1 + url.len(),
696 message: msg,
697 severity: Severity::Warning,
698 fix: None,
699 });
700 }
701 }
702 AbsoluteLinksOption::Ignore => {}
703 }
704 continue;
705 }
706
707 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
709 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
712 let fix_byte_start = image.byte_offset + url_offset;
713 let fix_byte_end = fix_byte_start + url.len();
714 Fix {
715 range: fix_byte_start..fix_byte_end,
716 replacement: suggestion.clone(),
717 }
718 });
719
720 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
721 let url_col = fix
722 .as_ref()
723 .map_or(image.start_col + 1, |f| f.range.start - img_line_start_byte + 1);
724 warnings.push(LintWarning {
725 rule_name: Some(self.name().to_string()),
726 line: image.line,
727 column: url_col,
728 end_line: image.line,
729 end_column: url_col + url.len(),
730 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
731 severity: Severity::Warning,
732 fix,
733 });
734 }
735
736 let file_path = Self::strip_query_and_fragment(url);
738
739 let decoded_path = Self::url_decode(file_path);
741
742 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
744
745 if file_exists_or_markdown_extension(&resolved_path) {
747 continue; }
749
750 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
752 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
753 && let (Some(stem), Some(parent)) = (
754 resolved_path.file_stem().and_then(|s| s.to_str()),
755 resolved_path.parent(),
756 ) {
757 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
758 let source_path = parent.join(format!("{stem}{md_ext}"));
759 file_exists_with_cache(&source_path)
760 })
761 } else {
762 false
763 };
764
765 if has_md_source {
766 continue; }
768
769 warnings.push(LintWarning {
772 rule_name: Some(self.name().to_string()),
773 line: image.line,
774 column: image.start_col + 1,
775 end_line: image.line,
776 end_column: image.start_col + 1 + url.len(),
777 message: format!("Relative link '{url}' does not exist"),
778 severity: Severity::Error,
779 fix: None,
780 });
781 }
782
783 for ref_def in &ctx.reference_defs {
785 let url = &ref_def.url;
786
787 if url.is_empty() {
789 continue;
790 }
791
792 if self.is_external_url(url) || self.is_fragment_only_link(url) {
794 continue;
795 }
796
797 if Self::is_absolute_path(url) {
799 match self.config.absolute_links {
800 AbsoluteLinksOption::Warn => {
801 let line_idx = ref_def.line - 1;
802 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
803 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
804 });
805 warnings.push(LintWarning {
806 rule_name: Some(self.name().to_string()),
807 line: ref_def.line,
808 column,
809 end_line: ref_def.line,
810 end_column: column + url.len(),
811 message: format!("Absolute link '{url}' cannot be validated locally"),
812 severity: Severity::Warning,
813 fix: None,
814 });
815 }
816 AbsoluteLinksOption::RelativeToDocs => {
817 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
818 let line_idx = ref_def.line - 1;
819 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
820 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
821 });
822 warnings.push(LintWarning {
823 rule_name: Some(self.name().to_string()),
824 line: ref_def.line,
825 column,
826 end_line: ref_def.line,
827 end_column: column + url.len(),
828 message: msg,
829 severity: Severity::Warning,
830 fix: None,
831 });
832 }
833 }
834 AbsoluteLinksOption::Ignore => {}
835 }
836 continue;
837 }
838
839 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
841 let ref_line_idx = ref_def.line - 1;
842 let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
843 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
844 });
845 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
846 let fix_byte_start = ref_line_start_byte + col - 1;
847 let fix_byte_end = fix_byte_start + url.len();
848 warnings.push(LintWarning {
849 rule_name: Some(self.name().to_string()),
850 line: ref_def.line,
851 column: col,
852 end_line: ref_def.line,
853 end_column: col + url.len(),
854 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
855 severity: Severity::Warning,
856 fix: Some(Fix {
857 range: fix_byte_start..fix_byte_end,
858 replacement: suggestion,
859 }),
860 });
861 }
862
863 let file_path = Self::strip_query_and_fragment(url);
865
866 let decoded_path = Self::url_decode(file_path);
868
869 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
871
872 if file_exists_or_markdown_extension(&resolved_path) {
874 continue; }
876
877 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
879 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
880 && let (Some(stem), Some(parent)) = (
881 resolved_path.file_stem().and_then(|s| s.to_str()),
882 resolved_path.parent(),
883 ) {
884 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
885 let source_path = parent.join(format!("{stem}{md_ext}"));
886 file_exists_with_cache(&source_path)
887 })
888 } else {
889 false
890 };
891
892 if has_md_source {
893 continue; }
895
896 let line_idx = ref_def.line - 1;
899 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
900 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
902 });
903
904 warnings.push(LintWarning {
905 rule_name: Some(self.name().to_string()),
906 line: ref_def.line,
907 column,
908 end_line: ref_def.line,
909 end_column: column + url.len(),
910 message: format!("Relative link '{url}' does not exist"),
911 severity: Severity::Error,
912 fix: None,
913 });
914 }
915
916 Ok(warnings)
917 }
918
919 fn fix_capability(&self) -> FixCapability {
920 if self.config.compact_paths {
921 FixCapability::ConditionallyFixable
922 } else {
923 FixCapability::Unfixable
924 }
925 }
926
927 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
928 if !self.config.compact_paths {
929 return Ok(ctx.content.to_string());
930 }
931
932 let warnings = self.check(ctx)?;
933 let mut content = ctx.content.to_string();
934
935 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
937 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
938
939 for fix in fixes {
940 if fix.range.end <= content.len() {
941 content.replace_range(fix.range.clone(), &fix.replacement);
942 }
943 }
944
945 Ok(content)
946 }
947
948 fn as_any(&self) -> &dyn std::any::Any {
949 self
950 }
951
952 fn default_config_section(&self) -> Option<(String, toml::Value)> {
953 let default_config = MD057Config::default();
954 let json_value = serde_json::to_value(&default_config).ok()?;
955 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
956
957 if let toml::Value::Table(table) = toml_value {
958 if !table.is_empty() {
959 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
960 } else {
961 None
962 }
963 } else {
964 None
965 }
966 }
967
968 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
969 where
970 Self: Sized,
971 {
972 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
973 Box::new(Self::from_config_struct(rule_config))
974 }
975
976 fn cross_file_scope(&self) -> CrossFileScope {
977 CrossFileScope::Workspace
978 }
979
980 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
981 for link in extract_cross_file_links(ctx) {
984 index.add_cross_file_link(link);
985 }
986 }
987
988 fn cross_file_check(
989 &self,
990 file_path: &Path,
991 file_index: &FileIndex,
992 workspace_index: &crate::workspace_index::WorkspaceIndex,
993 ) -> LintResult {
994 let mut warnings = Vec::new();
995
996 let file_dir = file_path.parent();
998
999 for cross_link in &file_index.cross_file_links {
1000 let decoded_target = Self::url_decode(&cross_link.target_path);
1003
1004 if decoded_target.starts_with('/') {
1008 continue;
1009 }
1010
1011 let target_path = if let Some(dir) = file_dir {
1013 dir.join(&decoded_target)
1014 } else {
1015 Path::new(&decoded_target).to_path_buf()
1016 };
1017
1018 let target_path = normalize_path(&target_path);
1020
1021 let file_exists =
1023 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1024
1025 if !file_exists {
1026 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1029 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1030 && let (Some(stem), Some(parent)) =
1031 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1032 {
1033 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1034 let source_path = parent.join(format!("{stem}{md_ext}"));
1035 workspace_index.contains_file(&source_path) || source_path.exists()
1036 })
1037 } else {
1038 false
1039 };
1040
1041 if !has_md_source {
1042 warnings.push(LintWarning {
1043 rule_name: Some(self.name().to_string()),
1044 line: cross_link.line,
1045 column: cross_link.column,
1046 end_line: cross_link.line,
1047 end_column: cross_link.column + cross_link.target_path.len(),
1048 message: format!("Relative link '{}' does not exist", cross_link.target_path),
1049 severity: Severity::Error,
1050 fix: None,
1051 });
1052 }
1053 }
1054 }
1055
1056 Ok(warnings)
1057 }
1058}
1059
1060fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1065 let from_components: Vec<_> = from_dir.components().collect();
1066 let to_components: Vec<_> = to_path.components().collect();
1067
1068 let common_len = from_components
1070 .iter()
1071 .zip(to_components.iter())
1072 .take_while(|(a, b)| a == b)
1073 .count();
1074
1075 let mut result = PathBuf::new();
1076
1077 for _ in common_len..from_components.len() {
1079 result.push("..");
1080 }
1081
1082 for component in &to_components[common_len..] {
1084 result.push(component);
1085 }
1086
1087 result
1088}
1089
1090fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1096 let link_path = Path::new(raw_link_path);
1097
1098 let has_traversal = link_path
1100 .components()
1101 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1102
1103 if !has_traversal {
1104 return None;
1105 }
1106
1107 let combined = source_dir.join(link_path);
1109 let normalized_target = normalize_path(&combined);
1110
1111 let normalized_source = normalize_path(source_dir);
1113 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1114
1115 if shortest != link_path {
1117 let compact = shortest.to_string_lossy().to_string();
1118 if compact.is_empty() {
1120 return None;
1121 }
1122 Some(compact.replace('\\', "/"))
1124 } else {
1125 None
1126 }
1127}
1128
1129fn normalize_path(path: &Path) -> PathBuf {
1131 let mut components = Vec::new();
1132
1133 for component in path.components() {
1134 match component {
1135 std::path::Component::ParentDir => {
1136 if !components.is_empty() {
1138 components.pop();
1139 }
1140 }
1141 std::path::Component::CurDir => {
1142 }
1144 _ => {
1145 components.push(component);
1146 }
1147 }
1148 }
1149
1150 components.iter().collect()
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use crate::workspace_index::CrossFileLinkIndex;
1157 use std::fs::File;
1158 use std::io::Write;
1159 use tempfile::tempdir;
1160
1161 #[test]
1162 fn test_strip_query_and_fragment() {
1163 assert_eq!(
1165 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1166 "file.png"
1167 );
1168 assert_eq!(
1169 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1170 "file.png"
1171 );
1172 assert_eq!(
1173 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1174 "file.png"
1175 );
1176
1177 assert_eq!(
1179 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1180 "file.md"
1181 );
1182 assert_eq!(
1183 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1184 "file.md"
1185 );
1186
1187 assert_eq!(
1189 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1190 "file.md"
1191 );
1192
1193 assert_eq!(
1195 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1196 "file.png"
1197 );
1198
1199 assert_eq!(
1201 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1202 "path/to/image.png"
1203 );
1204 assert_eq!(
1205 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1206 "path/to/image.png"
1207 );
1208
1209 assert_eq!(
1211 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1212 "file.md"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_url_decode() {
1218 assert_eq!(
1220 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1221 "penguin with space.jpg"
1222 );
1223
1224 assert_eq!(
1226 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1227 "assets/my file name.png"
1228 );
1229
1230 assert_eq!(
1232 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1233 "hello world!.md"
1234 );
1235
1236 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1238
1239 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1241
1242 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1244
1245 assert_eq!(
1247 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1248 "normal-file.md"
1249 );
1250
1251 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1253
1254 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1256
1257 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1259
1260 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1262
1263 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1265
1266 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1268
1269 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1271
1272 assert_eq!(
1274 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1275 "path/to/file.md"
1276 );
1277
1278 assert_eq!(
1280 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1281 "hello world/foo bar.md"
1282 );
1283
1284 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1286
1287 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1289 }
1290
1291 #[test]
1292 fn test_url_encoded_filenames() {
1293 let temp_dir = tempdir().unwrap();
1295 let base_path = temp_dir.path();
1296
1297 let file_with_spaces = base_path.join("penguin with space.jpg");
1299 File::create(&file_with_spaces)
1300 .unwrap()
1301 .write_all(b"image data")
1302 .unwrap();
1303
1304 let subdir = base_path.join("my images");
1306 std::fs::create_dir(&subdir).unwrap();
1307 let nested_file = subdir.join("photo 1.png");
1308 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1309
1310 let content = r#"
1312# Test Document with URL-Encoded Links
1313
1314
1315
1316
1317"#;
1318
1319 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1320
1321 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323
1324 assert_eq!(
1326 result.len(),
1327 1,
1328 "Should only warn about missing%20file.jpg. Got: {result:?}"
1329 );
1330 assert!(
1331 result[0].message.contains("missing%20file.jpg"),
1332 "Warning should mention the URL-encoded filename"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_external_urls() {
1338 let rule = MD057ExistingRelativeLinks::new();
1339
1340 assert!(rule.is_external_url("https://example.com"));
1342 assert!(rule.is_external_url("http://example.com"));
1343 assert!(rule.is_external_url("ftp://example.com"));
1344 assert!(rule.is_external_url("www.example.com"));
1345 assert!(rule.is_external_url("example.com"));
1346
1347 assert!(rule.is_external_url("file:///path/to/file"));
1349 assert!(rule.is_external_url("smb://server/share"));
1350 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1351 assert!(rule.is_external_url("mailto:user@example.com"));
1352 assert!(rule.is_external_url("tel:+1234567890"));
1353 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1354 assert!(rule.is_external_url("javascript:void(0)"));
1355 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1356 assert!(rule.is_external_url("git://github.com/repo.git"));
1357
1358 assert!(rule.is_external_url("user@example.com"));
1361 assert!(rule.is_external_url("steering@kubernetes.io"));
1362 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1363 assert!(rule.is_external_url("user_name@sub.domain.com"));
1364 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1365
1366 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"));
1377 assert!(!rule.is_external_url("/blog/2024/release.html"));
1378 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1379 assert!(!rule.is_external_url("/pkg/runtime"));
1380 assert!(!rule.is_external_url("/doc/go1compat"));
1381 assert!(!rule.is_external_url("/index.html"));
1382 assert!(!rule.is_external_url("/assets/logo.png"));
1383
1384 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1386 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1387 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1388 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1389 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1390
1391 assert!(rule.is_external_url("~/assets/image.png"));
1394 assert!(rule.is_external_url("~/components/Button.vue"));
1395 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1399 assert!(rule.is_external_url("@images/photo.jpg"));
1400 assert!(rule.is_external_url("@assets/styles.css"));
1401
1402 assert!(!rule.is_external_url("./relative/path.md"));
1404 assert!(!rule.is_external_url("relative/path.md"));
1405 assert!(!rule.is_external_url("../parent/path.md"));
1406 }
1407
1408 #[test]
1409 fn test_framework_path_aliases() {
1410 let temp_dir = tempdir().unwrap();
1412 let base_path = temp_dir.path();
1413
1414 let content = r#"
1416# Framework Path Aliases
1417
1418
1419
1420
1421
1422[Link](@/pages/about.md)
1423
1424This is a [real missing link](missing.md) that should be flagged.
1425"#;
1426
1427 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1428
1429 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let result = rule.check(&ctx).unwrap();
1431
1432 assert_eq!(
1434 result.len(),
1435 1,
1436 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1437 );
1438 assert!(
1439 result[0].message.contains("missing.md"),
1440 "Warning should be for missing.md"
1441 );
1442 }
1443
1444 #[test]
1445 fn test_url_decode_security_path_traversal() {
1446 let temp_dir = tempdir().unwrap();
1449 let base_path = temp_dir.path();
1450
1451 let file_in_base = base_path.join("safe.md");
1453 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1454
1455 let content = r#"
1460[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1461[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1462[Safe link](safe.md)
1463"#;
1464
1465 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1466
1467 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let result = rule.check(&ctx).unwrap();
1469
1470 assert_eq!(
1473 result.len(),
1474 2,
1475 "Should have warnings for traversal attempts. Got: {result:?}"
1476 );
1477 }
1478
1479 #[test]
1480 fn test_url_encoded_utf8_filenames() {
1481 let temp_dir = tempdir().unwrap();
1483 let base_path = temp_dir.path();
1484
1485 let cafe_file = base_path.join("café.md");
1487 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1488
1489 let content = r#"
1490[Café link](caf%C3%A9.md)
1491[Missing unicode](r%C3%A9sum%C3%A9.md)
1492"#;
1493
1494 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1495
1496 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let result = rule.check(&ctx).unwrap();
1498
1499 assert_eq!(
1501 result.len(),
1502 1,
1503 "Should only warn about missing résumé.md. Got: {result:?}"
1504 );
1505 assert!(
1506 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1507 "Warning should mention the URL-encoded filename"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_url_encoded_emoji_filenames() {
1513 let temp_dir = tempdir().unwrap();
1516 let base_path = temp_dir.path();
1517
1518 let emoji_dir = base_path.join("👤 Personal");
1520 std::fs::create_dir(&emoji_dir).unwrap();
1521
1522 let file_path = emoji_dir.join("TV Shows.md");
1524 File::create(&file_path)
1525 .unwrap()
1526 .write_all(b"# TV Shows\n\nContent here.")
1527 .unwrap();
1528
1529 let content = r#"
1532# Test Document
1533
1534[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1535[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1536"#;
1537
1538 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1539
1540 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541 let result = rule.check(&ctx).unwrap();
1542
1543 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1545 assert!(
1546 result[0].message.contains("Missing.md"),
1547 "Warning should be for Missing.md, got: {}",
1548 result[0].message
1549 );
1550 }
1551
1552 #[test]
1553 fn test_no_warnings_without_base_path() {
1554 let rule = MD057ExistingRelativeLinks::new();
1555 let content = "[Link](missing.md)";
1556
1557 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559 assert!(result.is_empty(), "Should have no warnings without base path");
1560 }
1561
1562 #[test]
1563 fn test_existing_and_missing_links() {
1564 let temp_dir = tempdir().unwrap();
1566 let base_path = temp_dir.path();
1567
1568 let exists_path = base_path.join("exists.md");
1570 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1571
1572 assert!(exists_path.exists(), "exists.md should exist for this test");
1574
1575 let content = r#"
1577# Test Document
1578
1579[Valid Link](exists.md)
1580[Invalid Link](missing.md)
1581[External Link](https://example.com)
1582[Media Link](image.jpg)
1583 "#;
1584
1585 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1587
1588 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let result = rule.check(&ctx).unwrap();
1591
1592 assert_eq!(result.len(), 2);
1594 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1595 assert!(messages.iter().any(|m| m.contains("missing.md")));
1596 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1597 }
1598
1599 #[test]
1600 fn test_angle_bracket_links() {
1601 let temp_dir = tempdir().unwrap();
1603 let base_path = temp_dir.path();
1604
1605 let exists_path = base_path.join("exists.md");
1607 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1608
1609 let content = r#"
1611# Test Document
1612
1613[Valid Link](<exists.md>)
1614[Invalid Link](<missing.md>)
1615[External Link](<https://example.com>)
1616 "#;
1617
1618 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1620
1621 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let result = rule.check(&ctx).unwrap();
1623
1624 assert_eq!(result.len(), 1, "Should have exactly one warning");
1626 assert!(
1627 result[0].message.contains("missing.md"),
1628 "Warning should mention missing.md"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_angle_bracket_links_with_parens() {
1634 let temp_dir = tempdir().unwrap();
1636 let base_path = temp_dir.path();
1637
1638 let app_dir = base_path.join("app");
1640 std::fs::create_dir(&app_dir).unwrap();
1641 let upload_dir = app_dir.join("(upload)");
1642 std::fs::create_dir(&upload_dir).unwrap();
1643 let page_file = upload_dir.join("page.tsx");
1644 File::create(&page_file)
1645 .unwrap()
1646 .write_all(b"export default function Page() {}")
1647 .unwrap();
1648
1649 let content = r#"
1651# Test Document with Paths Containing Parens
1652
1653[Upload Page](<app/(upload)/page.tsx>)
1654[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1655[Missing](<app/(missing)/file.md>)
1656"#;
1657
1658 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1659
1660 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let result = rule.check(&ctx).unwrap();
1662
1663 assert_eq!(
1665 result.len(),
1666 1,
1667 "Should have exactly one warning for missing file. Got: {result:?}"
1668 );
1669 assert!(
1670 result[0].message.contains("app/(missing)/file.md"),
1671 "Warning should mention app/(missing)/file.md"
1672 );
1673 }
1674
1675 #[test]
1676 fn test_all_file_types_checked() {
1677 let temp_dir = tempdir().unwrap();
1679 let base_path = temp_dir.path();
1680
1681 let content = r#"
1683[Image Link](image.jpg)
1684[Video Link](video.mp4)
1685[Markdown Link](document.md)
1686[PDF Link](file.pdf)
1687"#;
1688
1689 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1690
1691 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1692 let result = rule.check(&ctx).unwrap();
1693
1694 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1696 }
1697
1698 #[test]
1699 fn test_code_span_detection() {
1700 let rule = MD057ExistingRelativeLinks::new();
1701
1702 let temp_dir = tempdir().unwrap();
1704 let base_path = temp_dir.path();
1705
1706 let rule = rule.with_path(base_path);
1707
1708 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1710
1711 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1712 let result = rule.check(&ctx).unwrap();
1713
1714 assert_eq!(result.len(), 1, "Should only flag the real link");
1716 assert!(result[0].message.contains("nonexistent.md"));
1717 }
1718
1719 #[test]
1720 fn test_inline_code_spans() {
1721 let temp_dir = tempdir().unwrap();
1723 let base_path = temp_dir.path();
1724
1725 let content = r#"
1727# Test Document
1728
1729This is a normal link: [Link](missing.md)
1730
1731This is a code span with a link: `[Link](another-missing.md)`
1732
1733Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1734
1735 "#;
1736
1737 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1739
1740 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743
1744 assert_eq!(result.len(), 1, "Should have exactly one warning");
1746 assert!(
1747 result[0].message.contains("missing.md"),
1748 "Warning should be for missing.md"
1749 );
1750 assert!(
1751 !result.iter().any(|w| w.message.contains("another-missing.md")),
1752 "Should not warn about link in code span"
1753 );
1754 assert!(
1755 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1756 "Should not warn about link in inline code"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_extensionless_link_resolution() {
1762 let temp_dir = tempdir().unwrap();
1764 let base_path = temp_dir.path();
1765
1766 let page_path = base_path.join("page.md");
1768 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1769
1770 let content = r#"
1772# Test Document
1773
1774[Link without extension](page)
1775[Link with extension](page.md)
1776[Missing link](nonexistent)
1777"#;
1778
1779 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1780
1781 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782 let result = rule.check(&ctx).unwrap();
1783
1784 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1787 assert!(
1788 result[0].message.contains("nonexistent"),
1789 "Warning should be for 'nonexistent' not 'page'"
1790 );
1791 }
1792
1793 #[test]
1795 fn test_cross_file_scope() {
1796 let rule = MD057ExistingRelativeLinks::new();
1797 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1798 }
1799
1800 #[test]
1801 fn test_contribute_to_index_extracts_markdown_links() {
1802 let rule = MD057ExistingRelativeLinks::new();
1803 let content = r#"
1804# Document
1805
1806[Link to docs](./docs/guide.md)
1807[Link with fragment](./other.md#section)
1808[External link](https://example.com)
1809[Image link](image.png)
1810[Media file](video.mp4)
1811"#;
1812
1813 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814 let mut index = FileIndex::new();
1815 rule.contribute_to_index(&ctx, &mut index);
1816
1817 assert_eq!(index.cross_file_links.len(), 2);
1819
1820 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1822 assert_eq!(index.cross_file_links[0].fragment, "");
1823
1824 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1826 assert_eq!(index.cross_file_links[1].fragment, "section");
1827 }
1828
1829 #[test]
1830 fn test_contribute_to_index_skips_external_and_anchors() {
1831 let rule = MD057ExistingRelativeLinks::new();
1832 let content = r#"
1833# Document
1834
1835[External](https://example.com)
1836[Another external](http://example.org)
1837[Fragment only](#section)
1838[FTP link](ftp://files.example.com)
1839[Mail link](mailto:test@example.com)
1840[WWW link](www.example.com)
1841"#;
1842
1843 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1844 let mut index = FileIndex::new();
1845 rule.contribute_to_index(&ctx, &mut index);
1846
1847 assert_eq!(index.cross_file_links.len(), 0);
1849 }
1850
1851 #[test]
1852 fn test_cross_file_check_valid_link() {
1853 use crate::workspace_index::WorkspaceIndex;
1854
1855 let rule = MD057ExistingRelativeLinks::new();
1856
1857 let mut workspace_index = WorkspaceIndex::new();
1859 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1860
1861 let mut file_index = FileIndex::new();
1863 file_index.add_cross_file_link(CrossFileLinkIndex {
1864 target_path: "guide.md".to_string(),
1865 fragment: "".to_string(),
1866 line: 5,
1867 column: 1,
1868 });
1869
1870 let warnings = rule
1872 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1873 .unwrap();
1874
1875 assert!(warnings.is_empty());
1877 }
1878
1879 #[test]
1880 fn test_cross_file_check_missing_link() {
1881 use crate::workspace_index::WorkspaceIndex;
1882
1883 let rule = MD057ExistingRelativeLinks::new();
1884
1885 let workspace_index = WorkspaceIndex::new();
1887
1888 let mut file_index = FileIndex::new();
1890 file_index.add_cross_file_link(CrossFileLinkIndex {
1891 target_path: "missing.md".to_string(),
1892 fragment: "".to_string(),
1893 line: 5,
1894 column: 1,
1895 });
1896
1897 let warnings = rule
1899 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1900 .unwrap();
1901
1902 assert_eq!(warnings.len(), 1);
1904 assert!(warnings[0].message.contains("missing.md"));
1905 assert!(warnings[0].message.contains("does not exist"));
1906 }
1907
1908 #[test]
1909 fn test_cross_file_check_parent_path() {
1910 use crate::workspace_index::WorkspaceIndex;
1911
1912 let rule = MD057ExistingRelativeLinks::new();
1913
1914 let mut workspace_index = WorkspaceIndex::new();
1916 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1917
1918 let mut file_index = FileIndex::new();
1920 file_index.add_cross_file_link(CrossFileLinkIndex {
1921 target_path: "../readme.md".to_string(),
1922 fragment: "".to_string(),
1923 line: 5,
1924 column: 1,
1925 });
1926
1927 let warnings = rule
1929 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1930 .unwrap();
1931
1932 assert!(warnings.is_empty());
1934 }
1935
1936 #[test]
1937 fn test_cross_file_check_html_link_with_md_source() {
1938 use crate::workspace_index::WorkspaceIndex;
1941
1942 let rule = MD057ExistingRelativeLinks::new();
1943
1944 let mut workspace_index = WorkspaceIndex::new();
1946 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1947
1948 let mut file_index = FileIndex::new();
1950 file_index.add_cross_file_link(CrossFileLinkIndex {
1951 target_path: "guide.html".to_string(),
1952 fragment: "section".to_string(),
1953 line: 10,
1954 column: 5,
1955 });
1956
1957 let warnings = rule
1959 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1960 .unwrap();
1961
1962 assert!(
1964 warnings.is_empty(),
1965 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1966 );
1967 }
1968
1969 #[test]
1970 fn test_cross_file_check_html_link_without_source() {
1971 use crate::workspace_index::WorkspaceIndex;
1973
1974 let rule = MD057ExistingRelativeLinks::new();
1975
1976 let workspace_index = WorkspaceIndex::new();
1978
1979 let mut file_index = FileIndex::new();
1981 file_index.add_cross_file_link(CrossFileLinkIndex {
1982 target_path: "missing.html".to_string(),
1983 fragment: "".to_string(),
1984 line: 10,
1985 column: 5,
1986 });
1987
1988 let warnings = rule
1990 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1991 .unwrap();
1992
1993 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1995 assert!(warnings[0].message.contains("missing.html"));
1996 }
1997
1998 #[test]
1999 fn test_normalize_path_function() {
2000 assert_eq!(
2002 normalize_path(Path::new("docs/guide.md")),
2003 PathBuf::from("docs/guide.md")
2004 );
2005
2006 assert_eq!(
2008 normalize_path(Path::new("./docs/guide.md")),
2009 PathBuf::from("docs/guide.md")
2010 );
2011
2012 assert_eq!(
2014 normalize_path(Path::new("docs/sub/../guide.md")),
2015 PathBuf::from("docs/guide.md")
2016 );
2017
2018 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2020 }
2021
2022 #[test]
2023 fn test_html_link_with_md_source() {
2024 let temp_dir = tempdir().unwrap();
2026 let base_path = temp_dir.path();
2027
2028 let md_file = base_path.join("guide.md");
2030 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2031
2032 let content = r#"
2033[Read the guide](guide.html)
2034[Also here](getting-started.html)
2035"#;
2036
2037 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2038 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039 let result = rule.check(&ctx).unwrap();
2040
2041 assert_eq!(
2043 result.len(),
2044 1,
2045 "Should only warn about missing source. Got: {result:?}"
2046 );
2047 assert!(result[0].message.contains("getting-started.html"));
2048 }
2049
2050 #[test]
2051 fn test_htm_link_with_md_source() {
2052 let temp_dir = tempdir().unwrap();
2054 let base_path = temp_dir.path();
2055
2056 let md_file = base_path.join("page.md");
2057 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2058
2059 let content = "[Page](page.htm)";
2060
2061 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2062 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let result = rule.check(&ctx).unwrap();
2064
2065 assert!(
2066 result.is_empty(),
2067 "Should not warn when .md source exists for .htm link"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_html_link_finds_various_markdown_extensions() {
2073 let temp_dir = tempdir().unwrap();
2075 let base_path = temp_dir.path();
2076
2077 File::create(base_path.join("doc.md")).unwrap();
2078 File::create(base_path.join("tutorial.mdx")).unwrap();
2079 File::create(base_path.join("guide.markdown")).unwrap();
2080
2081 let content = r#"
2082[Doc](doc.html)
2083[Tutorial](tutorial.html)
2084[Guide](guide.html)
2085"#;
2086
2087 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2088 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2089 let result = rule.check(&ctx).unwrap();
2090
2091 assert!(
2092 result.is_empty(),
2093 "Should find all markdown variants as source files. Got: {result:?}"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_html_link_in_subdirectory() {
2099 let temp_dir = tempdir().unwrap();
2101 let base_path = temp_dir.path();
2102
2103 let docs_dir = base_path.join("docs");
2104 std::fs::create_dir(&docs_dir).unwrap();
2105 File::create(docs_dir.join("guide.md"))
2106 .unwrap()
2107 .write_all(b"# Guide")
2108 .unwrap();
2109
2110 let content = "[Guide](docs/guide.html)";
2111
2112 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2113 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2114 let result = rule.check(&ctx).unwrap();
2115
2116 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2117 }
2118
2119 #[test]
2120 fn test_absolute_path_skipped_in_check() {
2121 let temp_dir = tempdir().unwrap();
2124 let base_path = temp_dir.path();
2125
2126 let content = r#"
2127# Test Document
2128
2129[Go Runtime](/pkg/runtime)
2130[Go Runtime with Fragment](/pkg/runtime#section)
2131[API Docs](/api/v1/users)
2132[Blog Post](/blog/2024/release.html)
2133[React Hook](/react/hooks/use-state.html)
2134"#;
2135
2136 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2137 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2138 let result = rule.check(&ctx).unwrap();
2139
2140 assert!(
2142 result.is_empty(),
2143 "Absolute paths should be skipped. Got warnings: {result:?}"
2144 );
2145 }
2146
2147 #[test]
2148 fn test_absolute_path_skipped_in_cross_file_check() {
2149 use crate::workspace_index::WorkspaceIndex;
2151
2152 let rule = MD057ExistingRelativeLinks::new();
2153
2154 let workspace_index = WorkspaceIndex::new();
2156
2157 let mut file_index = FileIndex::new();
2159 file_index.add_cross_file_link(CrossFileLinkIndex {
2160 target_path: "/pkg/runtime.md".to_string(),
2161 fragment: "".to_string(),
2162 line: 5,
2163 column: 1,
2164 });
2165 file_index.add_cross_file_link(CrossFileLinkIndex {
2166 target_path: "/api/v1/users.md".to_string(),
2167 fragment: "section".to_string(),
2168 line: 10,
2169 column: 1,
2170 });
2171
2172 let warnings = rule
2174 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2175 .unwrap();
2176
2177 assert!(
2179 warnings.is_empty(),
2180 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_protocol_relative_url_not_skipped() {
2186 let temp_dir = tempdir().unwrap();
2189 let base_path = temp_dir.path();
2190
2191 let content = r#"
2192# Test Document
2193
2194[External](//example.com/page)
2195[Another](//cdn.example.com/asset.js)
2196"#;
2197
2198 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2199 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2200 let result = rule.check(&ctx).unwrap();
2201
2202 assert!(
2204 result.is_empty(),
2205 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2206 );
2207 }
2208
2209 #[test]
2210 fn test_email_addresses_skipped() {
2211 let temp_dir = tempdir().unwrap();
2214 let base_path = temp_dir.path();
2215
2216 let content = r#"
2217# Test Document
2218
2219[Contact](user@example.com)
2220[Steering](steering@kubernetes.io)
2221[Support](john.doe+filter@company.co.uk)
2222[User](user_name@sub.domain.com)
2223"#;
2224
2225 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2226 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2227 let result = rule.check(&ctx).unwrap();
2228
2229 assert!(
2231 result.is_empty(),
2232 "Email addresses should be skipped. Got warnings: {result:?}"
2233 );
2234 }
2235
2236 #[test]
2237 fn test_email_addresses_vs_file_paths() {
2238 let temp_dir = tempdir().unwrap();
2241 let base_path = temp_dir.path();
2242
2243 let content = r#"
2244# Test Document
2245
2246[Email](user@example.com) <!-- Should be skipped (email) -->
2247[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2248[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2249"#;
2250
2251 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2252 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253 let result = rule.check(&ctx).unwrap();
2254
2255 assert!(
2257 result.is_empty(),
2258 "All email addresses should be skipped. Got: {result:?}"
2259 );
2260 }
2261
2262 #[test]
2263 fn test_diagnostic_position_accuracy() {
2264 let temp_dir = tempdir().unwrap();
2266 let base_path = temp_dir.path();
2267
2268 let content = "prefix [text](missing.md) suffix";
2271 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2275 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2276 let result = rule.check(&ctx).unwrap();
2277
2278 assert_eq!(result.len(), 1, "Should have exactly one warning");
2279 assert_eq!(result[0].line, 1, "Should be on line 1");
2280 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2281 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2282 }
2283
2284 #[test]
2285 fn test_diagnostic_position_angle_brackets() {
2286 let temp_dir = tempdir().unwrap();
2288 let base_path = temp_dir.path();
2289
2290 let content = "[link](<missing.md>)";
2293 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2296 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2297 let result = rule.check(&ctx).unwrap();
2298
2299 assert_eq!(result.len(), 1, "Should have exactly one warning");
2300 assert_eq!(result[0].line, 1, "Should be on line 1");
2301 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2302 }
2303
2304 #[test]
2305 fn test_diagnostic_position_multiline() {
2306 let temp_dir = tempdir().unwrap();
2308 let base_path = temp_dir.path();
2309
2310 let content = r#"# Title
2311Some text on line 2
2312[link on line 3](missing1.md)
2313More text
2314[link on line 5](missing2.md)"#;
2315
2316 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2317 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2318 let result = rule.check(&ctx).unwrap();
2319
2320 assert_eq!(result.len(), 2, "Should have two warnings");
2321
2322 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2324 assert!(result[0].message.contains("missing1.md"));
2325
2326 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2328 assert!(result[1].message.contains("missing2.md"));
2329 }
2330
2331 #[test]
2332 fn test_diagnostic_position_with_spaces() {
2333 let temp_dir = tempdir().unwrap();
2335 let base_path = temp_dir.path();
2336
2337 let content = "[link]( missing.md )";
2338 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2343 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2344 let result = rule.check(&ctx).unwrap();
2345
2346 assert_eq!(result.len(), 1, "Should have exactly one warning");
2347 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2349 }
2350
2351 #[test]
2352 fn test_diagnostic_position_image() {
2353 let temp_dir = tempdir().unwrap();
2355 let base_path = temp_dir.path();
2356
2357 let content = "";
2358
2359 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2360 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2361 let result = rule.check(&ctx).unwrap();
2362
2363 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2364 assert_eq!(result[0].line, 1);
2365 assert!(result[0].column > 0, "Should have valid column position");
2367 assert!(result[0].message.contains("missing.jpg"));
2368 }
2369
2370 #[test]
2371 fn test_wikilinks_skipped() {
2372 let temp_dir = tempdir().unwrap();
2375 let base_path = temp_dir.path();
2376
2377 let content = r#"# Test Document
2378
2379[[Microsoft#Windows OS]]
2380[[SomePage]]
2381[[Page With Spaces]]
2382[[path/to/page#section]]
2383[[page|Display Text]]
2384
2385This is a [real missing link](missing.md) that should be flagged.
2386"#;
2387
2388 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2389 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2390 let result = rule.check(&ctx).unwrap();
2391
2392 assert_eq!(
2394 result.len(),
2395 1,
2396 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2397 );
2398 assert!(
2399 result[0].message.contains("missing.md"),
2400 "Warning should be for missing.md, not wikilinks"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_wikilinks_not_added_to_index() {
2406 let temp_dir = tempdir().unwrap();
2408 let base_path = temp_dir.path();
2409
2410 let content = r#"# Test Document
2411
2412[[Microsoft#Windows OS]]
2413[[SomePage#section]]
2414[Regular Link](other.md)
2415"#;
2416
2417 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2418 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419
2420 let mut file_index = FileIndex::new();
2421 rule.contribute_to_index(&ctx, &mut file_index);
2422
2423 let cross_file_links = &file_index.cross_file_links;
2426 assert_eq!(
2427 cross_file_links.len(),
2428 1,
2429 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2430 );
2431 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2432 }
2433
2434 #[test]
2435 fn test_reference_definition_missing_file() {
2436 let temp_dir = tempdir().unwrap();
2438 let base_path = temp_dir.path();
2439
2440 let content = r#"# Test Document
2441
2442[test]: ./missing.md
2443[example]: ./nonexistent.html
2444
2445Use [test] and [example] here.
2446"#;
2447
2448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2449 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2450 let result = rule.check(&ctx).unwrap();
2451
2452 assert_eq!(
2454 result.len(),
2455 2,
2456 "Should have warnings for missing reference definition targets. Got: {result:?}"
2457 );
2458 assert!(
2459 result.iter().any(|w| w.message.contains("missing.md")),
2460 "Should warn about missing.md"
2461 );
2462 assert!(
2463 result.iter().any(|w| w.message.contains("nonexistent.html")),
2464 "Should warn about nonexistent.html"
2465 );
2466 }
2467
2468 #[test]
2469 fn test_reference_definition_existing_file() {
2470 let temp_dir = tempdir().unwrap();
2472 let base_path = temp_dir.path();
2473
2474 let exists_path = base_path.join("exists.md");
2476 File::create(&exists_path)
2477 .unwrap()
2478 .write_all(b"# Existing file")
2479 .unwrap();
2480
2481 let content = r#"# Test Document
2482
2483[test]: ./exists.md
2484
2485Use [test] here.
2486"#;
2487
2488 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2489 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2490 let result = rule.check(&ctx).unwrap();
2491
2492 assert!(
2494 result.is_empty(),
2495 "Should not warn about existing file. Got: {result:?}"
2496 );
2497 }
2498
2499 #[test]
2500 fn test_reference_definition_external_url_skipped() {
2501 let temp_dir = tempdir().unwrap();
2503 let base_path = temp_dir.path();
2504
2505 let content = r#"# Test Document
2506
2507[google]: https://google.com
2508[example]: http://example.org
2509[mail]: mailto:test@example.com
2510[ftp]: ftp://files.example.com
2511[local]: ./missing.md
2512
2513Use [google], [example], [mail], [ftp], [local] here.
2514"#;
2515
2516 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2517 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2518 let result = rule.check(&ctx).unwrap();
2519
2520 assert_eq!(
2522 result.len(),
2523 1,
2524 "Should only warn about local missing file. Got: {result:?}"
2525 );
2526 assert!(
2527 result[0].message.contains("missing.md"),
2528 "Warning should be for missing.md"
2529 );
2530 }
2531
2532 #[test]
2533 fn test_reference_definition_fragment_only_skipped() {
2534 let temp_dir = tempdir().unwrap();
2536 let base_path = temp_dir.path();
2537
2538 let content = r#"# Test Document
2539
2540[section]: #my-section
2541
2542Use [section] here.
2543"#;
2544
2545 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2546 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2547 let result = rule.check(&ctx).unwrap();
2548
2549 assert!(
2551 result.is_empty(),
2552 "Should not warn about fragment-only reference. Got: {result:?}"
2553 );
2554 }
2555
2556 #[test]
2557 fn test_reference_definition_column_position() {
2558 let temp_dir = tempdir().unwrap();
2560 let base_path = temp_dir.path();
2561
2562 let content = "[ref]: ./missing.md";
2565 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2569 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2570 let result = rule.check(&ctx).unwrap();
2571
2572 assert_eq!(result.len(), 1, "Should have exactly one warning");
2573 assert_eq!(result[0].line, 1, "Should be on line 1");
2574 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2575 }
2576
2577 #[test]
2578 fn test_reference_definition_html_with_md_source() {
2579 let temp_dir = tempdir().unwrap();
2581 let base_path = temp_dir.path();
2582
2583 let md_file = base_path.join("guide.md");
2585 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2586
2587 let content = r#"# Test Document
2588
2589[guide]: ./guide.html
2590[missing]: ./missing.html
2591
2592Use [guide] and [missing] here.
2593"#;
2594
2595 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2596 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2597 let result = rule.check(&ctx).unwrap();
2598
2599 assert_eq!(
2601 result.len(),
2602 1,
2603 "Should only warn about missing source. Got: {result:?}"
2604 );
2605 assert!(result[0].message.contains("missing.html"));
2606 }
2607
2608 #[test]
2609 fn test_reference_definition_url_encoded() {
2610 let temp_dir = tempdir().unwrap();
2612 let base_path = temp_dir.path();
2613
2614 let file_with_spaces = base_path.join("file with spaces.md");
2616 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2617
2618 let content = r#"# Test Document
2619
2620[spaces]: ./file%20with%20spaces.md
2621[missing]: ./missing%20file.md
2622
2623Use [spaces] and [missing] here.
2624"#;
2625
2626 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2628 let result = rule.check(&ctx).unwrap();
2629
2630 assert_eq!(
2632 result.len(),
2633 1,
2634 "Should only warn about missing URL-encoded file. Got: {result:?}"
2635 );
2636 assert!(result[0].message.contains("missing%20file.md"));
2637 }
2638
2639 #[test]
2640 fn test_inline_and_reference_both_checked() {
2641 let temp_dir = tempdir().unwrap();
2643 let base_path = temp_dir.path();
2644
2645 let content = r#"# Test Document
2646
2647[inline link](./inline-missing.md)
2648[ref]: ./ref-missing.md
2649
2650Use [ref] here.
2651"#;
2652
2653 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2655 let result = rule.check(&ctx).unwrap();
2656
2657 assert_eq!(
2659 result.len(),
2660 2,
2661 "Should warn about both inline and reference links. Got: {result:?}"
2662 );
2663 assert!(
2664 result.iter().any(|w| w.message.contains("inline-missing.md")),
2665 "Should warn about inline-missing.md"
2666 );
2667 assert!(
2668 result.iter().any(|w| w.message.contains("ref-missing.md")),
2669 "Should warn about ref-missing.md"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_footnote_definitions_not_flagged() {
2675 let rule = MD057ExistingRelativeLinks::default();
2678
2679 let content = r#"# Title
2680
2681A footnote[^1].
2682
2683[^1]: [link](https://www.google.com).
2684"#;
2685
2686 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2687 let result = rule.check(&ctx).unwrap();
2688
2689 assert!(
2690 result.is_empty(),
2691 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2692 );
2693 }
2694
2695 #[test]
2696 fn test_footnote_with_relative_link_inside() {
2697 let rule = MD057ExistingRelativeLinks::default();
2700
2701 let content = r#"# Title
2702
2703See the footnote[^1].
2704
2705[^1]: Check out [this file](./existing.md) for more info.
2706[^2]: Also see [missing](./does-not-exist.md).
2707"#;
2708
2709 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2710 let result = rule.check(&ctx).unwrap();
2711
2712 for warning in &result {
2717 assert!(
2718 !warning.message.contains("[this file]"),
2719 "Footnote content should not be treated as URL: {warning:?}"
2720 );
2721 assert!(
2722 !warning.message.contains("[missing]"),
2723 "Footnote content should not be treated as URL: {warning:?}"
2724 );
2725 }
2726 }
2727
2728 #[test]
2729 fn test_mixed_footnotes_and_reference_definitions() {
2730 let temp_dir = tempdir().unwrap();
2732 let base_path = temp_dir.path();
2733
2734 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2735
2736 let content = r#"# Title
2737
2738A footnote[^1] and a [ref link][myref].
2739
2740[^1]: This is a footnote with [link](https://example.com).
2741
2742[myref]: ./missing-file.md "This should be checked"
2743"#;
2744
2745 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2746 let result = rule.check(&ctx).unwrap();
2747
2748 assert_eq!(
2750 result.len(),
2751 1,
2752 "Should only warn about the regular reference definition. Got: {result:?}"
2753 );
2754 assert!(
2755 result[0].message.contains("missing-file.md"),
2756 "Should warn about missing-file.md in reference definition"
2757 );
2758 }
2759
2760 #[test]
2761 fn test_absolute_links_ignore_by_default() {
2762 let temp_dir = tempdir().unwrap();
2764 let base_path = temp_dir.path();
2765
2766 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2767
2768 let content = r#"# Links
2769
2770[API docs](/api/v1/users)
2771[Blog post](/blog/2024/release.html)
2772
2773
2774[ref]: /docs/reference.md
2775"#;
2776
2777 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2778 let result = rule.check(&ctx).unwrap();
2779
2780 assert!(
2782 result.is_empty(),
2783 "Absolute links should be ignored by default. Got: {result:?}"
2784 );
2785 }
2786
2787 #[test]
2788 fn test_absolute_links_warn_config() {
2789 let temp_dir = tempdir().unwrap();
2791 let base_path = temp_dir.path();
2792
2793 let config = MD057Config {
2794 absolute_links: AbsoluteLinksOption::Warn,
2795 ..Default::default()
2796 };
2797 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2798
2799 let content = r#"# Links
2800
2801[API docs](/api/v1/users)
2802[Blog post](/blog/2024/release.html)
2803"#;
2804
2805 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2806 let result = rule.check(&ctx).unwrap();
2807
2808 assert_eq!(
2810 result.len(),
2811 2,
2812 "Should warn about both absolute links. Got: {result:?}"
2813 );
2814 assert!(
2815 result[0].message.contains("cannot be validated locally"),
2816 "Warning should explain why: {}",
2817 result[0].message
2818 );
2819 assert!(
2820 result[0].message.contains("/api/v1/users"),
2821 "Warning should include the link path"
2822 );
2823 }
2824
2825 #[test]
2826 fn test_absolute_links_warn_images() {
2827 let temp_dir = tempdir().unwrap();
2829 let base_path = temp_dir.path();
2830
2831 let config = MD057Config {
2832 absolute_links: AbsoluteLinksOption::Warn,
2833 ..Default::default()
2834 };
2835 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2836
2837 let content = r#"# Images
2838
2839
2840"#;
2841
2842 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2843 let result = rule.check(&ctx).unwrap();
2844
2845 assert_eq!(
2846 result.len(),
2847 1,
2848 "Should warn about absolute image path. Got: {result:?}"
2849 );
2850 assert!(
2851 result[0].message.contains("/assets/logo.png"),
2852 "Warning should include the image path"
2853 );
2854 }
2855
2856 #[test]
2857 fn test_absolute_links_warn_reference_definitions() {
2858 let temp_dir = tempdir().unwrap();
2860 let base_path = temp_dir.path();
2861
2862 let config = MD057Config {
2863 absolute_links: AbsoluteLinksOption::Warn,
2864 ..Default::default()
2865 };
2866 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2867
2868 let content = r#"# Reference
2869
2870See the [docs][ref].
2871
2872[ref]: /docs/reference.md
2873"#;
2874
2875 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2876 let result = rule.check(&ctx).unwrap();
2877
2878 assert_eq!(
2879 result.len(),
2880 1,
2881 "Should warn about absolute reference definition. Got: {result:?}"
2882 );
2883 assert!(
2884 result[0].message.contains("/docs/reference.md"),
2885 "Warning should include the reference path"
2886 );
2887 }
2888}