1use crate::rule::{CrossFileScope, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::element_cache::ElementCache;
8use crate::workspace_index::{FileIndex, extract_cross_file_links};
9use regex::Regex;
10use std::collections::HashMap;
11use std::env;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14use std::sync::{Arc, Mutex};
15
16mod md057_config;
17use crate::rule_config_serde::RuleConfig;
18use crate::utils::mkdocs_config::resolve_docs_dir;
19pub use md057_config::{AbsoluteLinksOption, MD057Config};
20
21static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
23 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
24
25fn reset_file_existence_cache() {
27 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
28 cache.clear();
29 }
30}
31
32fn file_exists_with_cache(path: &Path) -> bool {
34 match FILE_EXISTENCE_CACHE.lock() {
35 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
36 Err(_) => path.exists(), }
38}
39
40fn file_exists_or_markdown_extension(path: &Path) -> bool {
43 if file_exists_with_cache(path) {
45 return true;
46 }
47
48 if path.extension().is_none() {
50 for ext in MARKDOWN_EXTENSIONS {
51 let path_with_ext = path.with_extension(&ext[1..]);
53 if file_exists_with_cache(&path_with_ext) {
54 return true;
55 }
56 }
57 }
58
59 false
60}
61
62static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
64
65static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
69 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
70
71static URL_EXTRACT_REGEX: LazyLock<Regex> =
74 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
75
76static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
80 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
81
82static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
84
85#[inline]
88fn hex_digit_to_value(byte: u8) -> Option<u8> {
89 match byte {
90 b'0'..=b'9' => Some(byte - b'0'),
91 b'a'..=b'f' => Some(byte - b'a' + 10),
92 b'A'..=b'F' => Some(byte - b'A' + 10),
93 _ => None,
94 }
95}
96
97const MARKDOWN_EXTENSIONS: &[&str] = &[
99 ".md",
100 ".markdown",
101 ".mdx",
102 ".mkd",
103 ".mkdn",
104 ".mdown",
105 ".mdwn",
106 ".qmd",
107 ".rmd",
108];
109
110#[derive(Debug, Clone)]
112pub struct MD057ExistingRelativeLinks {
113 base_path: Arc<Mutex<Option<PathBuf>>>,
115 config: MD057Config,
117}
118
119impl Default for MD057ExistingRelativeLinks {
120 fn default() -> Self {
121 Self {
122 base_path: Arc::new(Mutex::new(None)),
123 config: MD057Config::default(),
124 }
125 }
126}
127
128impl MD057ExistingRelativeLinks {
129 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
136 let path = path.as_ref();
137 let dir_path = if path.is_file() {
138 path.parent().map(|p| p.to_path_buf())
139 } else {
140 Some(path.to_path_buf())
141 };
142
143 if let Ok(mut guard) = self.base_path.lock() {
144 *guard = dir_path;
145 }
146 self
147 }
148
149 pub fn from_config_struct(config: MD057Config) -> Self {
150 Self {
151 base_path: Arc::new(Mutex::new(None)),
152 config,
153 }
154 }
155
156 #[inline]
168 fn is_external_url(&self, url: &str) -> bool {
169 if url.is_empty() {
170 return false;
171 }
172
173 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
175 return true;
176 }
177
178 if url.starts_with("{{") || url.starts_with("{%") {
181 return true;
182 }
183
184 if url.contains('@') {
187 return true; }
189
190 if url.ends_with(".com") {
197 return true;
198 }
199
200 if url.starts_with('~') || url.starts_with('@') {
204 return true;
205 }
206
207 false
209 }
210
211 #[inline]
213 fn is_fragment_only_link(&self, url: &str) -> bool {
214 url.starts_with('#')
215 }
216
217 #[inline]
220 fn is_absolute_path(url: &str) -> bool {
221 url.starts_with('/')
222 }
223
224 fn url_decode(path: &str) -> String {
228 if !path.contains('%') {
230 return path.to_string();
231 }
232
233 let bytes = path.as_bytes();
234 let mut result = Vec::with_capacity(bytes.len());
235 let mut i = 0;
236
237 while i < bytes.len() {
238 if bytes[i] == b'%' && i + 2 < bytes.len() {
239 let hex1 = bytes[i + 1];
241 let hex2 = bytes[i + 2];
242 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
243 result.push(d1 * 16 + d2);
244 i += 3;
245 continue;
246 }
247 }
248 result.push(bytes[i]);
249 i += 1;
250 }
251
252 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
254 }
255
256 fn strip_query_and_fragment(url: &str) -> &str {
264 let query_pos = url.find('?');
267 let fragment_pos = url.find('#');
268
269 match (query_pos, fragment_pos) {
270 (Some(q), Some(f)) => {
271 &url[..q.min(f)]
273 }
274 (Some(q), None) => &url[..q],
275 (None, Some(f)) => &url[..f],
276 (None, None) => url,
277 }
278 }
279
280 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
282 base_path.join(link)
283 }
284
285 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
290 let Some(docs_dir) = resolve_docs_dir(source_path) else {
291 return Some(format!(
293 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
294 ));
295 };
296
297 let relative_url = url.trim_start_matches('/');
299
300 let file_path = Self::strip_query_and_fragment(relative_url);
302 let decoded = Self::url_decode(file_path);
303 let resolved_path = docs_dir.join(&decoded);
304
305 let is_directory_link = url.ends_with('/') || decoded.is_empty();
310 if is_directory_link || resolved_path.is_dir() {
311 let index_path = resolved_path.join("index.md");
312 if file_exists_with_cache(&index_path) {
313 return None; }
315 if resolved_path.is_dir() {
317 return Some(format!(
318 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
319 resolved_path.display()
320 ));
321 }
322 }
323
324 if file_exists_or_markdown_extension(&resolved_path) {
326 return None; }
328
329 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
331 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
332 && let (Some(stem), Some(parent)) = (
333 resolved_path.file_stem().and_then(|s| s.to_str()),
334 resolved_path.parent(),
335 )
336 {
337 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
338 let source_path = parent.join(format!("{stem}{md_ext}"));
339 file_exists_with_cache(&source_path)
340 });
341 if has_md_source {
342 return None; }
344 }
345
346 Some(format!(
347 "Absolute link '{url}' resolves to '{}' which does not exist",
348 resolved_path.display()
349 ))
350 }
351}
352
353impl Rule for MD057ExistingRelativeLinks {
354 fn name(&self) -> &'static str {
355 "MD057"
356 }
357
358 fn description(&self) -> &'static str {
359 "Relative links should point to existing files"
360 }
361
362 fn category(&self) -> RuleCategory {
363 RuleCategory::Link
364 }
365
366 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
367 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
368 }
369
370 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
371 let content = ctx.content;
372
373 if content.is_empty() || !content.contains('[') {
375 return Ok(Vec::new());
376 }
377
378 if !content.contains("](") && !content.contains("]:") {
381 return Ok(Vec::new());
382 }
383
384 reset_file_existence_cache();
386
387 let mut warnings = Vec::new();
388
389 let base_path: Option<PathBuf> = {
393 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
395 if explicit_base.is_some() {
396 explicit_base
397 } else if let Some(ref source_file) = ctx.source_file {
398 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
402 resolved_file
403 .parent()
404 .map(|p| p.to_path_buf())
405 .or_else(|| Some(CURRENT_DIR.clone()))
406 } else {
407 None
409 }
410 };
411
412 let Some(base_path) = base_path else {
414 return Ok(warnings);
415 };
416
417 if !ctx.links.is_empty() {
419 let line_index = &ctx.line_index;
421
422 let element_cache = ElementCache::new(content);
424
425 let lines = ctx.raw_lines();
427
428 let mut processed_lines = std::collections::HashSet::new();
431
432 for link in &ctx.links {
433 let line_idx = link.line - 1;
434 if line_idx >= lines.len() {
435 continue;
436 }
437
438 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
441 continue;
442 }
443
444 if !processed_lines.insert(line_idx) {
446 continue;
447 }
448
449 let line = lines[line_idx];
450
451 if !line.contains("](") {
453 continue;
454 }
455
456 for link_match in LINK_START_REGEX.find_iter(line) {
458 let start_pos = link_match.start();
459 let end_pos = link_match.end();
460
461 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
463 let absolute_start_pos = line_start_byte + start_pos;
464
465 if element_cache.is_in_code_span(absolute_start_pos) {
467 continue;
468 }
469
470 if ctx.is_in_math_span(absolute_start_pos) {
472 continue;
473 }
474
475 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
479 .captures_at(line, end_pos - 1)
480 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
481 .or_else(|| {
482 URL_EXTRACT_REGEX
483 .captures_at(line, end_pos - 1)
484 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
485 });
486
487 if let Some((_caps, url_group)) = caps_and_url {
488 let url = url_group.as_str().trim();
489
490 if url.is_empty() {
492 continue;
493 }
494
495 if url.starts_with('`') && url.ends_with('`') {
499 continue;
500 }
501
502 if self.is_external_url(url) || self.is_fragment_only_link(url) {
504 continue;
505 }
506
507 if Self::is_absolute_path(url) {
509 match self.config.absolute_links {
510 AbsoluteLinksOption::Warn => {
511 let url_start = url_group.start();
512 let url_end = url_group.end();
513 warnings.push(LintWarning {
514 rule_name: Some(self.name().to_string()),
515 line: link.line,
516 column: url_start + 1,
517 end_line: link.line,
518 end_column: url_end + 1,
519 message: format!("Absolute link '{url}' cannot be validated locally"),
520 severity: Severity::Warning,
521 fix: None,
522 });
523 }
524 AbsoluteLinksOption::RelativeToDocs => {
525 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
526 let url_start = url_group.start();
527 let url_end = url_group.end();
528 warnings.push(LintWarning {
529 rule_name: Some(self.name().to_string()),
530 line: link.line,
531 column: url_start + 1,
532 end_line: link.line,
533 end_column: url_end + 1,
534 message: msg,
535 severity: Severity::Warning,
536 fix: None,
537 });
538 }
539 }
540 AbsoluteLinksOption::Ignore => {}
541 }
542 continue;
543 }
544
545 let file_path = Self::strip_query_and_fragment(url);
547
548 let decoded_path = Self::url_decode(file_path);
550
551 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
553
554 if file_exists_or_markdown_extension(&resolved_path) {
556 continue; }
558
559 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
561 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
562 && let (Some(stem), Some(parent)) = (
563 resolved_path.file_stem().and_then(|s| s.to_str()),
564 resolved_path.parent(),
565 ) {
566 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
567 let source_path = parent.join(format!("{stem}{md_ext}"));
568 file_exists_with_cache(&source_path)
569 })
570 } else {
571 false
572 };
573
574 if has_md_source {
575 continue; }
577
578 let url_start = url_group.start();
582 let url_end = url_group.end();
583
584 warnings.push(LintWarning {
585 rule_name: Some(self.name().to_string()),
586 line: link.line,
587 column: url_start + 1, end_line: link.line,
589 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
591 severity: Severity::Error,
592 fix: None,
593 });
594 }
595 }
596 }
597 }
598
599 for image in &ctx.images {
601 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
603 continue;
604 }
605
606 let url = image.url.as_ref();
607
608 if url.is_empty() {
610 continue;
611 }
612
613 if self.is_external_url(url) || self.is_fragment_only_link(url) {
615 continue;
616 }
617
618 if Self::is_absolute_path(url) {
620 match self.config.absolute_links {
621 AbsoluteLinksOption::Warn => {
622 warnings.push(LintWarning {
623 rule_name: Some(self.name().to_string()),
624 line: image.line,
625 column: image.start_col + 1,
626 end_line: image.line,
627 end_column: image.start_col + 1 + url.len(),
628 message: format!("Absolute link '{url}' cannot be validated locally"),
629 severity: Severity::Warning,
630 fix: None,
631 });
632 }
633 AbsoluteLinksOption::RelativeToDocs => {
634 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
635 warnings.push(LintWarning {
636 rule_name: Some(self.name().to_string()),
637 line: image.line,
638 column: image.start_col + 1,
639 end_line: image.line,
640 end_column: image.start_col + 1 + url.len(),
641 message: msg,
642 severity: Severity::Warning,
643 fix: None,
644 });
645 }
646 }
647 AbsoluteLinksOption::Ignore => {}
648 }
649 continue;
650 }
651
652 let file_path = Self::strip_query_and_fragment(url);
654
655 let decoded_path = Self::url_decode(file_path);
657
658 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
660
661 if file_exists_or_markdown_extension(&resolved_path) {
663 continue; }
665
666 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
668 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
669 && let (Some(stem), Some(parent)) = (
670 resolved_path.file_stem().and_then(|s| s.to_str()),
671 resolved_path.parent(),
672 ) {
673 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
674 let source_path = parent.join(format!("{stem}{md_ext}"));
675 file_exists_with_cache(&source_path)
676 })
677 } else {
678 false
679 };
680
681 if has_md_source {
682 continue; }
684
685 warnings.push(LintWarning {
688 rule_name: Some(self.name().to_string()),
689 line: image.line,
690 column: image.start_col + 1,
691 end_line: image.line,
692 end_column: image.start_col + 1 + url.len(),
693 message: format!("Relative link '{url}' does not exist"),
694 severity: Severity::Error,
695 fix: None,
696 });
697 }
698
699 for ref_def in &ctx.reference_defs {
701 let url = &ref_def.url;
702
703 if url.is_empty() {
705 continue;
706 }
707
708 if self.is_external_url(url) || self.is_fragment_only_link(url) {
710 continue;
711 }
712
713 if Self::is_absolute_path(url) {
715 match self.config.absolute_links {
716 AbsoluteLinksOption::Warn => {
717 let line_idx = ref_def.line - 1;
718 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
719 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
720 });
721 warnings.push(LintWarning {
722 rule_name: Some(self.name().to_string()),
723 line: ref_def.line,
724 column,
725 end_line: ref_def.line,
726 end_column: column + url.len(),
727 message: format!("Absolute link '{url}' cannot be validated locally"),
728 severity: Severity::Warning,
729 fix: None,
730 });
731 }
732 AbsoluteLinksOption::RelativeToDocs => {
733 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
734 let line_idx = ref_def.line - 1;
735 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
736 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
737 });
738 warnings.push(LintWarning {
739 rule_name: Some(self.name().to_string()),
740 line: ref_def.line,
741 column,
742 end_line: ref_def.line,
743 end_column: column + url.len(),
744 message: msg,
745 severity: Severity::Warning,
746 fix: None,
747 });
748 }
749 }
750 AbsoluteLinksOption::Ignore => {}
751 }
752 continue;
753 }
754
755 let file_path = Self::strip_query_and_fragment(url);
757
758 let decoded_path = Self::url_decode(file_path);
760
761 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
763
764 if file_exists_or_markdown_extension(&resolved_path) {
766 continue; }
768
769 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
771 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
772 && let (Some(stem), Some(parent)) = (
773 resolved_path.file_stem().and_then(|s| s.to_str()),
774 resolved_path.parent(),
775 ) {
776 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
777 let source_path = parent.join(format!("{stem}{md_ext}"));
778 file_exists_with_cache(&source_path)
779 })
780 } else {
781 false
782 };
783
784 if has_md_source {
785 continue; }
787
788 let line_idx = ref_def.line - 1;
791 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
792 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
794 });
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!("Relative link '{url}' does not exist"),
803 severity: Severity::Error,
804 fix: None,
805 });
806 }
807
808 Ok(warnings)
809 }
810
811 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
812 Ok(ctx.content.to_string())
813 }
814
815 fn as_any(&self) -> &dyn std::any::Any {
816 self
817 }
818
819 fn default_config_section(&self) -> Option<(String, toml::Value)> {
820 let default_config = MD057Config::default();
821 let json_value = serde_json::to_value(&default_config).ok()?;
822 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
823
824 if let toml::Value::Table(table) = toml_value {
825 if !table.is_empty() {
826 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
827 } else {
828 None
829 }
830 } else {
831 None
832 }
833 }
834
835 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
836 where
837 Self: Sized,
838 {
839 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
840 Box::new(Self::from_config_struct(rule_config))
841 }
842
843 fn cross_file_scope(&self) -> CrossFileScope {
844 CrossFileScope::Workspace
845 }
846
847 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
848 for link in extract_cross_file_links(ctx) {
851 index.add_cross_file_link(link);
852 }
853 }
854
855 fn cross_file_check(
856 &self,
857 file_path: &Path,
858 file_index: &FileIndex,
859 workspace_index: &crate::workspace_index::WorkspaceIndex,
860 ) -> LintResult {
861 let mut warnings = Vec::new();
862
863 let file_dir = file_path.parent();
865
866 for cross_link in &file_index.cross_file_links {
867 let decoded_target = Self::url_decode(&cross_link.target_path);
870
871 if decoded_target.starts_with('/') {
875 continue;
876 }
877
878 let target_path = if let Some(dir) = file_dir {
880 dir.join(&decoded_target)
881 } else {
882 Path::new(&decoded_target).to_path_buf()
883 };
884
885 let target_path = normalize_path(&target_path);
887
888 let file_exists =
890 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
891
892 if !file_exists {
893 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
896 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
897 && let (Some(stem), Some(parent)) =
898 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
899 {
900 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
901 let source_path = parent.join(format!("{stem}{md_ext}"));
902 workspace_index.contains_file(&source_path) || source_path.exists()
903 })
904 } else {
905 false
906 };
907
908 if !has_md_source {
909 warnings.push(LintWarning {
910 rule_name: Some(self.name().to_string()),
911 line: cross_link.line,
912 column: cross_link.column,
913 end_line: cross_link.line,
914 end_column: cross_link.column + cross_link.target_path.len(),
915 message: format!("Relative link '{}' does not exist", cross_link.target_path),
916 severity: Severity::Error,
917 fix: None,
918 });
919 }
920 }
921 }
922
923 Ok(warnings)
924 }
925}
926
927fn normalize_path(path: &Path) -> PathBuf {
929 let mut components = Vec::new();
930
931 for component in path.components() {
932 match component {
933 std::path::Component::ParentDir => {
934 if !components.is_empty() {
936 components.pop();
937 }
938 }
939 std::path::Component::CurDir => {
940 }
942 _ => {
943 components.push(component);
944 }
945 }
946 }
947
948 components.iter().collect()
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use crate::workspace_index::CrossFileLinkIndex;
955 use std::fs::File;
956 use std::io::Write;
957 use tempfile::tempdir;
958
959 #[test]
960 fn test_strip_query_and_fragment() {
961 assert_eq!(
963 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
964 "file.png"
965 );
966 assert_eq!(
967 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
968 "file.png"
969 );
970 assert_eq!(
971 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
972 "file.png"
973 );
974
975 assert_eq!(
977 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
978 "file.md"
979 );
980 assert_eq!(
981 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
982 "file.md"
983 );
984
985 assert_eq!(
987 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
988 "file.md"
989 );
990
991 assert_eq!(
993 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
994 "file.png"
995 );
996
997 assert_eq!(
999 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1000 "path/to/image.png"
1001 );
1002 assert_eq!(
1003 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1004 "path/to/image.png"
1005 );
1006
1007 assert_eq!(
1009 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1010 "file.md"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_url_decode() {
1016 assert_eq!(
1018 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1019 "penguin with space.jpg"
1020 );
1021
1022 assert_eq!(
1024 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1025 "assets/my file name.png"
1026 );
1027
1028 assert_eq!(
1030 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1031 "hello world!.md"
1032 );
1033
1034 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1036
1037 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1039
1040 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1042
1043 assert_eq!(
1045 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1046 "normal-file.md"
1047 );
1048
1049 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1051
1052 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1054
1055 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1057
1058 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1060
1061 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1063
1064 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1066
1067 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1069
1070 assert_eq!(
1072 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1073 "path/to/file.md"
1074 );
1075
1076 assert_eq!(
1078 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1079 "hello world/foo bar.md"
1080 );
1081
1082 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1084
1085 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1087 }
1088
1089 #[test]
1090 fn test_url_encoded_filenames() {
1091 let temp_dir = tempdir().unwrap();
1093 let base_path = temp_dir.path();
1094
1095 let file_with_spaces = base_path.join("penguin with space.jpg");
1097 File::create(&file_with_spaces)
1098 .unwrap()
1099 .write_all(b"image data")
1100 .unwrap();
1101
1102 let subdir = base_path.join("my images");
1104 std::fs::create_dir(&subdir).unwrap();
1105 let nested_file = subdir.join("photo 1.png");
1106 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1107
1108 let content = r#"
1110# Test Document with URL-Encoded Links
1111
1112
1113
1114
1115"#;
1116
1117 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1118
1119 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121
1122 assert_eq!(
1124 result.len(),
1125 1,
1126 "Should only warn about missing%20file.jpg. Got: {result:?}"
1127 );
1128 assert!(
1129 result[0].message.contains("missing%20file.jpg"),
1130 "Warning should mention the URL-encoded filename"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_external_urls() {
1136 let rule = MD057ExistingRelativeLinks::new();
1137
1138 assert!(rule.is_external_url("https://example.com"));
1140 assert!(rule.is_external_url("http://example.com"));
1141 assert!(rule.is_external_url("ftp://example.com"));
1142 assert!(rule.is_external_url("www.example.com"));
1143 assert!(rule.is_external_url("example.com"));
1144
1145 assert!(rule.is_external_url("file:///path/to/file"));
1147 assert!(rule.is_external_url("smb://server/share"));
1148 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1149 assert!(rule.is_external_url("mailto:user@example.com"));
1150 assert!(rule.is_external_url("tel:+1234567890"));
1151 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1152 assert!(rule.is_external_url("javascript:void(0)"));
1153 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1154 assert!(rule.is_external_url("git://github.com/repo.git"));
1155
1156 assert!(rule.is_external_url("user@example.com"));
1159 assert!(rule.is_external_url("steering@kubernetes.io"));
1160 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1161 assert!(rule.is_external_url("user_name@sub.domain.com"));
1162 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1163
1164 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"));
1175 assert!(!rule.is_external_url("/blog/2024/release.html"));
1176 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1177 assert!(!rule.is_external_url("/pkg/runtime"));
1178 assert!(!rule.is_external_url("/doc/go1compat"));
1179 assert!(!rule.is_external_url("/index.html"));
1180 assert!(!rule.is_external_url("/assets/logo.png"));
1181
1182 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1184 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1185 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1186 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1187 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1188
1189 assert!(rule.is_external_url("~/assets/image.png"));
1192 assert!(rule.is_external_url("~/components/Button.vue"));
1193 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1197 assert!(rule.is_external_url("@images/photo.jpg"));
1198 assert!(rule.is_external_url("@assets/styles.css"));
1199
1200 assert!(!rule.is_external_url("./relative/path.md"));
1202 assert!(!rule.is_external_url("relative/path.md"));
1203 assert!(!rule.is_external_url("../parent/path.md"));
1204 }
1205
1206 #[test]
1207 fn test_framework_path_aliases() {
1208 let temp_dir = tempdir().unwrap();
1210 let base_path = temp_dir.path();
1211
1212 let content = r#"
1214# Framework Path Aliases
1215
1216
1217
1218
1219
1220[Link](@/pages/about.md)
1221
1222This is a [real missing link](missing.md) that should be flagged.
1223"#;
1224
1225 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1226
1227 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229
1230 assert_eq!(
1232 result.len(),
1233 1,
1234 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1235 );
1236 assert!(
1237 result[0].message.contains("missing.md"),
1238 "Warning should be for missing.md"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_url_decode_security_path_traversal() {
1244 let temp_dir = tempdir().unwrap();
1247 let base_path = temp_dir.path();
1248
1249 let file_in_base = base_path.join("safe.md");
1251 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1252
1253 let content = r#"
1258[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1259[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1260[Safe link](safe.md)
1261"#;
1262
1263 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1264
1265 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267
1268 assert_eq!(
1271 result.len(),
1272 2,
1273 "Should have warnings for traversal attempts. Got: {result:?}"
1274 );
1275 }
1276
1277 #[test]
1278 fn test_url_encoded_utf8_filenames() {
1279 let temp_dir = tempdir().unwrap();
1281 let base_path = temp_dir.path();
1282
1283 let cafe_file = base_path.join("café.md");
1285 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1286
1287 let content = r#"
1288[Café link](caf%C3%A9.md)
1289[Missing unicode](r%C3%A9sum%C3%A9.md)
1290"#;
1291
1292 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1293
1294 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let result = rule.check(&ctx).unwrap();
1296
1297 assert_eq!(
1299 result.len(),
1300 1,
1301 "Should only warn about missing résumé.md. Got: {result:?}"
1302 );
1303 assert!(
1304 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1305 "Warning should mention the URL-encoded filename"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_url_encoded_emoji_filenames() {
1311 let temp_dir = tempdir().unwrap();
1314 let base_path = temp_dir.path();
1315
1316 let emoji_dir = base_path.join("👤 Personal");
1318 std::fs::create_dir(&emoji_dir).unwrap();
1319
1320 let file_path = emoji_dir.join("TV Shows.md");
1322 File::create(&file_path)
1323 .unwrap()
1324 .write_all(b"# TV Shows\n\nContent here.")
1325 .unwrap();
1326
1327 let content = r#"
1330# Test Document
1331
1332[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1333[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1334"#;
1335
1336 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1337
1338 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 let result = rule.check(&ctx).unwrap();
1340
1341 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1343 assert!(
1344 result[0].message.contains("Missing.md"),
1345 "Warning should be for Missing.md, got: {}",
1346 result[0].message
1347 );
1348 }
1349
1350 #[test]
1351 fn test_no_warnings_without_base_path() {
1352 let rule = MD057ExistingRelativeLinks::new();
1353 let content = "[Link](missing.md)";
1354
1355 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let result = rule.check(&ctx).unwrap();
1357 assert!(result.is_empty(), "Should have no warnings without base path");
1358 }
1359
1360 #[test]
1361 fn test_existing_and_missing_links() {
1362 let temp_dir = tempdir().unwrap();
1364 let base_path = temp_dir.path();
1365
1366 let exists_path = base_path.join("exists.md");
1368 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1369
1370 assert!(exists_path.exists(), "exists.md should exist for this test");
1372
1373 let content = r#"
1375# Test Document
1376
1377[Valid Link](exists.md)
1378[Invalid Link](missing.md)
1379[External Link](https://example.com)
1380[Media Link](image.jpg)
1381 "#;
1382
1383 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1385
1386 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 let result = rule.check(&ctx).unwrap();
1389
1390 assert_eq!(result.len(), 2);
1392 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1393 assert!(messages.iter().any(|m| m.contains("missing.md")));
1394 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1395 }
1396
1397 #[test]
1398 fn test_angle_bracket_links() {
1399 let temp_dir = tempdir().unwrap();
1401 let base_path = temp_dir.path();
1402
1403 let exists_path = base_path.join("exists.md");
1405 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1406
1407 let content = r#"
1409# Test Document
1410
1411[Valid Link](<exists.md>)
1412[Invalid Link](<missing.md>)
1413[External Link](<https://example.com>)
1414 "#;
1415
1416 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1418
1419 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421
1422 assert_eq!(result.len(), 1, "Should have exactly one warning");
1424 assert!(
1425 result[0].message.contains("missing.md"),
1426 "Warning should mention missing.md"
1427 );
1428 }
1429
1430 #[test]
1431 fn test_angle_bracket_links_with_parens() {
1432 let temp_dir = tempdir().unwrap();
1434 let base_path = temp_dir.path();
1435
1436 let app_dir = base_path.join("app");
1438 std::fs::create_dir(&app_dir).unwrap();
1439 let upload_dir = app_dir.join("(upload)");
1440 std::fs::create_dir(&upload_dir).unwrap();
1441 let page_file = upload_dir.join("page.tsx");
1442 File::create(&page_file)
1443 .unwrap()
1444 .write_all(b"export default function Page() {}")
1445 .unwrap();
1446
1447 let content = r#"
1449# Test Document with Paths Containing Parens
1450
1451[Upload Page](<app/(upload)/page.tsx>)
1452[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1453[Missing](<app/(missing)/file.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!(
1463 result.len(),
1464 1,
1465 "Should have exactly one warning for missing file. Got: {result:?}"
1466 );
1467 assert!(
1468 result[0].message.contains("app/(missing)/file.md"),
1469 "Warning should mention app/(missing)/file.md"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_all_file_types_checked() {
1475 let temp_dir = tempdir().unwrap();
1477 let base_path = temp_dir.path();
1478
1479 let content = r#"
1481[Image Link](image.jpg)
1482[Video Link](video.mp4)
1483[Markdown Link](document.md)
1484[PDF Link](file.pdf)
1485"#;
1486
1487 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1488
1489 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let result = rule.check(&ctx).unwrap();
1491
1492 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1494 }
1495
1496 #[test]
1497 fn test_code_span_detection() {
1498 let rule = MD057ExistingRelativeLinks::new();
1499
1500 let temp_dir = tempdir().unwrap();
1502 let base_path = temp_dir.path();
1503
1504 let rule = rule.with_path(base_path);
1505
1506 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1508
1509 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let result = rule.check(&ctx).unwrap();
1511
1512 assert_eq!(result.len(), 1, "Should only flag the real link");
1514 assert!(result[0].message.contains("nonexistent.md"));
1515 }
1516
1517 #[test]
1518 fn test_inline_code_spans() {
1519 let temp_dir = tempdir().unwrap();
1521 let base_path = temp_dir.path();
1522
1523 let content = r#"
1525# Test Document
1526
1527This is a normal link: [Link](missing.md)
1528
1529This is a code span with a link: `[Link](another-missing.md)`
1530
1531Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1532
1533 "#;
1534
1535 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1537
1538 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1540 let result = rule.check(&ctx).unwrap();
1541
1542 assert_eq!(result.len(), 1, "Should have exactly one warning");
1544 assert!(
1545 result[0].message.contains("missing.md"),
1546 "Warning should be for missing.md"
1547 );
1548 assert!(
1549 !result.iter().any(|w| w.message.contains("another-missing.md")),
1550 "Should not warn about link in code span"
1551 );
1552 assert!(
1553 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1554 "Should not warn about link in inline code"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_extensionless_link_resolution() {
1560 let temp_dir = tempdir().unwrap();
1562 let base_path = temp_dir.path();
1563
1564 let page_path = base_path.join("page.md");
1566 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1567
1568 let content = r#"
1570# Test Document
1571
1572[Link without extension](page)
1573[Link with extension](page.md)
1574[Missing link](nonexistent)
1575"#;
1576
1577 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1578
1579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 let result = rule.check(&ctx).unwrap();
1581
1582 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1585 assert!(
1586 result[0].message.contains("nonexistent"),
1587 "Warning should be for 'nonexistent' not 'page'"
1588 );
1589 }
1590
1591 #[test]
1593 fn test_cross_file_scope() {
1594 let rule = MD057ExistingRelativeLinks::new();
1595 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1596 }
1597
1598 #[test]
1599 fn test_contribute_to_index_extracts_markdown_links() {
1600 let rule = MD057ExistingRelativeLinks::new();
1601 let content = r#"
1602# Document
1603
1604[Link to docs](./docs/guide.md)
1605[Link with fragment](./other.md#section)
1606[External link](https://example.com)
1607[Image link](image.png)
1608[Media file](video.mp4)
1609"#;
1610
1611 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let mut index = FileIndex::new();
1613 rule.contribute_to_index(&ctx, &mut index);
1614
1615 assert_eq!(index.cross_file_links.len(), 2);
1617
1618 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1620 assert_eq!(index.cross_file_links[0].fragment, "");
1621
1622 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1624 assert_eq!(index.cross_file_links[1].fragment, "section");
1625 }
1626
1627 #[test]
1628 fn test_contribute_to_index_skips_external_and_anchors() {
1629 let rule = MD057ExistingRelativeLinks::new();
1630 let content = r#"
1631# Document
1632
1633[External](https://example.com)
1634[Another external](http://example.org)
1635[Fragment only](#section)
1636[FTP link](ftp://files.example.com)
1637[Mail link](mailto:test@example.com)
1638[WWW link](www.example.com)
1639"#;
1640
1641 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let mut index = FileIndex::new();
1643 rule.contribute_to_index(&ctx, &mut index);
1644
1645 assert_eq!(index.cross_file_links.len(), 0);
1647 }
1648
1649 #[test]
1650 fn test_cross_file_check_valid_link() {
1651 use crate::workspace_index::WorkspaceIndex;
1652
1653 let rule = MD057ExistingRelativeLinks::new();
1654
1655 let mut workspace_index = WorkspaceIndex::new();
1657 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1658
1659 let mut file_index = FileIndex::new();
1661 file_index.add_cross_file_link(CrossFileLinkIndex {
1662 target_path: "guide.md".to_string(),
1663 fragment: "".to_string(),
1664 line: 5,
1665 column: 1,
1666 });
1667
1668 let warnings = rule
1670 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1671 .unwrap();
1672
1673 assert!(warnings.is_empty());
1675 }
1676
1677 #[test]
1678 fn test_cross_file_check_missing_link() {
1679 use crate::workspace_index::WorkspaceIndex;
1680
1681 let rule = MD057ExistingRelativeLinks::new();
1682
1683 let workspace_index = WorkspaceIndex::new();
1685
1686 let mut file_index = FileIndex::new();
1688 file_index.add_cross_file_link(CrossFileLinkIndex {
1689 target_path: "missing.md".to_string(),
1690 fragment: "".to_string(),
1691 line: 5,
1692 column: 1,
1693 });
1694
1695 let warnings = rule
1697 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1698 .unwrap();
1699
1700 assert_eq!(warnings.len(), 1);
1702 assert!(warnings[0].message.contains("missing.md"));
1703 assert!(warnings[0].message.contains("does not exist"));
1704 }
1705
1706 #[test]
1707 fn test_cross_file_check_parent_path() {
1708 use crate::workspace_index::WorkspaceIndex;
1709
1710 let rule = MD057ExistingRelativeLinks::new();
1711
1712 let mut workspace_index = WorkspaceIndex::new();
1714 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1715
1716 let mut file_index = FileIndex::new();
1718 file_index.add_cross_file_link(CrossFileLinkIndex {
1719 target_path: "../readme.md".to_string(),
1720 fragment: "".to_string(),
1721 line: 5,
1722 column: 1,
1723 });
1724
1725 let warnings = rule
1727 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1728 .unwrap();
1729
1730 assert!(warnings.is_empty());
1732 }
1733
1734 #[test]
1735 fn test_cross_file_check_html_link_with_md_source() {
1736 use crate::workspace_index::WorkspaceIndex;
1739
1740 let rule = MD057ExistingRelativeLinks::new();
1741
1742 let mut workspace_index = WorkspaceIndex::new();
1744 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1745
1746 let mut file_index = FileIndex::new();
1748 file_index.add_cross_file_link(CrossFileLinkIndex {
1749 target_path: "guide.html".to_string(),
1750 fragment: "section".to_string(),
1751 line: 10,
1752 column: 5,
1753 });
1754
1755 let warnings = rule
1757 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1758 .unwrap();
1759
1760 assert!(
1762 warnings.is_empty(),
1763 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_cross_file_check_html_link_without_source() {
1769 use crate::workspace_index::WorkspaceIndex;
1771
1772 let rule = MD057ExistingRelativeLinks::new();
1773
1774 let workspace_index = WorkspaceIndex::new();
1776
1777 let mut file_index = FileIndex::new();
1779 file_index.add_cross_file_link(CrossFileLinkIndex {
1780 target_path: "missing.html".to_string(),
1781 fragment: "".to_string(),
1782 line: 10,
1783 column: 5,
1784 });
1785
1786 let warnings = rule
1788 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1789 .unwrap();
1790
1791 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1793 assert!(warnings[0].message.contains("missing.html"));
1794 }
1795
1796 #[test]
1797 fn test_normalize_path_function() {
1798 assert_eq!(
1800 normalize_path(Path::new("docs/guide.md")),
1801 PathBuf::from("docs/guide.md")
1802 );
1803
1804 assert_eq!(
1806 normalize_path(Path::new("./docs/guide.md")),
1807 PathBuf::from("docs/guide.md")
1808 );
1809
1810 assert_eq!(
1812 normalize_path(Path::new("docs/sub/../guide.md")),
1813 PathBuf::from("docs/guide.md")
1814 );
1815
1816 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1818 }
1819
1820 #[test]
1821 fn test_html_link_with_md_source() {
1822 let temp_dir = tempdir().unwrap();
1824 let base_path = temp_dir.path();
1825
1826 let md_file = base_path.join("guide.md");
1828 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1829
1830 let content = r#"
1831[Read the guide](guide.html)
1832[Also here](getting-started.html)
1833"#;
1834
1835 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1836 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let result = rule.check(&ctx).unwrap();
1838
1839 assert_eq!(
1841 result.len(),
1842 1,
1843 "Should only warn about missing source. Got: {result:?}"
1844 );
1845 assert!(result[0].message.contains("getting-started.html"));
1846 }
1847
1848 #[test]
1849 fn test_htm_link_with_md_source() {
1850 let temp_dir = tempdir().unwrap();
1852 let base_path = temp_dir.path();
1853
1854 let md_file = base_path.join("page.md");
1855 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1856
1857 let content = "[Page](page.htm)";
1858
1859 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1860 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let result = rule.check(&ctx).unwrap();
1862
1863 assert!(
1864 result.is_empty(),
1865 "Should not warn when .md source exists for .htm link"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_html_link_finds_various_markdown_extensions() {
1871 let temp_dir = tempdir().unwrap();
1873 let base_path = temp_dir.path();
1874
1875 File::create(base_path.join("doc.md")).unwrap();
1876 File::create(base_path.join("tutorial.mdx")).unwrap();
1877 File::create(base_path.join("guide.markdown")).unwrap();
1878
1879 let content = r#"
1880[Doc](doc.html)
1881[Tutorial](tutorial.html)
1882[Guide](guide.html)
1883"#;
1884
1885 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1886 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1887 let result = rule.check(&ctx).unwrap();
1888
1889 assert!(
1890 result.is_empty(),
1891 "Should find all markdown variants as source files. Got: {result:?}"
1892 );
1893 }
1894
1895 #[test]
1896 fn test_html_link_in_subdirectory() {
1897 let temp_dir = tempdir().unwrap();
1899 let base_path = temp_dir.path();
1900
1901 let docs_dir = base_path.join("docs");
1902 std::fs::create_dir(&docs_dir).unwrap();
1903 File::create(docs_dir.join("guide.md"))
1904 .unwrap()
1905 .write_all(b"# Guide")
1906 .unwrap();
1907
1908 let content = "[Guide](docs/guide.html)";
1909
1910 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1911 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let result = rule.check(&ctx).unwrap();
1913
1914 assert!(result.is_empty(), "Should find markdown source in subdirectory");
1915 }
1916
1917 #[test]
1918 fn test_absolute_path_skipped_in_check() {
1919 let temp_dir = tempdir().unwrap();
1922 let base_path = temp_dir.path();
1923
1924 let content = r#"
1925# Test Document
1926
1927[Go Runtime](/pkg/runtime)
1928[Go Runtime with Fragment](/pkg/runtime#section)
1929[API Docs](/api/v1/users)
1930[Blog Post](/blog/2024/release.html)
1931[React Hook](/react/hooks/use-state.html)
1932"#;
1933
1934 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1935 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let result = rule.check(&ctx).unwrap();
1937
1938 assert!(
1940 result.is_empty(),
1941 "Absolute paths should be skipped. Got warnings: {result:?}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_absolute_path_skipped_in_cross_file_check() {
1947 use crate::workspace_index::WorkspaceIndex;
1949
1950 let rule = MD057ExistingRelativeLinks::new();
1951
1952 let workspace_index = WorkspaceIndex::new();
1954
1955 let mut file_index = FileIndex::new();
1957 file_index.add_cross_file_link(CrossFileLinkIndex {
1958 target_path: "/pkg/runtime.md".to_string(),
1959 fragment: "".to_string(),
1960 line: 5,
1961 column: 1,
1962 });
1963 file_index.add_cross_file_link(CrossFileLinkIndex {
1964 target_path: "/api/v1/users.md".to_string(),
1965 fragment: "section".to_string(),
1966 line: 10,
1967 column: 1,
1968 });
1969
1970 let warnings = rule
1972 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1973 .unwrap();
1974
1975 assert!(
1977 warnings.is_empty(),
1978 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1979 );
1980 }
1981
1982 #[test]
1983 fn test_protocol_relative_url_not_skipped() {
1984 let temp_dir = tempdir().unwrap();
1987 let base_path = temp_dir.path();
1988
1989 let content = r#"
1990# Test Document
1991
1992[External](//example.com/page)
1993[Another](//cdn.example.com/asset.js)
1994"#;
1995
1996 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1997 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let result = rule.check(&ctx).unwrap();
1999
2000 assert!(
2002 result.is_empty(),
2003 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2004 );
2005 }
2006
2007 #[test]
2008 fn test_email_addresses_skipped() {
2009 let temp_dir = tempdir().unwrap();
2012 let base_path = temp_dir.path();
2013
2014 let content = r#"
2015# Test Document
2016
2017[Contact](user@example.com)
2018[Steering](steering@kubernetes.io)
2019[Support](john.doe+filter@company.co.uk)
2020[User](user_name@sub.domain.com)
2021"#;
2022
2023 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2024 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025 let result = rule.check(&ctx).unwrap();
2026
2027 assert!(
2029 result.is_empty(),
2030 "Email addresses should be skipped. Got warnings: {result:?}"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_email_addresses_vs_file_paths() {
2036 let temp_dir = tempdir().unwrap();
2039 let base_path = temp_dir.path();
2040
2041 let content = r#"
2042# Test Document
2043
2044[Email](user@example.com) <!-- Should be skipped (email) -->
2045[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2046[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2047"#;
2048
2049 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2050 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2051 let result = rule.check(&ctx).unwrap();
2052
2053 assert!(
2055 result.is_empty(),
2056 "All email addresses should be skipped. Got: {result:?}"
2057 );
2058 }
2059
2060 #[test]
2061 fn test_diagnostic_position_accuracy() {
2062 let temp_dir = tempdir().unwrap();
2064 let base_path = temp_dir.path();
2065
2066 let content = "prefix [text](missing.md) suffix";
2069 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2073 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074 let result = rule.check(&ctx).unwrap();
2075
2076 assert_eq!(result.len(), 1, "Should have exactly one warning");
2077 assert_eq!(result[0].line, 1, "Should be on line 1");
2078 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2079 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2080 }
2081
2082 #[test]
2083 fn test_diagnostic_position_angle_brackets() {
2084 let temp_dir = tempdir().unwrap();
2086 let base_path = temp_dir.path();
2087
2088 let content = "[link](<missing.md>)";
2091 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2094 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2095 let result = rule.check(&ctx).unwrap();
2096
2097 assert_eq!(result.len(), 1, "Should have exactly one warning");
2098 assert_eq!(result[0].line, 1, "Should be on line 1");
2099 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2100 }
2101
2102 #[test]
2103 fn test_diagnostic_position_multiline() {
2104 let temp_dir = tempdir().unwrap();
2106 let base_path = temp_dir.path();
2107
2108 let content = r#"# Title
2109Some text on line 2
2110[link on line 3](missing1.md)
2111More text
2112[link on line 5](missing2.md)"#;
2113
2114 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2115 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116 let result = rule.check(&ctx).unwrap();
2117
2118 assert_eq!(result.len(), 2, "Should have two warnings");
2119
2120 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2122 assert!(result[0].message.contains("missing1.md"));
2123
2124 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2126 assert!(result[1].message.contains("missing2.md"));
2127 }
2128
2129 #[test]
2130 fn test_diagnostic_position_with_spaces() {
2131 let temp_dir = tempdir().unwrap();
2133 let base_path = temp_dir.path();
2134
2135 let content = "[link]( missing.md )";
2136 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2141 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142 let result = rule.check(&ctx).unwrap();
2143
2144 assert_eq!(result.len(), 1, "Should have exactly one warning");
2145 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2147 }
2148
2149 #[test]
2150 fn test_diagnostic_position_image() {
2151 let temp_dir = tempdir().unwrap();
2153 let base_path = temp_dir.path();
2154
2155 let content = "";
2156
2157 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2158 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2159 let result = rule.check(&ctx).unwrap();
2160
2161 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2162 assert_eq!(result[0].line, 1);
2163 assert!(result[0].column > 0, "Should have valid column position");
2165 assert!(result[0].message.contains("missing.jpg"));
2166 }
2167
2168 #[test]
2169 fn test_wikilinks_skipped() {
2170 let temp_dir = tempdir().unwrap();
2173 let base_path = temp_dir.path();
2174
2175 let content = r#"# Test Document
2176
2177[[Microsoft#Windows OS]]
2178[[SomePage]]
2179[[Page With Spaces]]
2180[[path/to/page#section]]
2181[[page|Display Text]]
2182
2183This is a [real missing link](missing.md) that should be flagged.
2184"#;
2185
2186 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2187 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2188 let result = rule.check(&ctx).unwrap();
2189
2190 assert_eq!(
2192 result.len(),
2193 1,
2194 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2195 );
2196 assert!(
2197 result[0].message.contains("missing.md"),
2198 "Warning should be for missing.md, not wikilinks"
2199 );
2200 }
2201
2202 #[test]
2203 fn test_wikilinks_not_added_to_index() {
2204 let temp_dir = tempdir().unwrap();
2206 let base_path = temp_dir.path();
2207
2208 let content = r#"# Test Document
2209
2210[[Microsoft#Windows OS]]
2211[[SomePage#section]]
2212[Regular Link](other.md)
2213"#;
2214
2215 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2216 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217
2218 let mut file_index = FileIndex::new();
2219 rule.contribute_to_index(&ctx, &mut file_index);
2220
2221 let cross_file_links = &file_index.cross_file_links;
2224 assert_eq!(
2225 cross_file_links.len(),
2226 1,
2227 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2228 );
2229 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2230 }
2231
2232 #[test]
2233 fn test_reference_definition_missing_file() {
2234 let temp_dir = tempdir().unwrap();
2236 let base_path = temp_dir.path();
2237
2238 let content = r#"# Test Document
2239
2240[test]: ./missing.md
2241[example]: ./nonexistent.html
2242
2243Use [test] and [example] here.
2244"#;
2245
2246 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2247 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2248 let result = rule.check(&ctx).unwrap();
2249
2250 assert_eq!(
2252 result.len(),
2253 2,
2254 "Should have warnings for missing reference definition targets. Got: {result:?}"
2255 );
2256 assert!(
2257 result.iter().any(|w| w.message.contains("missing.md")),
2258 "Should warn about missing.md"
2259 );
2260 assert!(
2261 result.iter().any(|w| w.message.contains("nonexistent.html")),
2262 "Should warn about nonexistent.html"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_reference_definition_existing_file() {
2268 let temp_dir = tempdir().unwrap();
2270 let base_path = temp_dir.path();
2271
2272 let exists_path = base_path.join("exists.md");
2274 File::create(&exists_path)
2275 .unwrap()
2276 .write_all(b"# Existing file")
2277 .unwrap();
2278
2279 let content = r#"# Test Document
2280
2281[test]: ./exists.md
2282
2283Use [test] here.
2284"#;
2285
2286 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!(
2292 result.is_empty(),
2293 "Should not warn about existing file. Got: {result:?}"
2294 );
2295 }
2296
2297 #[test]
2298 fn test_reference_definition_external_url_skipped() {
2299 let temp_dir = tempdir().unwrap();
2301 let base_path = temp_dir.path();
2302
2303 let content = r#"# Test Document
2304
2305[google]: https://google.com
2306[example]: http://example.org
2307[mail]: mailto:test@example.com
2308[ftp]: ftp://files.example.com
2309[local]: ./missing.md
2310
2311Use [google], [example], [mail], [ftp], [local] here.
2312"#;
2313
2314 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2315 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316 let result = rule.check(&ctx).unwrap();
2317
2318 assert_eq!(
2320 result.len(),
2321 1,
2322 "Should only warn about local missing file. Got: {result:?}"
2323 );
2324 assert!(
2325 result[0].message.contains("missing.md"),
2326 "Warning should be for missing.md"
2327 );
2328 }
2329
2330 #[test]
2331 fn test_reference_definition_fragment_only_skipped() {
2332 let temp_dir = tempdir().unwrap();
2334 let base_path = temp_dir.path();
2335
2336 let content = r#"# Test Document
2337
2338[section]: #my-section
2339
2340Use [section] here.
2341"#;
2342
2343 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2344 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345 let result = rule.check(&ctx).unwrap();
2346
2347 assert!(
2349 result.is_empty(),
2350 "Should not warn about fragment-only reference. Got: {result:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_reference_definition_column_position() {
2356 let temp_dir = tempdir().unwrap();
2358 let base_path = temp_dir.path();
2359
2360 let content = "[ref]: ./missing.md";
2363 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2367 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368 let result = rule.check(&ctx).unwrap();
2369
2370 assert_eq!(result.len(), 1, "Should have exactly one warning");
2371 assert_eq!(result[0].line, 1, "Should be on line 1");
2372 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2373 }
2374
2375 #[test]
2376 fn test_reference_definition_html_with_md_source() {
2377 let temp_dir = tempdir().unwrap();
2379 let base_path = temp_dir.path();
2380
2381 let md_file = base_path.join("guide.md");
2383 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2384
2385 let content = r#"# Test Document
2386
2387[guide]: ./guide.html
2388[missing]: ./missing.html
2389
2390Use [guide] and [missing] here.
2391"#;
2392
2393 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2394 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395 let result = rule.check(&ctx).unwrap();
2396
2397 assert_eq!(
2399 result.len(),
2400 1,
2401 "Should only warn about missing source. Got: {result:?}"
2402 );
2403 assert!(result[0].message.contains("missing.html"));
2404 }
2405
2406 #[test]
2407 fn test_reference_definition_url_encoded() {
2408 let temp_dir = tempdir().unwrap();
2410 let base_path = temp_dir.path();
2411
2412 let file_with_spaces = base_path.join("file with spaces.md");
2414 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2415
2416 let content = r#"# Test Document
2417
2418[spaces]: ./file%20with%20spaces.md
2419[missing]: ./missing%20file.md
2420
2421Use [spaces] and [missing] here.
2422"#;
2423
2424 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2425 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426 let result = rule.check(&ctx).unwrap();
2427
2428 assert_eq!(
2430 result.len(),
2431 1,
2432 "Should only warn about missing URL-encoded file. Got: {result:?}"
2433 );
2434 assert!(result[0].message.contains("missing%20file.md"));
2435 }
2436
2437 #[test]
2438 fn test_inline_and_reference_both_checked() {
2439 let temp_dir = tempdir().unwrap();
2441 let base_path = temp_dir.path();
2442
2443 let content = r#"# Test Document
2444
2445[inline link](./inline-missing.md)
2446[ref]: ./ref-missing.md
2447
2448Use [ref] here.
2449"#;
2450
2451 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2452 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2453 let result = rule.check(&ctx).unwrap();
2454
2455 assert_eq!(
2457 result.len(),
2458 2,
2459 "Should warn about both inline and reference links. Got: {result:?}"
2460 );
2461 assert!(
2462 result.iter().any(|w| w.message.contains("inline-missing.md")),
2463 "Should warn about inline-missing.md"
2464 );
2465 assert!(
2466 result.iter().any(|w| w.message.contains("ref-missing.md")),
2467 "Should warn about ref-missing.md"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_footnote_definitions_not_flagged() {
2473 let rule = MD057ExistingRelativeLinks::default();
2476
2477 let content = r#"# Title
2478
2479A footnote[^1].
2480
2481[^1]: [link](https://www.google.com).
2482"#;
2483
2484 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485 let result = rule.check(&ctx).unwrap();
2486
2487 assert!(
2488 result.is_empty(),
2489 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_footnote_with_relative_link_inside() {
2495 let rule = MD057ExistingRelativeLinks::default();
2498
2499 let content = r#"# Title
2500
2501See the footnote[^1].
2502
2503[^1]: Check out [this file](./existing.md) for more info.
2504[^2]: Also see [missing](./does-not-exist.md).
2505"#;
2506
2507 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508 let result = rule.check(&ctx).unwrap();
2509
2510 for warning in &result {
2515 assert!(
2516 !warning.message.contains("[this file]"),
2517 "Footnote content should not be treated as URL: {warning:?}"
2518 );
2519 assert!(
2520 !warning.message.contains("[missing]"),
2521 "Footnote content should not be treated as URL: {warning:?}"
2522 );
2523 }
2524 }
2525
2526 #[test]
2527 fn test_mixed_footnotes_and_reference_definitions() {
2528 let temp_dir = tempdir().unwrap();
2530 let base_path = temp_dir.path();
2531
2532 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2533
2534 let content = r#"# Title
2535
2536A footnote[^1] and a [ref link][myref].
2537
2538[^1]: This is a footnote with [link](https://example.com).
2539
2540[myref]: ./missing-file.md "This should be checked"
2541"#;
2542
2543 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2544 let result = rule.check(&ctx).unwrap();
2545
2546 assert_eq!(
2548 result.len(),
2549 1,
2550 "Should only warn about the regular reference definition. Got: {result:?}"
2551 );
2552 assert!(
2553 result[0].message.contains("missing-file.md"),
2554 "Should warn about missing-file.md in reference definition"
2555 );
2556 }
2557
2558 #[test]
2559 fn test_absolute_links_ignore_by_default() {
2560 let temp_dir = tempdir().unwrap();
2562 let base_path = temp_dir.path();
2563
2564 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2565
2566 let content = r#"# Links
2567
2568[API docs](/api/v1/users)
2569[Blog post](/blog/2024/release.html)
2570
2571
2572[ref]: /docs/reference.md
2573"#;
2574
2575 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576 let result = rule.check(&ctx).unwrap();
2577
2578 assert!(
2580 result.is_empty(),
2581 "Absolute links should be ignored by default. Got: {result:?}"
2582 );
2583 }
2584
2585 #[test]
2586 fn test_absolute_links_warn_config() {
2587 let temp_dir = tempdir().unwrap();
2589 let base_path = temp_dir.path();
2590
2591 let config = MD057Config {
2592 absolute_links: AbsoluteLinksOption::Warn,
2593 };
2594 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2595
2596 let content = r#"# Links
2597
2598[API docs](/api/v1/users)
2599[Blog post](/blog/2024/release.html)
2600"#;
2601
2602 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2603 let result = rule.check(&ctx).unwrap();
2604
2605 assert_eq!(
2607 result.len(),
2608 2,
2609 "Should warn about both absolute links. Got: {result:?}"
2610 );
2611 assert!(
2612 result[0].message.contains("cannot be validated locally"),
2613 "Warning should explain why: {}",
2614 result[0].message
2615 );
2616 assert!(
2617 result[0].message.contains("/api/v1/users"),
2618 "Warning should include the link path"
2619 );
2620 }
2621
2622 #[test]
2623 fn test_absolute_links_warn_images() {
2624 let temp_dir = tempdir().unwrap();
2626 let base_path = temp_dir.path();
2627
2628 let config = MD057Config {
2629 absolute_links: AbsoluteLinksOption::Warn,
2630 };
2631 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2632
2633 let content = r#"# Images
2634
2635
2636"#;
2637
2638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639 let result = rule.check(&ctx).unwrap();
2640
2641 assert_eq!(
2642 result.len(),
2643 1,
2644 "Should warn about absolute image path. Got: {result:?}"
2645 );
2646 assert!(
2647 result[0].message.contains("/assets/logo.png"),
2648 "Warning should include the image path"
2649 );
2650 }
2651
2652 #[test]
2653 fn test_absolute_links_warn_reference_definitions() {
2654 let temp_dir = tempdir().unwrap();
2656 let base_path = temp_dir.path();
2657
2658 let config = MD057Config {
2659 absolute_links: AbsoluteLinksOption::Warn,
2660 };
2661 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2662
2663 let content = r#"# Reference
2664
2665See the [docs][ref].
2666
2667[ref]: /docs/reference.md
2668"#;
2669
2670 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2671 let result = rule.check(&ctx).unwrap();
2672
2673 assert_eq!(
2674 result.len(),
2675 1,
2676 "Should warn about absolute reference definition. Got: {result:?}"
2677 );
2678 assert!(
2679 result[0].message.contains("/docs/reference.md"),
2680 "Warning should include the reference path"
2681 );
2682 }
2683}