Skip to main content

ailint_core/rules/structural/
broken_local_link.rs

1//! AIL040 `broken-local-link` — a relative link in a Markdown document
2//! points at a file that does not exist on disk.
3//!
4//! See: `docs/rules/structural/AIL040.md`
5
6use std::path::{Path, PathBuf};
7
8use crate::file_type::FileType;
9use crate::parser::{DocumentContent, ParsedDocument};
10use crate::rules::structural::AIL040;
11use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
12
13/// AIL040 broken-local-link: relative link target does not exist on disk.
14#[derive(Debug, Default)]
15pub struct BrokenLocalLinkRule;
16
17impl Rule for BrokenLocalLinkRule {
18    fn id(&self) -> RuleId {
19        AIL040
20    }
21
22    fn default_severity(&self) -> Severity {
23        Severity::Warning
24    }
25
26    fn description(&self) -> &'static str {
27        "Markdown link points to a path that does not exist on disk."
28    }
29
30    fn fix_hint(&self) -> &'static str {
31        "Correct the path, or remove the link."
32    }
33
34    /// Applies to every Markdown document — guidance and generic project docs
35    /// alike — since broken links are a universal quality concern.
36    fn applies_to(&self, file_type: FileType) -> bool {
37        file_type.is_markdown()
38    }
39
40    fn run(&self, doc: &ParsedDocument, _ctx: &RuleContext<'_>) -> Vec<Violation> {
41        let md = match &doc.content {
42            DocumentContent::Markdown(m) => m,
43            _ => return Vec::new(),
44        };
45
46        let doc_dir = doc.path.parent().unwrap_or(Path::new(""));
47        let mut out = Vec::new();
48
49        for link in &md.links {
50            if !is_local_link(&link.url) {
51                continue;
52            }
53            let (path_part, _fragment) = split_fragment(&link.url);
54            if path_part.is_empty() {
55                // pure `#anchor` — skip, in-doc anchors aren't checked yet.
56                continue;
57            }
58            let target = resolve_target(doc_dir, path_part);
59            if target.exists() {
60                continue;
61            }
62            let v = Violation::new(
63                AIL040,
64                self.default_severity(),
65                doc.path.clone(),
66                "broken local link",
67            )
68            .at(link.line, 1)
69            .with_detail(link.url.clone());
70            out.push(v);
71        }
72
73        out
74    }
75}
76
77/// A link is "local" if it lacks a URL scheme and isn't a pure anchor or
78/// mailto/tel/etc.
79fn is_local_link(url: &str) -> bool {
80    if url.is_empty() {
81        return false;
82    }
83    if url.starts_with('#') {
84        return false;
85    }
86    if url.starts_with("mailto:") || url.starts_with("tel:") {
87        return false;
88    }
89    // http://, https://, ftp://, file://, javascript:, data:, etc.
90    if has_scheme(url) {
91        return false;
92    }
93    // Protocol-relative URLs.
94    if url.starts_with("//") {
95        return false;
96    }
97    true
98}
99
100fn has_scheme(url: &str) -> bool {
101    // A scheme is [A-Za-z][A-Za-z0-9+\-.]*:
102    let bytes = url.as_bytes();
103    if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
104        return false;
105    }
106    for (i, &b) in bytes.iter().enumerate().skip(1) {
107        if b == b':' {
108            return i > 0;
109        }
110        if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') {
111            return false;
112        }
113    }
114    false
115}
116
117fn split_fragment(url: &str) -> (&str, Option<&str>) {
118    match url.find('#') {
119        Some(i) => (&url[..i], Some(&url[i + 1..])),
120        None => (url, None),
121    }
122}
123
124fn resolve_target(doc_dir: &Path, rel: &str) -> PathBuf {
125    // Absolute paths (starting with `/`) are treated as workspace-relative:
126    // resolve from the deepest existing ancestor. For simplicity, we resolve
127    // from `doc_dir` for absolute paths too — this may miss some cases but
128    // avoids false positives from workspace-root assumptions.
129    let trimmed = rel.trim_start_matches('/');
130    doc_dir.join(trimmed)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn detects_scheme() {
139        assert!(has_scheme("http://example.com"));
140        assert!(has_scheme("https://example.com"));
141        assert!(has_scheme("mailto:a@b"));
142        assert!(has_scheme("ftp://x"));
143        assert!(!has_scheme("relative/path.md"));
144        assert!(!has_scheme("./relative.md"));
145        assert!(!has_scheme("#anchor"));
146    }
147
148    #[test]
149    fn classifies_local_links() {
150        assert!(is_local_link("README.md"));
151        assert!(is_local_link("./sub/doc.md"));
152        assert!(is_local_link("../up.md"));
153        assert!(is_local_link("/absolute/rel.md"));
154        assert!(!is_local_link("https://example.com"));
155        assert!(!is_local_link("mailto:a@b"));
156        assert!(!is_local_link("#anchor"));
157        assert!(!is_local_link(""));
158        assert!(!is_local_link("//cdn.example.com/a.js"));
159    }
160
161    #[test]
162    fn splits_fragment() {
163        assert_eq!(split_fragment("a.md#sec"), ("a.md", Some("sec")));
164        assert_eq!(split_fragment("a.md"), ("a.md", None));
165    }
166}