Skip to main content

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}