ailint_core/rules/structural/
broken_local_link.rs1use 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#[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 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 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
73fn 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 if has_scheme(url) {
87 return false;
88 }
89 if url.starts_with("//") {
91 return false;
92 }
93 true
94}
95
96fn has_scheme(url: &str) -> bool {
97 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 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}