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