rumdl_lib/rules/md057_existing_relative_links/link_resolver.rs
1//! Where a link destination leads, answered the way MD057 answers it.
2//!
3//! MD057 decides whether a destination exists; other rules need to know which
4//! file it is. Both go through the resolution below, so a rule checking a
5//! fragment looks in the same file MD057 says the link lands on: the same
6//! decoding, the same directory and extension fallbacks, the same search paths
7//! and absolute-link handling, and the same supplied document set.
8
9use super::{AbsoluteLinksOption, CURRENT_DIR, MD057Config, MD057ExistingRelativeLinks, PROTOCOL_DOMAIN_REGEX};
10use crate::lint_context::LinkTargetPolicy;
11use crate::utils::project_root::project_root;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15/// Check if a URL is external or should be skipped for validation.
16///
17/// Returns `true` (skip validation) for:
18/// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
19/// - Bare domains: `www.example.com`, `example.com`
20/// - Email addresses: `user@example.com` (without `mailto:`)
21/// - Template variables: `{{URL}}`, `{{% include %}}`
22/// - Framework path aliases: `~/assets/logo.png`, `@/components/Button.vue`
23///
24/// Returns `false` (validate) for:
25/// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
26pub(crate) fn is_external_url(url: &str) -> bool {
27 if url.is_empty() {
28 return false;
29 }
30
31 // Quick checks for common external URL patterns
32 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
33 return true;
34 }
35
36 // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
37 // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
38 if url.starts_with("{{") || url.starts_with("{%") {
39 return true;
40 }
41
42 // Simple check: if URL contains @, it's almost certainly an email address
43 // File paths with @ are extremely rare, so this is a safe heuristic
44 if url.contains('@') {
45 return true;
46 }
47
48 // Bare domain check (e.g., "example.com")
49 // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
50 // Links like [text](nodejs.org/path) without a protocol are broken -
51 // they'll be treated as relative paths by markdown renderers.
52 // Flagging them helps users find missing protocols.
53 // We only skip .com as a minimal safety net for the most common case.
54 // Require the absence of a path separator so a relative file reference
55 // that merely ends in ".com" (e.g. "../../vendor.com") is still
56 // validated rather than assumed to be a bare domain.
57 if !url.contains('/') && url.ends_with(".com") {
58 return true;
59 }
60
61 // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
62 // These are not filesystem paths but module/asset aliases
63 // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
64 if url.starts_with('~') || url.starts_with('@') {
65 return true;
66 }
67
68 false
69}
70
71/// Whether a destination names no file relative to the Markdown source:
72/// external destinations, and gh-aw output placeholders in that flavor.
73pub(crate) fn is_non_file_destination(url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
74 is_external_url(url)
75 || (flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_output_placeholder(url))
76}
77
78/// What a link destination resolves to.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum LinkResolution {
81 /// The file or directory the destination lands on.
82 Target(PathBuf),
83 /// The destination was resolved and names nothing.
84 Missing,
85 /// The configured handling does not resolve this destination: an absolute
86 /// link under `absolute-links = "ignore"` or `"warn"`, or a docs-dir route
87 /// with no mkdocs.yml to find the docs directory by.
88 Unchecked,
89}
90
91/// Resolves link destinations with MD057's configuration.
92#[derive(Debug, Clone, Default)]
93pub struct LinkResolver {
94 config: MD057Config,
95 /// Settles the flavor per file, which decides whether the Obsidian
96 /// attachment folder is searched. Without it every file is Standard.
97 markdown_config: Option<Arc<crate::config::Config>>,
98}
99
100impl LinkResolver {
101 pub fn new(config: MD057Config, markdown_config: Option<Arc<crate::config::Config>>) -> Self {
102 Self {
103 config,
104 markdown_config,
105 }
106 }
107
108 /// A resolver reading MD057's section of `config`, whichever rules are
109 /// enabled: where a link leads does not depend on whether MD057 reports it.
110 pub fn from_config(config: &crate::config::Config) -> Self {
111 let md057 = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
112 Self::new(md057, Some(Arc::new(config.clone())))
113 }
114
115 /// Resolve `url`, written in the document at `source_file`.
116 ///
117 /// Resolving several links from one document goes through
118 /// [`Self::for_document`], which settles the document's side once.
119 pub fn resolve(&self, source_file: &Path, url: &str, policy: Option<&LinkTargetPolicy>) -> LinkResolution {
120 self.for_document(source_file).resolve(url, policy)
121 }
122
123 /// The links of the document at `source_file`, resolved from its
124 /// directory as MD057 reads it: the directory of the file a symlink
125 /// points to, so a link means what it means where the content lives.
126 pub fn for_document(&self, source_file: &Path) -> DocumentLinks<'_> {
127 let flavor = self
128 .markdown_config
129 .as_ref()
130 .map_or(crate::config::MarkdownFlavor::Standard, |config| {
131 config.get_flavor_for_file(source_file)
132 });
133 let base_path = source_file
134 .canonicalize()
135 .ok()
136 .and_then(|file| file.parent().map(Path::to_path_buf))
137 .or_else(|| source_file.parent().map(Path::to_path_buf))
138 .unwrap_or_else(|| CURRENT_DIR.clone());
139 let project_root = project_root();
140 let search_paths = MD057ExistingRelativeLinks::search_paths_for(
141 &self.config.search_paths,
142 flavor,
143 Some(source_file),
144 &base_path,
145 project_root,
146 );
147 DocumentLinks {
148 resolver: self,
149 flavor,
150 base_path,
151 project_root,
152 search_paths,
153 }
154 }
155}
156
157/// A [`LinkResolver`] bound to the document whose links it resolves.
158pub struct DocumentLinks<'a> {
159 resolver: &'a LinkResolver,
160 flavor: crate::config::MarkdownFlavor,
161 base_path: PathBuf,
162 project_root: &'static Path,
163 search_paths: Vec<PathBuf>,
164}
165
166impl DocumentLinks<'_> {
167 /// Resolve `url` as written in this document.
168 ///
169 /// The destination's query and fragment are ignored. A destination that
170 /// names no file (external, fragment-only, empty) is `Unchecked`.
171 pub fn resolve(&self, url: &str, policy: Option<&LinkTargetPolicy>) -> LinkResolution {
172 if url.is_empty() || url.starts_with('#') || is_non_file_destination(url, self.flavor) {
173 return LinkResolution::Unchecked;
174 }
175 if MD057ExistingRelativeLinks::is_absolute_path(url) {
176 return self
177 .resolver
178 .resolve_absolute(url, &self.base_path, self.project_root, policy);
179 }
180 match MD057ExistingRelativeLinks::resolve_relative(url, &self.base_path, &self.search_paths, policy) {
181 Some(target) => LinkResolution::Target(target),
182 None => LinkResolution::Missing,
183 }
184 }
185}
186
187impl LinkResolver {
188 fn resolve_absolute(
189 &self,
190 url: &str,
191 base_path: &Path,
192 project_root: &Path,
193 policy: Option<&LinkTargetPolicy>,
194 ) -> LinkResolution {
195 match self.config.absolute_links {
196 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => LinkResolution::Unchecked,
197 AbsoluteLinksOption::RelativeToDocs => {
198 match MD057ExistingRelativeLinks::resolve_absolute_via_docs_dir(url, base_path, policy) {
199 None => LinkResolution::Unchecked,
200 Some(super::Resolution::Found(target)) => LinkResolution::Target(target),
201 Some(_) => LinkResolution::Missing,
202 }
203 }
204 AbsoluteLinksOption::RelativeToRoots => {
205 match MD057ExistingRelativeLinks::resolve_absolute_via_roots(
206 url,
207 &self.config.roots,
208 project_root,
209 policy,
210 ) {
211 Some(target) => LinkResolution::Target(target),
212 None => LinkResolution::Missing,
213 }
214 }
215 }
216 }
217}