1use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct BrokenLink {
10 pub page: String,
12 pub link: String,
14}
15
16pub fn check_links(dir: &Path) -> Vec<BrokenLink> {
20 let mut pages = Vec::new();
21 walk(dir, "", &mut pages);
22 pages.sort();
23 let texts: HashMap<&str, String> = pages
24 .iter()
25 .map(|p| {
26 (
27 p.as_str(),
28 std::fs::read_to_string(dir.join(p)).unwrap_or_default(),
29 )
30 })
31 .collect();
32 let ids: HashMap<&str, HashSet<String>> = texts
33 .iter()
34 .map(|(p, t)| (*p, attributes(t, "id").into_iter().collect()))
35 .collect();
36 let mut broken = Vec::new();
37 for page in &pages {
38 let text = &texts[page.as_str()];
39 for link in attributes(text, "href")
40 .into_iter()
41 .chain(attributes(text, "src"))
42 {
43 if !resolves(dir, page, &link, &ids) {
44 broken.push(BrokenLink {
45 page: page.clone(),
46 link,
47 });
48 }
49 }
50 }
51 broken
52}
53
54fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) {
56 let Ok(entries) = std::fs::read_dir(dir) else {
57 return;
58 };
59 for entry in entries.flatten() {
60 let name = entry.file_name().to_string_lossy().into_owned();
61 let rel = if prefix.is_empty() {
62 name.clone()
63 } else {
64 format!("{prefix}/{name}")
65 };
66 let path = entry.path();
67 if path.is_dir() {
68 walk(&path, &rel, out);
69 } else if name.ends_with(".html") {
70 out.push(rel);
71 }
72 }
73}
74
75fn attributes(html: &str, name: &str) -> Vec<String> {
79 let pattern = format!(" {name}=\"");
80 let mut out = Vec::new();
81 let mut rest = html;
82 while let Some(open) = rest.find('<') {
83 let tag = &rest[open..];
84 let close = tag.find('>').unwrap_or(tag.len());
85 let mut inside = &tag[..close];
86 while let Some(at) = inside.find(&pattern) {
87 let value = &inside[at + pattern.len()..];
88 let end = value.find('"').unwrap_or(value.len());
89 out.push(unescape(&value[..end]));
90 inside = &value[end..];
91 }
92 rest = &tag[close..];
93 }
94 out
95}
96
97fn resolves(dir: &Path, page: &str, link: &str, ids: &HashMap<&str, HashSet<String>>) -> bool {
100 if link.starts_with("//") || has_scheme(link) {
101 return true;
102 }
103 let (path, fragment) = link.split_once('#').unwrap_or((link, ""));
104 let path = path.split('?').next().unwrap_or("");
105 let target = if path.is_empty() {
106 page.to_string()
107 } else {
108 match join(page, &decode(path)) {
109 Some(t) => t,
110 None => return false,
111 }
112 };
113 match ids.get(target.as_str()) {
114 Some(on_page) => fragment.is_empty() || on_page.contains(&decode(fragment)),
115 None => fragment.is_empty() && dir.join(&target).is_file(),
116 }
117}
118
119fn join(page: &str, link: &str) -> Option<String> {
122 if link.starts_with('/') {
123 return None;
124 }
125 let mut parts: Vec<&str> = page.split('/').collect();
126 parts.pop();
127 for part in link.split('/') {
128 match part {
129 "" | "." => {}
130 ".." => {
131 parts.pop()?;
132 }
133 p => parts.push(p),
134 }
135 }
136 Some(parts.join("/"))
137}
138
139fn has_scheme(link: &str) -> bool {
141 let Some(colon) = link.find(':') else {
142 return false;
143 };
144 let scheme = &link[..colon];
145 scheme.starts_with(|c: char| c.is_ascii_alphabetic())
146 && scheme
147 .chars()
148 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
149}
150
151fn decode(text: &str) -> String {
153 let bytes = text.as_bytes();
154 let mut out = Vec::with_capacity(bytes.len());
155 let mut i = 0;
156 while i < bytes.len() {
157 let hex = bytes
158 .get(i + 1..i + 3)
159 .and_then(|h| std::str::from_utf8(h).ok())
160 .and_then(|h| u8::from_str_radix(h, 16).ok());
161 match (bytes[i], hex) {
162 (b'%', Some(b)) => {
163 out.push(b);
164 i += 3;
165 }
166 (b, _) => {
167 out.push(b);
168 i += 1;
169 }
170 }
171 }
172 String::from_utf8_lossy(&out).into_owned()
173}
174
175fn unescape(text: &str) -> String {
177 text.replace(""", "\"")
178 .replace("'", "'")
179 .replace("<", "<")
180 .replace(">", ">")
181 .replace("&", "&")
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::path::PathBuf;
188
189 fn site(name: &str, files: &[(&str, &str)]) -> PathBuf {
191 let dir = std::env::temp_dir().join(format!("nova-doc-links-{name}"));
192 let _ = std::fs::remove_dir_all(&dir);
193 for (path, text) in files {
194 let path = dir.join(path);
195 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
196 std::fs::write(path, text).unwrap();
197 }
198 dir
199 }
200
201 fn broken(page: &str, link: &str) -> BrokenLink {
202 BrokenLink {
203 page: page.to_string(),
204 link: link.to_string(),
205 }
206 }
207
208 #[test]
209 fn a_missing_file_and_a_missing_fragment_are_broken() {
210 let dir = site(
211 "broken",
212 &[
213 (
214 "index.html",
215 "<a href=\"a/index.html\">a</a> <a href=\"a/index.html#here\">h</a> \
216 <a href=\"a/index.html#gone\">g</a> <a href=\"nope.html\">n</a> \
217 <script src=\"x.js\"></script>",
218 ),
219 (
220 "a/index.html",
221 "<h3 id=\"here\">Here</h3> <a href=\"#here\">s</a> <a href=\"#missing\">m</a> \
222 <a href=\"../index.html\">u</a> <a href=\"../b/index.html#there\">b</a> \
223 <a href=\"../../out.html\">o</a>",
224 ),
225 ("b/index.html", "<p id=\"there\">There</p>"),
226 ("x.js", ""),
227 ],
228 );
229 assert_eq!(
230 check_links(&dir),
231 vec![
232 broken("a/index.html", "#missing"),
233 broken("a/index.html", "../../out.html"),
234 broken("index.html", "a/index.html#gone"),
235 broken("index.html", "nope.html"),
236 ]
237 );
238 }
239
240 #[test]
241 fn absolute_urls_are_not_checked() {
242 let dir = site(
243 "absolute",
244 &[(
245 "index.html",
246 "<a href=\"https://example.com/x\">x</a> <a href=\"mailto:a@b.c\">m</a> \
247 <a href=\"//cdn.example/x.js\">c</a>",
248 )],
249 );
250 assert_eq!(check_links(&dir), Vec::new());
251 }
252
253 #[test]
254 fn a_link_written_in_text_is_not_checked() {
255 let dir = site(
258 "text-link",
259 &[(
260 "index.html",
261 "<p>Write <code><a href=\"gone.html\"></code> or src=\"gone.js\".</p>",
262 )],
263 );
264 assert_eq!(check_links(&dir), Vec::new());
265 }
266
267 #[test]
268 fn an_id_written_in_text_is_no_target() {
269 let dir = site(
270 "text-id",
271 &[(
272 "index.html",
273 "<p>Not a target: id=\"fn.fake\".</p> <a href=\"#fn.fake\">f</a>",
274 )],
275 );
276 assert_eq!(check_links(&dir), vec![broken("index.html", "#fn.fake")]);
277 }
278
279 #[test]
280 fn an_escaped_or_encoded_link_is_read_as_its_target() {
281 let dir = site(
282 "encoded",
283 &[
284 ("index.html", "<a href=\"a%20b.html#x&y\">e</a>"),
285 ("a b.html", "<p id=\"x&y\">p</p>"),
286 ],
287 );
288 assert_eq!(check_links(&dir), Vec::new());
289 }
290}