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 img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
710 let fix_byte_start = img_line_start_byte + image.start_col;
711 let fix_byte_end = fix_byte_start + url.len();
712 warnings.push(LintWarning {
713 rule_name: Some(self.name().to_string()),
714 line: image.line,
715 column: image.start_col + 1,
716 end_line: image.line,
717 end_column: image.start_col + 1 + url.len(),
718 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
719 severity: Severity::Warning,
720 fix: Some(Fix {
721 range: fix_byte_start..fix_byte_end,
722 replacement: suggestion,
723 }),
724 });
725 }
726
727 let file_path = Self::strip_query_and_fragment(url);
729
730 let decoded_path = Self::url_decode(file_path);
732
733 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
735
736 if file_exists_or_markdown_extension(&resolved_path) {
738 continue; }
740
741 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
743 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
744 && let (Some(stem), Some(parent)) = (
745 resolved_path.file_stem().and_then(|s| s.to_str()),
746 resolved_path.parent(),
747 ) {
748 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
749 let source_path = parent.join(format!("{stem}{md_ext}"));
750 file_exists_with_cache(&source_path)
751 })
752 } else {
753 false
754 };
755
756 if has_md_source {
757 continue; }
759
760 warnings.push(LintWarning {
763 rule_name: Some(self.name().to_string()),
764 line: image.line,
765 column: image.start_col + 1,
766 end_line: image.line,
767 end_column: image.start_col + 1 + url.len(),
768 message: format!("Relative link '{url}' does not exist"),
769 severity: Severity::Error,
770 fix: None,
771 });
772 }
773
774 for ref_def in &ctx.reference_defs {
776 let url = &ref_def.url;
777
778 if url.is_empty() {
780 continue;
781 }
782
783 if self.is_external_url(url) || self.is_fragment_only_link(url) {
785 continue;
786 }
787
788 if Self::is_absolute_path(url) {
790 match self.config.absolute_links {
791 AbsoluteLinksOption::Warn => {
792 let line_idx = ref_def.line - 1;
793 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
794 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
795 });
796 warnings.push(LintWarning {
797 rule_name: Some(self.name().to_string()),
798 line: ref_def.line,
799 column,
800 end_line: ref_def.line,
801 end_column: column + url.len(),
802 message: format!("Absolute link '{url}' cannot be validated locally"),
803 severity: Severity::Warning,
804 fix: None,
805 });
806 }
807 AbsoluteLinksOption::RelativeToDocs => {
808 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
809 let line_idx = ref_def.line - 1;
810 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
811 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
812 });
813 warnings.push(LintWarning {
814 rule_name: Some(self.name().to_string()),
815 line: ref_def.line,
816 column,
817 end_line: ref_def.line,
818 end_column: column + url.len(),
819 message: msg,
820 severity: Severity::Warning,
821 fix: None,
822 });
823 }
824 }
825 AbsoluteLinksOption::Ignore => {}
826 }
827 continue;
828 }
829
830 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
832 let ref_line_idx = ref_def.line - 1;
833 let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
834 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
835 });
836 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
837 let fix_byte_start = ref_line_start_byte + col - 1;
838 let fix_byte_end = fix_byte_start + url.len();
839 warnings.push(LintWarning {
840 rule_name: Some(self.name().to_string()),
841 line: ref_def.line,
842 column: col,
843 end_line: ref_def.line,
844 end_column: col + url.len(),
845 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
846 severity: Severity::Warning,
847 fix: Some(Fix {
848 range: fix_byte_start..fix_byte_end,
849 replacement: suggestion,
850 }),
851 });
852 }
853
854 let file_path = Self::strip_query_and_fragment(url);
856
857 let decoded_path = Self::url_decode(file_path);
859
860 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
862
863 if file_exists_or_markdown_extension(&resolved_path) {
865 continue; }
867
868 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
870 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
871 && let (Some(stem), Some(parent)) = (
872 resolved_path.file_stem().and_then(|s| s.to_str()),
873 resolved_path.parent(),
874 ) {
875 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
876 let source_path = parent.join(format!("{stem}{md_ext}"));
877 file_exists_with_cache(&source_path)
878 })
879 } else {
880 false
881 };
882
883 if has_md_source {
884 continue; }
886
887 let line_idx = ref_def.line - 1;
890 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
891 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
893 });
894
895 warnings.push(LintWarning {
896 rule_name: Some(self.name().to_string()),
897 line: ref_def.line,
898 column,
899 end_line: ref_def.line,
900 end_column: column + url.len(),
901 message: format!("Relative link '{url}' does not exist"),
902 severity: Severity::Error,
903 fix: None,
904 });
905 }
906
907 Ok(warnings)
908 }
909
910 fn fix_capability(&self) -> FixCapability {
911 if self.config.compact_paths {
912 FixCapability::ConditionallyFixable
913 } else {
914 FixCapability::Unfixable
915 }
916 }
917
918 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
919 if !self.config.compact_paths {
920 return Ok(ctx.content.to_string());
921 }
922
923 let warnings = self.check(ctx)?;
924 let mut content = ctx.content.to_string();
925
926 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
928 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
929
930 for fix in fixes {
931 if fix.range.end <= content.len() {
932 content.replace_range(fix.range.clone(), &fix.replacement);
933 }
934 }
935
936 Ok(content)
937 }
938
939 fn as_any(&self) -> &dyn std::any::Any {
940 self
941 }
942
943 fn default_config_section(&self) -> Option<(String, toml::Value)> {
944 let default_config = MD057Config::default();
945 let json_value = serde_json::to_value(&default_config).ok()?;
946 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
947
948 if let toml::Value::Table(table) = toml_value {
949 if !table.is_empty() {
950 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
951 } else {
952 None
953 }
954 } else {
955 None
956 }
957 }
958
959 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
960 where
961 Self: Sized,
962 {
963 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
964 Box::new(Self::from_config_struct(rule_config))
965 }
966
967 fn cross_file_scope(&self) -> CrossFileScope {
968 CrossFileScope::Workspace
969 }
970
971 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
972 for link in extract_cross_file_links(ctx) {
975 index.add_cross_file_link(link);
976 }
977 }
978
979 fn cross_file_check(
980 &self,
981 file_path: &Path,
982 file_index: &FileIndex,
983 workspace_index: &crate::workspace_index::WorkspaceIndex,
984 ) -> LintResult {
985 let mut warnings = Vec::new();
986
987 let file_dir = file_path.parent();
989
990 for cross_link in &file_index.cross_file_links {
991 let decoded_target = Self::url_decode(&cross_link.target_path);
994
995 if decoded_target.starts_with('/') {
999 continue;
1000 }
1001
1002 let target_path = if let Some(dir) = file_dir {
1004 dir.join(&decoded_target)
1005 } else {
1006 Path::new(&decoded_target).to_path_buf()
1007 };
1008
1009 let target_path = normalize_path(&target_path);
1011
1012 let file_exists =
1014 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1015
1016 if !file_exists {
1017 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1020 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1021 && let (Some(stem), Some(parent)) =
1022 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1023 {
1024 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1025 let source_path = parent.join(format!("{stem}{md_ext}"));
1026 workspace_index.contains_file(&source_path) || source_path.exists()
1027 })
1028 } else {
1029 false
1030 };
1031
1032 if !has_md_source {
1033 warnings.push(LintWarning {
1034 rule_name: Some(self.name().to_string()),
1035 line: cross_link.line,
1036 column: cross_link.column,
1037 end_line: cross_link.line,
1038 end_column: cross_link.column + cross_link.target_path.len(),
1039 message: format!("Relative link '{}' does not exist", cross_link.target_path),
1040 severity: Severity::Error,
1041 fix: None,
1042 });
1043 }
1044 }
1045 }
1046
1047 Ok(warnings)
1048 }
1049}
1050
1051fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1056 let from_components: Vec<_> = from_dir.components().collect();
1057 let to_components: Vec<_> = to_path.components().collect();
1058
1059 let common_len = from_components
1061 .iter()
1062 .zip(to_components.iter())
1063 .take_while(|(a, b)| a == b)
1064 .count();
1065
1066 let mut result = PathBuf::new();
1067
1068 for _ in common_len..from_components.len() {
1070 result.push("..");
1071 }
1072
1073 for component in &to_components[common_len..] {
1075 result.push(component);
1076 }
1077
1078 result
1079}
1080
1081fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1087 let link_path = Path::new(raw_link_path);
1088
1089 let has_traversal = link_path
1091 .components()
1092 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1093
1094 if !has_traversal {
1095 return None;
1096 }
1097
1098 let combined = source_dir.join(link_path);
1100 let normalized_target = normalize_path(&combined);
1101
1102 let normalized_source = normalize_path(source_dir);
1104 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1105
1106 if shortest != link_path {
1108 let compact = shortest.to_string_lossy().to_string();
1109 if compact.is_empty() {
1111 return None;
1112 }
1113 Some(compact.replace('\\', "/"))
1115 } else {
1116 None
1117 }
1118}
1119
1120fn normalize_path(path: &Path) -> PathBuf {
1122 let mut components = Vec::new();
1123
1124 for component in path.components() {
1125 match component {
1126 std::path::Component::ParentDir => {
1127 if !components.is_empty() {
1129 components.pop();
1130 }
1131 }
1132 std::path::Component::CurDir => {
1133 }
1135 _ => {
1136 components.push(component);
1137 }
1138 }
1139 }
1140
1141 components.iter().collect()
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147 use crate::workspace_index::CrossFileLinkIndex;
1148 use std::fs::File;
1149 use std::io::Write;
1150 use tempfile::tempdir;
1151
1152 #[test]
1153 fn test_strip_query_and_fragment() {
1154 assert_eq!(
1156 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1157 "file.png"
1158 );
1159 assert_eq!(
1160 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1161 "file.png"
1162 );
1163 assert_eq!(
1164 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1165 "file.png"
1166 );
1167
1168 assert_eq!(
1170 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1171 "file.md"
1172 );
1173 assert_eq!(
1174 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1175 "file.md"
1176 );
1177
1178 assert_eq!(
1180 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1181 "file.md"
1182 );
1183
1184 assert_eq!(
1186 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1187 "file.png"
1188 );
1189
1190 assert_eq!(
1192 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1193 "path/to/image.png"
1194 );
1195 assert_eq!(
1196 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1197 "path/to/image.png"
1198 );
1199
1200 assert_eq!(
1202 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1203 "file.md"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_url_decode() {
1209 assert_eq!(
1211 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1212 "penguin with space.jpg"
1213 );
1214
1215 assert_eq!(
1217 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1218 "assets/my file name.png"
1219 );
1220
1221 assert_eq!(
1223 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1224 "hello world!.md"
1225 );
1226
1227 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1229
1230 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1232
1233 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1235
1236 assert_eq!(
1238 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1239 "normal-file.md"
1240 );
1241
1242 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1244
1245 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1247
1248 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1250
1251 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1253
1254 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1256
1257 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1259
1260 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1262
1263 assert_eq!(
1265 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1266 "path/to/file.md"
1267 );
1268
1269 assert_eq!(
1271 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1272 "hello world/foo bar.md"
1273 );
1274
1275 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1277
1278 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1280 }
1281
1282 #[test]
1283 fn test_url_encoded_filenames() {
1284 let temp_dir = tempdir().unwrap();
1286 let base_path = temp_dir.path();
1287
1288 let file_with_spaces = base_path.join("penguin with space.jpg");
1290 File::create(&file_with_spaces)
1291 .unwrap()
1292 .write_all(b"image data")
1293 .unwrap();
1294
1295 let subdir = base_path.join("my images");
1297 std::fs::create_dir(&subdir).unwrap();
1298 let nested_file = subdir.join("photo 1.png");
1299 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1300
1301 let content = r#"
1303# Test Document with URL-Encoded Links
1304
1305
1306
1307
1308"#;
1309
1310 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1311
1312 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314
1315 assert_eq!(
1317 result.len(),
1318 1,
1319 "Should only warn about missing%20file.jpg. Got: {result:?}"
1320 );
1321 assert!(
1322 result[0].message.contains("missing%20file.jpg"),
1323 "Warning should mention the URL-encoded filename"
1324 );
1325 }
1326
1327 #[test]
1328 fn test_external_urls() {
1329 let rule = MD057ExistingRelativeLinks::new();
1330
1331 assert!(rule.is_external_url("https://example.com"));
1333 assert!(rule.is_external_url("http://example.com"));
1334 assert!(rule.is_external_url("ftp://example.com"));
1335 assert!(rule.is_external_url("www.example.com"));
1336 assert!(rule.is_external_url("example.com"));
1337
1338 assert!(rule.is_external_url("file:///path/to/file"));
1340 assert!(rule.is_external_url("smb://server/share"));
1341 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1342 assert!(rule.is_external_url("mailto:user@example.com"));
1343 assert!(rule.is_external_url("tel:+1234567890"));
1344 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1345 assert!(rule.is_external_url("javascript:void(0)"));
1346 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1347 assert!(rule.is_external_url("git://github.com/repo.git"));
1348
1349 assert!(rule.is_external_url("user@example.com"));
1352 assert!(rule.is_external_url("steering@kubernetes.io"));
1353 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1354 assert!(rule.is_external_url("user_name@sub.domain.com"));
1355 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1356
1357 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"));
1368 assert!(!rule.is_external_url("/blog/2024/release.html"));
1369 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1370 assert!(!rule.is_external_url("/pkg/runtime"));
1371 assert!(!rule.is_external_url("/doc/go1compat"));
1372 assert!(!rule.is_external_url("/index.html"));
1373 assert!(!rule.is_external_url("/assets/logo.png"));
1374
1375 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1377 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1378 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1379 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1380 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1381
1382 assert!(rule.is_external_url("~/assets/image.png"));
1385 assert!(rule.is_external_url("~/components/Button.vue"));
1386 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1390 assert!(rule.is_external_url("@images/photo.jpg"));
1391 assert!(rule.is_external_url("@assets/styles.css"));
1392
1393 assert!(!rule.is_external_url("./relative/path.md"));
1395 assert!(!rule.is_external_url("relative/path.md"));
1396 assert!(!rule.is_external_url("../parent/path.md"));
1397 }
1398
1399 #[test]
1400 fn test_framework_path_aliases() {
1401 let temp_dir = tempdir().unwrap();
1403 let base_path = temp_dir.path();
1404
1405 let content = r#"
1407# Framework Path Aliases
1408
1409
1410
1411
1412
1413[Link](@/pages/about.md)
1414
1415This is a [real missing link](missing.md) that should be flagged.
1416"#;
1417
1418 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1419
1420 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421 let result = rule.check(&ctx).unwrap();
1422
1423 assert_eq!(
1425 result.len(),
1426 1,
1427 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1428 );
1429 assert!(
1430 result[0].message.contains("missing.md"),
1431 "Warning should be for missing.md"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_url_decode_security_path_traversal() {
1437 let temp_dir = tempdir().unwrap();
1440 let base_path = temp_dir.path();
1441
1442 let file_in_base = base_path.join("safe.md");
1444 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1445
1446 let content = r#"
1451[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1452[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1453[Safe link](safe.md)
1454"#;
1455
1456 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1457
1458 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let result = rule.check(&ctx).unwrap();
1460
1461 assert_eq!(
1464 result.len(),
1465 2,
1466 "Should have warnings for traversal attempts. Got: {result:?}"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_url_encoded_utf8_filenames() {
1472 let temp_dir = tempdir().unwrap();
1474 let base_path = temp_dir.path();
1475
1476 let cafe_file = base_path.join("café.md");
1478 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1479
1480 let content = r#"
1481[Café link](caf%C3%A9.md)
1482[Missing unicode](r%C3%A9sum%C3%A9.md)
1483"#;
1484
1485 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1486
1487 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let result = rule.check(&ctx).unwrap();
1489
1490 assert_eq!(
1492 result.len(),
1493 1,
1494 "Should only warn about missing résumé.md. Got: {result:?}"
1495 );
1496 assert!(
1497 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1498 "Warning should mention the URL-encoded filename"
1499 );
1500 }
1501
1502 #[test]
1503 fn test_url_encoded_emoji_filenames() {
1504 let temp_dir = tempdir().unwrap();
1507 let base_path = temp_dir.path();
1508
1509 let emoji_dir = base_path.join("👤 Personal");
1511 std::fs::create_dir(&emoji_dir).unwrap();
1512
1513 let file_path = emoji_dir.join("TV Shows.md");
1515 File::create(&file_path)
1516 .unwrap()
1517 .write_all(b"# TV Shows\n\nContent here.")
1518 .unwrap();
1519
1520 let content = r#"
1523# Test Document
1524
1525[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1526[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1527"#;
1528
1529 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1530
1531 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532 let result = rule.check(&ctx).unwrap();
1533
1534 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1536 assert!(
1537 result[0].message.contains("Missing.md"),
1538 "Warning should be for Missing.md, got: {}",
1539 result[0].message
1540 );
1541 }
1542
1543 #[test]
1544 fn test_no_warnings_without_base_path() {
1545 let rule = MD057ExistingRelativeLinks::new();
1546 let content = "[Link](missing.md)";
1547
1548 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549 let result = rule.check(&ctx).unwrap();
1550 assert!(result.is_empty(), "Should have no warnings without base path");
1551 }
1552
1553 #[test]
1554 fn test_existing_and_missing_links() {
1555 let temp_dir = tempdir().unwrap();
1557 let base_path = temp_dir.path();
1558
1559 let exists_path = base_path.join("exists.md");
1561 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1562
1563 assert!(exists_path.exists(), "exists.md should exist for this test");
1565
1566 let content = r#"
1568# Test Document
1569
1570[Valid Link](exists.md)
1571[Invalid Link](missing.md)
1572[External Link](https://example.com)
1573[Media Link](image.jpg)
1574 "#;
1575
1576 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1578
1579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let result = rule.check(&ctx).unwrap();
1582
1583 assert_eq!(result.len(), 2);
1585 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1586 assert!(messages.iter().any(|m| m.contains("missing.md")));
1587 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1588 }
1589
1590 #[test]
1591 fn test_angle_bracket_links() {
1592 let temp_dir = tempdir().unwrap();
1594 let base_path = temp_dir.path();
1595
1596 let exists_path = base_path.join("exists.md");
1598 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1599
1600 let content = r#"
1602# Test Document
1603
1604[Valid Link](<exists.md>)
1605[Invalid Link](<missing.md>)
1606[External Link](<https://example.com>)
1607 "#;
1608
1609 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1611
1612 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613 let result = rule.check(&ctx).unwrap();
1614
1615 assert_eq!(result.len(), 1, "Should have exactly one warning");
1617 assert!(
1618 result[0].message.contains("missing.md"),
1619 "Warning should mention missing.md"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_angle_bracket_links_with_parens() {
1625 let temp_dir = tempdir().unwrap();
1627 let base_path = temp_dir.path();
1628
1629 let app_dir = base_path.join("app");
1631 std::fs::create_dir(&app_dir).unwrap();
1632 let upload_dir = app_dir.join("(upload)");
1633 std::fs::create_dir(&upload_dir).unwrap();
1634 let page_file = upload_dir.join("page.tsx");
1635 File::create(&page_file)
1636 .unwrap()
1637 .write_all(b"export default function Page() {}")
1638 .unwrap();
1639
1640 let content = r#"
1642# Test Document with Paths Containing Parens
1643
1644[Upload Page](<app/(upload)/page.tsx>)
1645[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1646[Missing](<app/(missing)/file.md>)
1647"#;
1648
1649 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1650
1651 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1652 let result = rule.check(&ctx).unwrap();
1653
1654 assert_eq!(
1656 result.len(),
1657 1,
1658 "Should have exactly one warning for missing file. Got: {result:?}"
1659 );
1660 assert!(
1661 result[0].message.contains("app/(missing)/file.md"),
1662 "Warning should mention app/(missing)/file.md"
1663 );
1664 }
1665
1666 #[test]
1667 fn test_all_file_types_checked() {
1668 let temp_dir = tempdir().unwrap();
1670 let base_path = temp_dir.path();
1671
1672 let content = r#"
1674[Image Link](image.jpg)
1675[Video Link](video.mp4)
1676[Markdown Link](document.md)
1677[PDF Link](file.pdf)
1678"#;
1679
1680 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1681
1682 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let result = rule.check(&ctx).unwrap();
1684
1685 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1687 }
1688
1689 #[test]
1690 fn test_code_span_detection() {
1691 let rule = MD057ExistingRelativeLinks::new();
1692
1693 let temp_dir = tempdir().unwrap();
1695 let base_path = temp_dir.path();
1696
1697 let rule = rule.with_path(base_path);
1698
1699 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1701
1702 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704
1705 assert_eq!(result.len(), 1, "Should only flag the real link");
1707 assert!(result[0].message.contains("nonexistent.md"));
1708 }
1709
1710 #[test]
1711 fn test_inline_code_spans() {
1712 let temp_dir = tempdir().unwrap();
1714 let base_path = temp_dir.path();
1715
1716 let content = r#"
1718# Test Document
1719
1720This is a normal link: [Link](missing.md)
1721
1722This is a code span with a link: `[Link](another-missing.md)`
1723
1724Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1725
1726 "#;
1727
1728 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1730
1731 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let result = rule.check(&ctx).unwrap();
1734
1735 assert_eq!(result.len(), 1, "Should have exactly one warning");
1737 assert!(
1738 result[0].message.contains("missing.md"),
1739 "Warning should be for missing.md"
1740 );
1741 assert!(
1742 !result.iter().any(|w| w.message.contains("another-missing.md")),
1743 "Should not warn about link in code span"
1744 );
1745 assert!(
1746 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1747 "Should not warn about link in inline code"
1748 );
1749 }
1750
1751 #[test]
1752 fn test_extensionless_link_resolution() {
1753 let temp_dir = tempdir().unwrap();
1755 let base_path = temp_dir.path();
1756
1757 let page_path = base_path.join("page.md");
1759 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1760
1761 let content = r#"
1763# Test Document
1764
1765[Link without extension](page)
1766[Link with extension](page.md)
1767[Missing link](nonexistent)
1768"#;
1769
1770 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1771
1772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let result = rule.check(&ctx).unwrap();
1774
1775 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1778 assert!(
1779 result[0].message.contains("nonexistent"),
1780 "Warning should be for 'nonexistent' not 'page'"
1781 );
1782 }
1783
1784 #[test]
1786 fn test_cross_file_scope() {
1787 let rule = MD057ExistingRelativeLinks::new();
1788 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1789 }
1790
1791 #[test]
1792 fn test_contribute_to_index_extracts_markdown_links() {
1793 let rule = MD057ExistingRelativeLinks::new();
1794 let content = r#"
1795# Document
1796
1797[Link to docs](./docs/guide.md)
1798[Link with fragment](./other.md#section)
1799[External link](https://example.com)
1800[Image link](image.png)
1801[Media file](video.mp4)
1802"#;
1803
1804 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1805 let mut index = FileIndex::new();
1806 rule.contribute_to_index(&ctx, &mut index);
1807
1808 assert_eq!(index.cross_file_links.len(), 2);
1810
1811 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1813 assert_eq!(index.cross_file_links[0].fragment, "");
1814
1815 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1817 assert_eq!(index.cross_file_links[1].fragment, "section");
1818 }
1819
1820 #[test]
1821 fn test_contribute_to_index_skips_external_and_anchors() {
1822 let rule = MD057ExistingRelativeLinks::new();
1823 let content = r#"
1824# Document
1825
1826[External](https://example.com)
1827[Another external](http://example.org)
1828[Fragment only](#section)
1829[FTP link](ftp://files.example.com)
1830[Mail link](mailto:test@example.com)
1831[WWW link](www.example.com)
1832"#;
1833
1834 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let mut index = FileIndex::new();
1836 rule.contribute_to_index(&ctx, &mut index);
1837
1838 assert_eq!(index.cross_file_links.len(), 0);
1840 }
1841
1842 #[test]
1843 fn test_cross_file_check_valid_link() {
1844 use crate::workspace_index::WorkspaceIndex;
1845
1846 let rule = MD057ExistingRelativeLinks::new();
1847
1848 let mut workspace_index = WorkspaceIndex::new();
1850 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1851
1852 let mut file_index = FileIndex::new();
1854 file_index.add_cross_file_link(CrossFileLinkIndex {
1855 target_path: "guide.md".to_string(),
1856 fragment: "".to_string(),
1857 line: 5,
1858 column: 1,
1859 });
1860
1861 let warnings = rule
1863 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1864 .unwrap();
1865
1866 assert!(warnings.is_empty());
1868 }
1869
1870 #[test]
1871 fn test_cross_file_check_missing_link() {
1872 use crate::workspace_index::WorkspaceIndex;
1873
1874 let rule = MD057ExistingRelativeLinks::new();
1875
1876 let workspace_index = WorkspaceIndex::new();
1878
1879 let mut file_index = FileIndex::new();
1881 file_index.add_cross_file_link(CrossFileLinkIndex {
1882 target_path: "missing.md".to_string(),
1883 fragment: "".to_string(),
1884 line: 5,
1885 column: 1,
1886 });
1887
1888 let warnings = rule
1890 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1891 .unwrap();
1892
1893 assert_eq!(warnings.len(), 1);
1895 assert!(warnings[0].message.contains("missing.md"));
1896 assert!(warnings[0].message.contains("does not exist"));
1897 }
1898
1899 #[test]
1900 fn test_cross_file_check_parent_path() {
1901 use crate::workspace_index::WorkspaceIndex;
1902
1903 let rule = MD057ExistingRelativeLinks::new();
1904
1905 let mut workspace_index = WorkspaceIndex::new();
1907 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1908
1909 let mut file_index = FileIndex::new();
1911 file_index.add_cross_file_link(CrossFileLinkIndex {
1912 target_path: "../readme.md".to_string(),
1913 fragment: "".to_string(),
1914 line: 5,
1915 column: 1,
1916 });
1917
1918 let warnings = rule
1920 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1921 .unwrap();
1922
1923 assert!(warnings.is_empty());
1925 }
1926
1927 #[test]
1928 fn test_cross_file_check_html_link_with_md_source() {
1929 use crate::workspace_index::WorkspaceIndex;
1932
1933 let rule = MD057ExistingRelativeLinks::new();
1934
1935 let mut workspace_index = WorkspaceIndex::new();
1937 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1938
1939 let mut file_index = FileIndex::new();
1941 file_index.add_cross_file_link(CrossFileLinkIndex {
1942 target_path: "guide.html".to_string(),
1943 fragment: "section".to_string(),
1944 line: 10,
1945 column: 5,
1946 });
1947
1948 let warnings = rule
1950 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1951 .unwrap();
1952
1953 assert!(
1955 warnings.is_empty(),
1956 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_cross_file_check_html_link_without_source() {
1962 use crate::workspace_index::WorkspaceIndex;
1964
1965 let rule = MD057ExistingRelativeLinks::new();
1966
1967 let workspace_index = WorkspaceIndex::new();
1969
1970 let mut file_index = FileIndex::new();
1972 file_index.add_cross_file_link(CrossFileLinkIndex {
1973 target_path: "missing.html".to_string(),
1974 fragment: "".to_string(),
1975 line: 10,
1976 column: 5,
1977 });
1978
1979 let warnings = rule
1981 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1982 .unwrap();
1983
1984 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1986 assert!(warnings[0].message.contains("missing.html"));
1987 }
1988
1989 #[test]
1990 fn test_normalize_path_function() {
1991 assert_eq!(
1993 normalize_path(Path::new("docs/guide.md")),
1994 PathBuf::from("docs/guide.md")
1995 );
1996
1997 assert_eq!(
1999 normalize_path(Path::new("./docs/guide.md")),
2000 PathBuf::from("docs/guide.md")
2001 );
2002
2003 assert_eq!(
2005 normalize_path(Path::new("docs/sub/../guide.md")),
2006 PathBuf::from("docs/guide.md")
2007 );
2008
2009 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2011 }
2012
2013 #[test]
2014 fn test_html_link_with_md_source() {
2015 let temp_dir = tempdir().unwrap();
2017 let base_path = temp_dir.path();
2018
2019 let md_file = base_path.join("guide.md");
2021 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2022
2023 let content = r#"
2024[Read the guide](guide.html)
2025[Also here](getting-started.html)
2026"#;
2027
2028 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2029 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let result = rule.check(&ctx).unwrap();
2031
2032 assert_eq!(
2034 result.len(),
2035 1,
2036 "Should only warn about missing source. Got: {result:?}"
2037 );
2038 assert!(result[0].message.contains("getting-started.html"));
2039 }
2040
2041 #[test]
2042 fn test_htm_link_with_md_source() {
2043 let temp_dir = tempdir().unwrap();
2045 let base_path = temp_dir.path();
2046
2047 let md_file = base_path.join("page.md");
2048 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2049
2050 let content = "[Page](page.htm)";
2051
2052 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2053 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let result = rule.check(&ctx).unwrap();
2055
2056 assert!(
2057 result.is_empty(),
2058 "Should not warn when .md source exists for .htm link"
2059 );
2060 }
2061
2062 #[test]
2063 fn test_html_link_finds_various_markdown_extensions() {
2064 let temp_dir = tempdir().unwrap();
2066 let base_path = temp_dir.path();
2067
2068 File::create(base_path.join("doc.md")).unwrap();
2069 File::create(base_path.join("tutorial.mdx")).unwrap();
2070 File::create(base_path.join("guide.markdown")).unwrap();
2071
2072 let content = r#"
2073[Doc](doc.html)
2074[Tutorial](tutorial.html)
2075[Guide](guide.html)
2076"#;
2077
2078 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2079 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2080 let result = rule.check(&ctx).unwrap();
2081
2082 assert!(
2083 result.is_empty(),
2084 "Should find all markdown variants as source files. Got: {result:?}"
2085 );
2086 }
2087
2088 #[test]
2089 fn test_html_link_in_subdirectory() {
2090 let temp_dir = tempdir().unwrap();
2092 let base_path = temp_dir.path();
2093
2094 let docs_dir = base_path.join("docs");
2095 std::fs::create_dir(&docs_dir).unwrap();
2096 File::create(docs_dir.join("guide.md"))
2097 .unwrap()
2098 .write_all(b"# Guide")
2099 .unwrap();
2100
2101 let content = "[Guide](docs/guide.html)";
2102
2103 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2104 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let result = rule.check(&ctx).unwrap();
2106
2107 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2108 }
2109
2110 #[test]
2111 fn test_absolute_path_skipped_in_check() {
2112 let temp_dir = tempdir().unwrap();
2115 let base_path = temp_dir.path();
2116
2117 let content = r#"
2118# Test Document
2119
2120[Go Runtime](/pkg/runtime)
2121[Go Runtime with Fragment](/pkg/runtime#section)
2122[API Docs](/api/v1/users)
2123[Blog Post](/blog/2024/release.html)
2124[React Hook](/react/hooks/use-state.html)
2125"#;
2126
2127 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2128 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2129 let result = rule.check(&ctx).unwrap();
2130
2131 assert!(
2133 result.is_empty(),
2134 "Absolute paths should be skipped. Got warnings: {result:?}"
2135 );
2136 }
2137
2138 #[test]
2139 fn test_absolute_path_skipped_in_cross_file_check() {
2140 use crate::workspace_index::WorkspaceIndex;
2142
2143 let rule = MD057ExistingRelativeLinks::new();
2144
2145 let workspace_index = WorkspaceIndex::new();
2147
2148 let mut file_index = FileIndex::new();
2150 file_index.add_cross_file_link(CrossFileLinkIndex {
2151 target_path: "/pkg/runtime.md".to_string(),
2152 fragment: "".to_string(),
2153 line: 5,
2154 column: 1,
2155 });
2156 file_index.add_cross_file_link(CrossFileLinkIndex {
2157 target_path: "/api/v1/users.md".to_string(),
2158 fragment: "section".to_string(),
2159 line: 10,
2160 column: 1,
2161 });
2162
2163 let warnings = rule
2165 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2166 .unwrap();
2167
2168 assert!(
2170 warnings.is_empty(),
2171 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2172 );
2173 }
2174
2175 #[test]
2176 fn test_protocol_relative_url_not_skipped() {
2177 let temp_dir = tempdir().unwrap();
2180 let base_path = temp_dir.path();
2181
2182 let content = r#"
2183# Test Document
2184
2185[External](//example.com/page)
2186[Another](//cdn.example.com/asset.js)
2187"#;
2188
2189 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2190 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2191 let result = rule.check(&ctx).unwrap();
2192
2193 assert!(
2195 result.is_empty(),
2196 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_email_addresses_skipped() {
2202 let temp_dir = tempdir().unwrap();
2205 let base_path = temp_dir.path();
2206
2207 let content = r#"
2208# Test Document
2209
2210[Contact](user@example.com)
2211[Steering](steering@kubernetes.io)
2212[Support](john.doe+filter@company.co.uk)
2213[User](user_name@sub.domain.com)
2214"#;
2215
2216 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2217 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2218 let result = rule.check(&ctx).unwrap();
2219
2220 assert!(
2222 result.is_empty(),
2223 "Email addresses should be skipped. Got warnings: {result:?}"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_email_addresses_vs_file_paths() {
2229 let temp_dir = tempdir().unwrap();
2232 let base_path = temp_dir.path();
2233
2234 let content = r#"
2235# Test Document
2236
2237[Email](user@example.com) <!-- Should be skipped (email) -->
2238[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2239[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2240"#;
2241
2242 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2243 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2244 let result = rule.check(&ctx).unwrap();
2245
2246 assert!(
2248 result.is_empty(),
2249 "All email addresses should be skipped. Got: {result:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_diagnostic_position_accuracy() {
2255 let temp_dir = tempdir().unwrap();
2257 let base_path = temp_dir.path();
2258
2259 let content = "prefix [text](missing.md) suffix";
2262 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2266 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267 let result = rule.check(&ctx).unwrap();
2268
2269 assert_eq!(result.len(), 1, "Should have exactly one warning");
2270 assert_eq!(result[0].line, 1, "Should be on line 1");
2271 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2272 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2273 }
2274
2275 #[test]
2276 fn test_diagnostic_position_angle_brackets() {
2277 let temp_dir = tempdir().unwrap();
2279 let base_path = temp_dir.path();
2280
2281 let content = "[link](<missing.md>)";
2284 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2287 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288 let result = rule.check(&ctx).unwrap();
2289
2290 assert_eq!(result.len(), 1, "Should have exactly one warning");
2291 assert_eq!(result[0].line, 1, "Should be on line 1");
2292 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2293 }
2294
2295 #[test]
2296 fn test_diagnostic_position_multiline() {
2297 let temp_dir = tempdir().unwrap();
2299 let base_path = temp_dir.path();
2300
2301 let content = r#"# Title
2302Some text on line 2
2303[link on line 3](missing1.md)
2304More text
2305[link on line 5](missing2.md)"#;
2306
2307 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2308 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2309 let result = rule.check(&ctx).unwrap();
2310
2311 assert_eq!(result.len(), 2, "Should have two warnings");
2312
2313 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2315 assert!(result[0].message.contains("missing1.md"));
2316
2317 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2319 assert!(result[1].message.contains("missing2.md"));
2320 }
2321
2322 #[test]
2323 fn test_diagnostic_position_with_spaces() {
2324 let temp_dir = tempdir().unwrap();
2326 let base_path = temp_dir.path();
2327
2328 let content = "[link]( missing.md )";
2329 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2334 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335 let result = rule.check(&ctx).unwrap();
2336
2337 assert_eq!(result.len(), 1, "Should have exactly one warning");
2338 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2340 }
2341
2342 #[test]
2343 fn test_diagnostic_position_image() {
2344 let temp_dir = tempdir().unwrap();
2346 let base_path = temp_dir.path();
2347
2348 let content = "";
2349
2350 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2351 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let result = rule.check(&ctx).unwrap();
2353
2354 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2355 assert_eq!(result[0].line, 1);
2356 assert!(result[0].column > 0, "Should have valid column position");
2358 assert!(result[0].message.contains("missing.jpg"));
2359 }
2360
2361 #[test]
2362 fn test_wikilinks_skipped() {
2363 let temp_dir = tempdir().unwrap();
2366 let base_path = temp_dir.path();
2367
2368 let content = r#"# Test Document
2369
2370[[Microsoft#Windows OS]]
2371[[SomePage]]
2372[[Page With Spaces]]
2373[[path/to/page#section]]
2374[[page|Display Text]]
2375
2376This is a [real missing link](missing.md) that should be flagged.
2377"#;
2378
2379 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2380 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2381 let result = rule.check(&ctx).unwrap();
2382
2383 assert_eq!(
2385 result.len(),
2386 1,
2387 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2388 );
2389 assert!(
2390 result[0].message.contains("missing.md"),
2391 "Warning should be for missing.md, not wikilinks"
2392 );
2393 }
2394
2395 #[test]
2396 fn test_wikilinks_not_added_to_index() {
2397 let temp_dir = tempdir().unwrap();
2399 let base_path = temp_dir.path();
2400
2401 let content = r#"# Test Document
2402
2403[[Microsoft#Windows OS]]
2404[[SomePage#section]]
2405[Regular Link](other.md)
2406"#;
2407
2408 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2409 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2410
2411 let mut file_index = FileIndex::new();
2412 rule.contribute_to_index(&ctx, &mut file_index);
2413
2414 let cross_file_links = &file_index.cross_file_links;
2417 assert_eq!(
2418 cross_file_links.len(),
2419 1,
2420 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2421 );
2422 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2423 }
2424
2425 #[test]
2426 fn test_reference_definition_missing_file() {
2427 let temp_dir = tempdir().unwrap();
2429 let base_path = temp_dir.path();
2430
2431 let content = r#"# Test Document
2432
2433[test]: ./missing.md
2434[example]: ./nonexistent.html
2435
2436Use [test] and [example] here.
2437"#;
2438
2439 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2440 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2441 let result = rule.check(&ctx).unwrap();
2442
2443 assert_eq!(
2445 result.len(),
2446 2,
2447 "Should have warnings for missing reference definition targets. Got: {result:?}"
2448 );
2449 assert!(
2450 result.iter().any(|w| w.message.contains("missing.md")),
2451 "Should warn about missing.md"
2452 );
2453 assert!(
2454 result.iter().any(|w| w.message.contains("nonexistent.html")),
2455 "Should warn about nonexistent.html"
2456 );
2457 }
2458
2459 #[test]
2460 fn test_reference_definition_existing_file() {
2461 let temp_dir = tempdir().unwrap();
2463 let base_path = temp_dir.path();
2464
2465 let exists_path = base_path.join("exists.md");
2467 File::create(&exists_path)
2468 .unwrap()
2469 .write_all(b"# Existing file")
2470 .unwrap();
2471
2472 let content = r#"# Test Document
2473
2474[test]: ./exists.md
2475
2476Use [test] here.
2477"#;
2478
2479 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2480 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2481 let result = rule.check(&ctx).unwrap();
2482
2483 assert!(
2485 result.is_empty(),
2486 "Should not warn about existing file. Got: {result:?}"
2487 );
2488 }
2489
2490 #[test]
2491 fn test_reference_definition_external_url_skipped() {
2492 let temp_dir = tempdir().unwrap();
2494 let base_path = temp_dir.path();
2495
2496 let content = r#"# Test Document
2497
2498[google]: https://google.com
2499[example]: http://example.org
2500[mail]: mailto:test@example.com
2501[ftp]: ftp://files.example.com
2502[local]: ./missing.md
2503
2504Use [google], [example], [mail], [ftp], [local] here.
2505"#;
2506
2507 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2508 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2509 let result = rule.check(&ctx).unwrap();
2510
2511 assert_eq!(
2513 result.len(),
2514 1,
2515 "Should only warn about local missing file. Got: {result:?}"
2516 );
2517 assert!(
2518 result[0].message.contains("missing.md"),
2519 "Warning should be for missing.md"
2520 );
2521 }
2522
2523 #[test]
2524 fn test_reference_definition_fragment_only_skipped() {
2525 let temp_dir = tempdir().unwrap();
2527 let base_path = temp_dir.path();
2528
2529 let content = r#"# Test Document
2530
2531[section]: #my-section
2532
2533Use [section] here.
2534"#;
2535
2536 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2537 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2538 let result = rule.check(&ctx).unwrap();
2539
2540 assert!(
2542 result.is_empty(),
2543 "Should not warn about fragment-only reference. Got: {result:?}"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_reference_definition_column_position() {
2549 let temp_dir = tempdir().unwrap();
2551 let base_path = temp_dir.path();
2552
2553 let content = "[ref]: ./missing.md";
2556 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2560 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2561 let result = rule.check(&ctx).unwrap();
2562
2563 assert_eq!(result.len(), 1, "Should have exactly one warning");
2564 assert_eq!(result[0].line, 1, "Should be on line 1");
2565 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2566 }
2567
2568 #[test]
2569 fn test_reference_definition_html_with_md_source() {
2570 let temp_dir = tempdir().unwrap();
2572 let base_path = temp_dir.path();
2573
2574 let md_file = base_path.join("guide.md");
2576 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2577
2578 let content = r#"# Test Document
2579
2580[guide]: ./guide.html
2581[missing]: ./missing.html
2582
2583Use [guide] and [missing] here.
2584"#;
2585
2586 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2587 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2588 let result = rule.check(&ctx).unwrap();
2589
2590 assert_eq!(
2592 result.len(),
2593 1,
2594 "Should only warn about missing source. Got: {result:?}"
2595 );
2596 assert!(result[0].message.contains("missing.html"));
2597 }
2598
2599 #[test]
2600 fn test_reference_definition_url_encoded() {
2601 let temp_dir = tempdir().unwrap();
2603 let base_path = temp_dir.path();
2604
2605 let file_with_spaces = base_path.join("file with spaces.md");
2607 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2608
2609 let content = r#"# Test Document
2610
2611[spaces]: ./file%20with%20spaces.md
2612[missing]: ./missing%20file.md
2613
2614Use [spaces] and [missing] here.
2615"#;
2616
2617 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2618 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2619 let result = rule.check(&ctx).unwrap();
2620
2621 assert_eq!(
2623 result.len(),
2624 1,
2625 "Should only warn about missing URL-encoded file. Got: {result:?}"
2626 );
2627 assert!(result[0].message.contains("missing%20file.md"));
2628 }
2629
2630 #[test]
2631 fn test_inline_and_reference_both_checked() {
2632 let temp_dir = tempdir().unwrap();
2634 let base_path = temp_dir.path();
2635
2636 let content = r#"# Test Document
2637
2638[inline link](./inline-missing.md)
2639[ref]: ./ref-missing.md
2640
2641Use [ref] here.
2642"#;
2643
2644 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2646 let result = rule.check(&ctx).unwrap();
2647
2648 assert_eq!(
2650 result.len(),
2651 2,
2652 "Should warn about both inline and reference links. Got: {result:?}"
2653 );
2654 assert!(
2655 result.iter().any(|w| w.message.contains("inline-missing.md")),
2656 "Should warn about inline-missing.md"
2657 );
2658 assert!(
2659 result.iter().any(|w| w.message.contains("ref-missing.md")),
2660 "Should warn about ref-missing.md"
2661 );
2662 }
2663
2664 #[test]
2665 fn test_footnote_definitions_not_flagged() {
2666 let rule = MD057ExistingRelativeLinks::default();
2669
2670 let content = r#"# Title
2671
2672A footnote[^1].
2673
2674[^1]: [link](https://www.google.com).
2675"#;
2676
2677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2678 let result = rule.check(&ctx).unwrap();
2679
2680 assert!(
2681 result.is_empty(),
2682 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2683 );
2684 }
2685
2686 #[test]
2687 fn test_footnote_with_relative_link_inside() {
2688 let rule = MD057ExistingRelativeLinks::default();
2691
2692 let content = r#"# Title
2693
2694See the footnote[^1].
2695
2696[^1]: Check out [this file](./existing.md) for more info.
2697[^2]: Also see [missing](./does-not-exist.md).
2698"#;
2699
2700 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2701 let result = rule.check(&ctx).unwrap();
2702
2703 for warning in &result {
2708 assert!(
2709 !warning.message.contains("[this file]"),
2710 "Footnote content should not be treated as URL: {warning:?}"
2711 );
2712 assert!(
2713 !warning.message.contains("[missing]"),
2714 "Footnote content should not be treated as URL: {warning:?}"
2715 );
2716 }
2717 }
2718
2719 #[test]
2720 fn test_mixed_footnotes_and_reference_definitions() {
2721 let temp_dir = tempdir().unwrap();
2723 let base_path = temp_dir.path();
2724
2725 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2726
2727 let content = r#"# Title
2728
2729A footnote[^1] and a [ref link][myref].
2730
2731[^1]: This is a footnote with [link](https://example.com).
2732
2733[myref]: ./missing-file.md "This should be checked"
2734"#;
2735
2736 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2737 let result = rule.check(&ctx).unwrap();
2738
2739 assert_eq!(
2741 result.len(),
2742 1,
2743 "Should only warn about the regular reference definition. Got: {result:?}"
2744 );
2745 assert!(
2746 result[0].message.contains("missing-file.md"),
2747 "Should warn about missing-file.md in reference definition"
2748 );
2749 }
2750
2751 #[test]
2752 fn test_absolute_links_ignore_by_default() {
2753 let temp_dir = tempdir().unwrap();
2755 let base_path = temp_dir.path();
2756
2757 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2758
2759 let content = r#"# Links
2760
2761[API docs](/api/v1/users)
2762[Blog post](/blog/2024/release.html)
2763
2764
2765[ref]: /docs/reference.md
2766"#;
2767
2768 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2769 let result = rule.check(&ctx).unwrap();
2770
2771 assert!(
2773 result.is_empty(),
2774 "Absolute links should be ignored by default. Got: {result:?}"
2775 );
2776 }
2777
2778 #[test]
2779 fn test_absolute_links_warn_config() {
2780 let temp_dir = tempdir().unwrap();
2782 let base_path = temp_dir.path();
2783
2784 let config = MD057Config {
2785 absolute_links: AbsoluteLinksOption::Warn,
2786 ..Default::default()
2787 };
2788 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2789
2790 let content = r#"# Links
2791
2792[API docs](/api/v1/users)
2793[Blog post](/blog/2024/release.html)
2794"#;
2795
2796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2797 let result = rule.check(&ctx).unwrap();
2798
2799 assert_eq!(
2801 result.len(),
2802 2,
2803 "Should warn about both absolute links. Got: {result:?}"
2804 );
2805 assert!(
2806 result[0].message.contains("cannot be validated locally"),
2807 "Warning should explain why: {}",
2808 result[0].message
2809 );
2810 assert!(
2811 result[0].message.contains("/api/v1/users"),
2812 "Warning should include the link path"
2813 );
2814 }
2815
2816 #[test]
2817 fn test_absolute_links_warn_images() {
2818 let temp_dir = tempdir().unwrap();
2820 let base_path = temp_dir.path();
2821
2822 let config = MD057Config {
2823 absolute_links: AbsoluteLinksOption::Warn,
2824 ..Default::default()
2825 };
2826 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2827
2828 let content = r#"# Images
2829
2830
2831"#;
2832
2833 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2834 let result = rule.check(&ctx).unwrap();
2835
2836 assert_eq!(
2837 result.len(),
2838 1,
2839 "Should warn about absolute image path. Got: {result:?}"
2840 );
2841 assert!(
2842 result[0].message.contains("/assets/logo.png"),
2843 "Warning should include the image path"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_absolute_links_warn_reference_definitions() {
2849 let temp_dir = tempdir().unwrap();
2851 let base_path = temp_dir.path();
2852
2853 let config = MD057Config {
2854 absolute_links: AbsoluteLinksOption::Warn,
2855 ..Default::default()
2856 };
2857 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2858
2859 let content = r#"# Reference
2860
2861See the [docs][ref].
2862
2863[ref]: /docs/reference.md
2864"#;
2865
2866 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2867 let result = rule.check(&ctx).unwrap();
2868
2869 assert_eq!(
2870 result.len(),
2871 1,
2872 "Should warn about absolute reference definition. Got: {result:?}"
2873 );
2874 assert!(
2875 result[0].message.contains("/docs/reference.md"),
2876 "Warning should include the reference path"
2877 );
2878 }
2879}