use crate::classify::{kind_from_ext, Kind};
use crate::output::Entry;
use crate::scan::Asset;
use std::collections::BTreeSet;
pub fn referenced(items: &[(Asset, Kind)]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for (asset, kind) in items {
match kind {
Kind::Html => scan_specifiers(&asset.bytes, &mut out, true),
Kind::Js => scan_specifiers(&asset.bytes, &mut out, false),
Kind::Css => scan_urls(&asset.bytes, &mut out),
_ => {}
}
}
out
}
pub fn missing(items: &[(Asset, Kind)], entries: &[Entry]) -> Vec<String> {
let written: BTreeSet<&str> = entries.iter().map(|e| basename(&e.name)).collect();
referenced(items).into_iter().filter(|r| !written.contains(r.as_str())).collect()
}
fn basename(p: &str) -> &str {
p.rsplit('/').next().unwrap_or(p)
}
fn name_char(b: u8) -> bool {
matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-')
}
fn scan_specifiers(bytes: &[u8], out: &mut BTreeSet<String>, root_absolute: bool) {
let markers: &[&[u8]] = if root_absolute {
&[b"assets/", b"./", b"/"]
} else {
&[b"assets/", b"./"]
};
for marker in markers.iter().copied() {
let mut i = 0;
while i + marker.len() < bytes.len() {
let Some(rel) = bytes[i..].windows(marker.len()).position(|w| w == marker) else { break };
let start = i + rel;
let s = start + marker.len();
let mut quoted = false;
if marker == b"/" {
let open = match start.checked_sub(1) { Some(o) => o, None => { i = s; continue; } };
if !matches!(bytes[open], b'"' | b'\'' | b'`') { i = s; continue; }
if open > 0 && bytes[open - 1] == b'+' { i = s; continue; }
quoted = true;
}
let mut e = s;
while e < bytes.len() && name_char(bytes[e]) { e += 1; }
let terminated = e < bytes.len() && matches!(bytes[e], b'"' | b'\'' | b'`' | b')');
let concatenated = quoted && bytes.get(e + 1) == Some(&b'+');
if terminated && !concatenated && e > s {
if let Ok(name) = std::str::from_utf8(&bytes[s..e]) {
if kind_from_ext(name).is_some() { out.insert(name.to_string()); }
}
}
i = s;
}
}
}
fn scan_urls(bytes: &[u8], out: &mut BTreeSet<String>) {
let mut i = 0;
while i + 4 < bytes.len() {
let Some(rel) = bytes[i..].windows(4).position(|w| w == b"url(") else { break };
let s = i + rel + 4;
let Some(len) = bytes[s..].iter().position(|&b| b == b')') else { break };
if let Ok(raw) = std::str::from_utf8(&bytes[s..s + len]) {
let raw = raw.trim().trim_matches(['"', '\'']);
if !raw.starts_with("data:") && !raw.contains("://") && !raw.starts_with("//") {
let name = basename(raw.split(['?', '#']).next().unwrap_or(raw));
if kind_from_ext(name).is_some() { out.insert(name.to_string()); }
}
}
i = s;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(bytes: &[u8], kind: Kind) -> (Asset, Kind) {
(Asset { offset: 0, compressed_len: 1, bytes: bytes.to_vec(), name: None, path: None }, kind)
}
fn entry(name: &str) -> Entry {
Entry { name: name.into(), key: None, original_name: None, kind: "js".into(), offset: 0, compressed_len: 1, decompressed_len: 1, hash: None, alias_of: None }
}
#[test]
fn sees_a_root_absolute_reference_but_not_a_built_url() {
let mut out = BTreeSet::new();
scan_specifiers(br#"<link href="/vite.svg"><script src="/app.js"></script>"#, &mut out, true);
scan_specifiers(br#"<img src="https://cdn.example.com/remote.js"><a href="//cdn.io/p.js">"#, &mut out, true);
assert!(out.contains("vite.svg"), "got {out:?}");
assert!(out.contains("app.js"), "got {out:?}");
assert!(!out.contains("remote.js"), "a remote URL is not a local asset");
assert!(!out.contains("p.js"), "a protocol-relative URL is not a local asset");
let mut js = BTreeSet::new();
scan_specifiers(br#"x.concat(t.config.paths.vs,"/loader.js")"#, &mut js, false);
scan_specifiers(br#"od(t,`node_modules`,e,`package.json`)"#, &mut js, false);
scan_specifiers(br#"e.WriteImage(r,"/image.png")"#, &mut js, false);
scan_specifiers(br#"endpointFor("assets","/array/"+t.token+"/config.js")"#, &mut js, false);
assert!(js.is_empty(), "root-absolute paths in JS must not be reported: {js:?}");
}
#[test]
fn flags_a_referenced_chunk_that_was_not_written() {
let js = br#"const __vite__mapDeps=(i,m)=>{};__vite__mapDeps(["assets/PanelWindow-E5f6G7h8.js","assets/index-AAAA1111.js"])"#;
let items = vec![item(js, Kind::Js)];
let entries = vec![entry("js/index-AAAA1111.js")];
assert_eq!(missing(&items, &entries), vec!["PanelWindow-E5f6G7h8.js".to_string()]);
}
#[test]
fn css_url_bundled_fonts_are_referenced_and_remote_ones_are_not() {
let css = br#"@font-face{src:url(./KaTeX_Main-Bold-I9j0K1l2.woff2#iefix) format("woff2"),url("https://fonts.gstatic.com/s/inter-v13.woff2") format("woff2"),url(//cdn.example.com/proto-rel.woff2),url(data:font/woff;base64,AAAA)}"#;
let refs = referenced(&[item(css, Kind::Css)]);
assert!(refs.contains("KaTeX_Main-Bold-I9j0K1l2.woff2"), "bundled font must be referenced (and #iefix stripped): {refs:?}");
assert_eq!(refs.len(), 1, "remote (incl. protocol-relative) and data: urls must not be referenced: {refs:?}");
}
#[test]
fn reports_nothing_when_every_reference_was_written() {
let js = br#"import("./graph-Xy78Zw90.js")"#;
let items = vec![item(js, Kind::Js)];
let entries = vec![entry("js/graph-Xy78Zw90.js")];
assert!(missing(&items, &entries).is_empty());
}
}