use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrokenLink {
pub page: String,
pub link: String,
}
pub fn check_links(dir: &Path) -> Vec<BrokenLink> {
let mut pages = Vec::new();
walk(dir, "", &mut pages);
pages.sort();
let texts: HashMap<&str, String> = pages
.iter()
.map(|p| {
(
p.as_str(),
std::fs::read_to_string(dir.join(p)).unwrap_or_default(),
)
})
.collect();
let ids: HashMap<&str, HashSet<String>> = texts
.iter()
.map(|(p, t)| (*p, attributes(t, "id").into_iter().collect()))
.collect();
let mut broken = Vec::new();
for page in &pages {
let text = &texts[page.as_str()];
for link in attributes(text, "href")
.into_iter()
.chain(attributes(text, "src"))
{
if !resolves(dir, page, &link, &ids) {
broken.push(BrokenLink {
page: page.clone(),
link,
});
}
}
}
broken
}
fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
let path = entry.path();
if path.is_dir() {
walk(&path, &rel, out);
} else if name.ends_with(".html") {
out.push(rel);
}
}
}
fn attributes(html: &str, name: &str) -> Vec<String> {
let pattern = format!(" {name}=\"");
let mut out = Vec::new();
let mut rest = html;
while let Some(open) = rest.find('<') {
let tag = &rest[open..];
let close = tag.find('>').unwrap_or(tag.len());
let mut inside = &tag[..close];
while let Some(at) = inside.find(&pattern) {
let value = &inside[at + pattern.len()..];
let end = value.find('"').unwrap_or(value.len());
out.push(unescape(&value[..end]));
inside = &value[end..];
}
rest = &tag[close..];
}
out
}
fn resolves(dir: &Path, page: &str, link: &str, ids: &HashMap<&str, HashSet<String>>) -> bool {
if link.starts_with("//") || has_scheme(link) {
return true;
}
let (path, fragment) = link.split_once('#').unwrap_or((link, ""));
let path = path.split('?').next().unwrap_or("");
let target = if path.is_empty() {
page.to_string()
} else {
match join(page, &decode(path)) {
Some(t) => t,
None => return false,
}
};
match ids.get(target.as_str()) {
Some(on_page) => fragment.is_empty() || on_page.contains(&decode(fragment)),
None => fragment.is_empty() && dir.join(&target).is_file(),
}
}
fn join(page: &str, link: &str) -> Option<String> {
if link.starts_with('/') {
return None;
}
let mut parts: Vec<&str> = page.split('/').collect();
parts.pop();
for part in link.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
Some(parts.join("/"))
}
fn has_scheme(link: &str) -> bool {
let Some(colon) = link.find(':') else {
return false;
};
let scheme = &link[..colon];
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = bytes
.get(i + 1..i + 3)
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok());
match (bytes[i], hex) {
(b'%', Some(b)) => {
out.push(b);
i += 3;
}
(b, _) => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn unescape(text: &str) -> String {
text.replace(""", "\"")
.replace("'", "'")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn site(name: &str, files: &[(&str, &str)]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("nova-doc-links-{name}"));
let _ = std::fs::remove_dir_all(&dir);
for (path, text) in files {
let path = dir.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
dir
}
fn broken(page: &str, link: &str) -> BrokenLink {
BrokenLink {
page: page.to_string(),
link: link.to_string(),
}
}
#[test]
fn a_missing_file_and_a_missing_fragment_are_broken() {
let dir = site(
"broken",
&[
(
"index.html",
"<a href=\"a/index.html\">a</a> <a href=\"a/index.html#here\">h</a> \
<a href=\"a/index.html#gone\">g</a> <a href=\"nope.html\">n</a> \
<script src=\"x.js\"></script>",
),
(
"a/index.html",
"<h3 id=\"here\">Here</h3> <a href=\"#here\">s</a> <a href=\"#missing\">m</a> \
<a href=\"../index.html\">u</a> <a href=\"../b/index.html#there\">b</a> \
<a href=\"../../out.html\">o</a>",
),
("b/index.html", "<p id=\"there\">There</p>"),
("x.js", ""),
],
);
assert_eq!(
check_links(&dir),
vec![
broken("a/index.html", "#missing"),
broken("a/index.html", "../../out.html"),
broken("index.html", "a/index.html#gone"),
broken("index.html", "nope.html"),
]
);
}
#[test]
fn absolute_urls_are_not_checked() {
let dir = site(
"absolute",
&[(
"index.html",
"<a href=\"https://example.com/x\">x</a> <a href=\"mailto:a@b.c\">m</a> \
<a href=\"//cdn.example/x.js\">c</a>",
)],
);
assert_eq!(check_links(&dir), Vec::new());
}
#[test]
fn a_link_written_in_text_is_not_checked() {
let dir = site(
"text-link",
&[(
"index.html",
"<p>Write <code><a href=\"gone.html\"></code> or src=\"gone.js\".</p>",
)],
);
assert_eq!(check_links(&dir), Vec::new());
}
#[test]
fn an_id_written_in_text_is_no_target() {
let dir = site(
"text-id",
&[(
"index.html",
"<p>Not a target: id=\"fn.fake\".</p> <a href=\"#fn.fake\">f</a>",
)],
);
assert_eq!(check_links(&dir), vec![broken("index.html", "#fn.fake")]);
}
#[test]
fn an_escaped_or_encoded_link_is_read_as_its_target() {
let dir = site(
"encoded",
&[
("index.html", "<a href=\"a%20b.html#x&y\">e</a>"),
("a b.html", "<p id=\"x&y\">p</p>"),
],
);
assert_eq!(check_links(&dir), Vec::new());
}
}