Skip to main content

nova_doc/
links.rs

1//! The link check (spec §11).
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6/// A link that leads nowhere: the page it is on, from the site's root,
7/// and the link as written.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct BrokenLink {
10    /// The page, from the site's root, with `/`.
11    pub page: String,
12    /// The link, as its attribute holds it, unescaped.
13    pub link: String,
14}
15
16/// Every link in the site at `dir` that is not absolute and leads to no
17/// file of the site, or to no `id` on its page (spec §11). Pages in order
18/// of their paths, and each page's links in order.
19pub 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
54/// Each `.html` file under `dir`, by its path from the site's root.
55fn 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
75/// The values of every `name="…"` attribute in `html`'s tags, unescaped.
76/// Only a tag, from a `<` to the next `>`, is read: a page's text can show
77/// `href="…"`, but never a raw `<`, which is escaped.
78fn 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
97/// Whether `link`, on `page`, leads to a file of the site, and to an `id`
98/// on it when it names one.
99fn 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
119/// `link`, relative to `page`'s directory, as a path from the site's root;
120/// `None` when it leaves the site or starts at a root.
121fn 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
139/// Whether `link` starts with a URL scheme, such as `https:` or `mailto:`.
140fn 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
151/// `text` with its `%XX` escapes decoded.
152fn 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
175/// An attribute's value with its entities decoded.
176fn unescape(text: &str) -> String {
177    text.replace("&quot;", "\"")
178        .replace("&#39;", "'")
179        .replace("&lt;", "<")
180        .replace("&gt;", ">")
181        .replace("&amp;", "&")
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::path::PathBuf;
188
189    /// A fresh site of `files` in a directory with a fixed name.
190    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        // pulldown-cmark does not escape `"` in text or inline code, so a
256        // doc can show `href="…"` outside any tag.
257        let dir = site(
258            "text-link",
259            &[(
260                "index.html",
261                "<p>Write <code>&lt;a href=\"gone.html\"&gt;</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&amp;y\">e</a>"),
285                ("a b.html", "<p id=\"x&amp;y\">p</p>"),
286            ],
287        );
288        assert_eq!(check_links(&dir), Vec::new());
289    }
290}