use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileCoverage {
pub path: String,
pub covered: u32,
pub total: u32,
pub uncovered: Vec<u32>,
pub covered_lines: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CoverageReport {
pub files: Vec<FileCoverage>,
}
impl CoverageReport {
pub fn total_covered(&self) -> u32 {
self.files.iter().map(|f| f.covered).sum()
}
pub fn total_lines(&self) -> u32 {
self.files.iter().map(|f| f.total).sum()
}
pub fn total_percent(&self) -> u32 {
percent(self.total_covered(), self.total_lines())
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
}
pub fn percent(covered: u32, total: u32) -> u32 {
if total == 0 || covered >= total {
100
} else {
let rounded = (covered as u64 * 100 + total as u64 / 2) / total as u64;
(rounded as u32).min(99)
}
}
#[derive(Deserialize)]
struct V8Document {
#[serde(default)]
result: Vec<V8Script>,
}
#[derive(Deserialize)]
struct V8Script {
url: String,
#[serde(default)]
functions: Vec<V8Function>,
}
#[derive(Deserialize)]
struct V8Function {
#[serde(default)]
ranges: Vec<V8Range>,
}
#[derive(Deserialize)]
struct V8Range {
#[serde(rename = "startOffset")]
start: usize,
#[serde(rename = "endOffset")]
end: usize,
count: i64,
}
pub fn collect_coverage(
v8_dir: &Path,
out_js_root: &Path,
out_root: &Path,
source_root: &Path,
) -> std::io::Result<CoverageReport> {
let canonical_root = source_root.canonicalize().ok();
let mut scripts: HashMap<PathBuf, Vec<V8Range>> = HashMap::new();
for entry in std::fs::read_dir(v8_dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(doc) = serde_json::from_str::<V8Document>(&text) else {
continue;
};
for script in doc.result {
let Some(fs_path) = file_url_to_path(&script.url) else {
continue;
};
let canon = std::fs::canonicalize(&fs_path).unwrap_or(fs_path);
let ranges = scripts.entry(canon).or_default();
for func in script.functions {
ranges.extend(func.ranges);
}
}
}
let out_js_canon =
std::fs::canonicalize(out_js_root).unwrap_or_else(|_| out_js_root.to_path_buf());
let mut acc: BTreeMap<String, FileAcc> = BTreeMap::new();
for (script_path, ranges) in &scripts {
let Ok(rel) = script_path.strip_prefix(&out_js_canon) else {
continue;
};
if !is_measurable_emitted(rel) {
continue;
}
let Ok(js_text) = std::fs::read_to_string(script_path) else {
continue;
};
let js_map_path = append_ext(script_path, "map");
let Some(js_map) = std::fs::read_to_string(&js_map_path)
.ok()
.and_then(|s| SourceMap::parse(&s))
else {
continue;
};
let ts_rel = rel.with_extension("ts");
let ts_map_path = append_ext(&out_root.join(&ts_rel), "map");
let Some(ts_map) = std::fs::read_to_string(&ts_map_path)
.ok()
.and_then(|s| SourceMap::parse(&s))
else {
continue;
};
let line_reps = line_representatives(&js_text);
for (js_line, rep_off) in line_reps.iter().enumerate() {
let Some(off) = rep_off else { continue };
let Some((span, count)) = innermost_range(*off, ranges) else {
continue;
};
let Some(js_segs) = js_map.lines.get(js_line) else {
continue;
};
for &(_js_src, ts_line) in js_segs {
let Some(ts_segs) = ts_map.lines.get(ts_line as usize) else {
continue;
};
for &(bynk_src, bynk_line) in ts_segs {
let Some(bynk_abs) = ts_map.sources.get(bynk_src) else {
continue;
};
let disp = relativise(bynk_abs, source_root, canonical_root.as_deref());
let file = acc.entry(disp).or_default();
let line1 = bynk_line + 1;
file.observe(line1, span, count);
}
}
}
}
let files = acc
.into_iter()
.map(|(path, a)| {
let total = a.lines.len() as u32;
let mut covered_lines = Vec::new();
let mut uncovered = Vec::new();
for (&line, &(_, count)) in &a.lines {
if count > 0 {
covered_lines.push(line);
} else {
uncovered.push(line);
}
}
FileCoverage {
path,
covered: covered_lines.len() as u32,
total,
uncovered,
covered_lines,
}
})
.collect();
Ok(CoverageReport { files })
}
#[derive(Default)]
struct FileAcc {
lines: BTreeMap<u32, (usize, i64)>,
}
impl FileAcc {
fn observe(&mut self, line: u32, span: usize, count: i64) {
let slot = self.lines.entry(line).or_insert((usize::MAX, 0));
if span < slot.0 || (span == slot.0 && count > slot.1) {
*slot = (span, count);
}
}
}
fn innermost_range(off: usize, ranges: &[V8Range]) -> Option<(usize, i64)> {
let mut best: Option<(usize, i64)> = None;
for r in ranges {
if r.start <= off && off < r.end {
let span = r.end - r.start;
match best {
Some((bs, bc)) if span > bs || (span == bs && r.count <= bc) => {}
_ => best = Some((span, r.count)),
}
}
}
best
}
fn line_representatives(text: &str) -> Vec<Option<usize>> {
let mut out = Vec::new();
let mut u16_off = 0usize;
for line in text.split_inclusive('\n') {
let mut rep = None;
let mut o = u16_off;
for c in line.chars() {
if c.is_whitespace() {
o += c.len_utf16();
} else {
rep = Some(o);
break;
}
}
if let Some(start) = rep {
let rest = line.trim_start();
if let Some(after) = rest.strip_prefix("export ")
&& !after.trim().is_empty()
{
rep = Some(start + rest.len() - after.trim_start().len());
}
}
out.push(rep);
u16_off += line.chars().map(char::len_utf16).sum::<usize>();
}
out
}
fn is_measurable_emitted(rel: &Path) -> bool {
if rel.extension().and_then(|e| e.to_str()) != Some("js") {
return false;
}
let mut comps = rel.components();
match comps.next() {
Some(Component::Normal(c)) if c == "tests" || c == "workers" => return false,
_ => {}
}
if rel == Path::new("runtime.js") {
return false;
}
true
}
fn relativise(abs: &str, source_root: &Path, canonical_root: Option<&Path>) -> String {
let p = Path::new(abs);
for root in std::iter::once(source_root).chain(canonical_root) {
if let Ok(rel) = p.strip_prefix(root) {
return forward_slash(rel);
}
}
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| abs.to_string())
}
fn forward_slash(p: &Path) -> String {
p.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn file_url_to_path(url: &str) -> Option<PathBuf> {
let rest = url.strip_prefix("file://")?;
let path = rest
.strip_prefix('/')
.map(|r| format!("/{r}"))
.unwrap_or_else(|| rest.to_string());
Some(PathBuf::from(percent_decode(&path)))
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn append_ext(path: &Path, ext: &str) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".");
name.push(ext);
path.with_file_name(name)
}
struct SourceMap {
sources: Vec<String>,
lines: Vec<Vec<(usize, u32)>>,
}
impl SourceMap {
fn parse(json: &str) -> Option<SourceMap> {
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
sources: Vec<String>,
#[serde(default)]
mappings: String,
}
let raw: Raw = serde_json::from_str(json).ok()?;
Some(SourceMap {
sources: raw.sources,
lines: decode_line_mappings(&raw.mappings),
})
}
}
fn decode_line_mappings(mappings: &str) -> Vec<Vec<(usize, u32)>> {
let mut lines = Vec::new();
let (mut src, mut src_line) = (0i64, 0i64);
for seg_line in mappings.split(';') {
let mut segs = Vec::new();
for seg in seg_line.split(',') {
if seg.is_empty() {
continue;
}
let fields = vlq_decode(seg);
if fields.len() >= 4 {
src += fields[1];
src_line += fields[2];
if src >= 0 && src_line >= 0 {
segs.push((src as usize, src_line as u32));
}
}
}
lines.push(segs);
}
lines
}
fn vlq_decode(seg: &str) -> Vec<i64> {
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let (mut shift, mut acc) = (0u32, 0i64);
for &c in seg.as_bytes() {
let Some(d) = B64.iter().position(|&b| b == c).map(|p| p as i64) else {
continue;
};
acc += (d & 0b11111) << shift;
if d & 0b100000 != 0 {
shift += 5;
} else {
let value = if acc & 1 == 1 { -(acc >> 1) } else { acc >> 1 };
out.push(value);
shift = 0;
acc = 0;
}
}
out
}
pub fn render_rich(report: &CoverageReport) -> String {
let mut out = String::new();
out.push_str("\nCoverage\n");
if report.is_empty() {
out.push_str(" (no executable `.bynk` lines were attributed)\n");
return out;
}
let name_w = report
.files
.iter()
.map(|f| f.path.len())
.max()
.unwrap_or(0)
.max(5);
for f in &report.files {
let pct = percent(f.covered, f.total);
out.push_str(&format!(
" {:<name_w$} {} {:>3}% ({}/{} lines)",
f.path,
bar(f.covered, f.total),
pct,
f.covered,
f.total,
));
if !f.uncovered.is_empty() {
out.push_str(&format!(" uncovered: {}", format_ranges(&f.uncovered)));
}
out.push('\n');
}
let dashes = "─".repeat(name_w + 24);
out.push_str(&format!(" {dashes}\n"));
out.push_str(&format!(
" {:<name_w$} {} {:>3}% ({}/{} lines)\n",
"total",
bar(report.total_covered(), report.total_lines()),
report.total_percent(),
report.total_covered(),
report.total_lines(),
));
out
}
fn bar(covered: u32, total: u32) -> String {
const CELLS: u32 = 8;
let filled = if total == 0 {
CELLS
} else {
(covered as u64 * CELLS as u64 / total as u64) as u32
};
let mut s = String::new();
for _ in 0..filled {
s.push('▓');
}
for _ in filled..CELLS {
s.push('·');
}
s
}
pub fn format_ranges(lines: &[u32]) -> String {
let mut parts = Vec::new();
let mut i = 0;
while i < lines.len() {
let start = lines[i];
let mut end = start;
while i + 1 < lines.len() && lines[i + 1] == end + 1 {
end += 1;
i += 1;
}
if start == end {
parts.push(format!("{start}"));
} else {
parts.push(format!("{start}-{end}"));
}
i += 1;
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_relative_root_still_relativises_through_its_canonical_form() {
let abs = "/work/shop/src/window.bynk";
assert_eq!(
relativise(abs, Path::new("."), Some(Path::new("/work/shop"))),
"src/window.bynk"
);
assert_eq!(
relativise(abs, Path::new("/work/shop"), None),
"src/window.bynk"
);
assert_eq!(
relativise(abs, Path::new("."), Some(Path::new("/elsewhere"))),
"window.bynk"
);
}
#[test]
fn vlq_decodes_signed_fields() {
assert_eq!(vlq_decode("AAAA"), vec![0, 0, 0, 0]);
assert_eq!(vlq_decode("AAEA"), vec![0, 0, 2, 0]);
assert_eq!(vlq_decode("AACE"), vec![0, 0, 1, 2]);
assert_eq!(vlq_decode("D"), vec![-1]);
}
#[test]
fn line_mappings_track_running_source_line() {
let m = ";;;;;AAEA;AACE;AAAA;AAGF;AACE;AACA";
let lines = decode_line_mappings(m);
assert_eq!(lines[0], vec![]);
assert_eq!(lines[5], vec![(0, 2)]);
assert_eq!(lines[6], vec![(0, 3)]);
assert_eq!(lines[7], vec![(0, 3)]);
assert_eq!(lines[8], vec![(0, 6)]);
assert_eq!(lines[9], vec![(0, 7)]);
assert_eq!(lines[10], vec![(0, 8)]);
}
#[test]
fn innermost_range_prefers_the_tightest_span() {
let ranges = vec![
V8Range {
start: 0,
end: 100,
count: 1,
},
V8Range {
start: 40,
end: 60,
count: 0,
},
];
assert_eq!(innermost_range(10, &ranges), Some((100, 1)));
assert_eq!(innermost_range(50, &ranges), Some((20, 0)));
assert_eq!(innermost_range(200, &ranges), None); }
#[test]
fn file_acc_lets_the_tightest_range_decide() {
let mut acc = FileAcc::default();
acc.observe(7, 352, 1); acc.observe(7, 61, 0); assert_eq!(acc.lines.get(&7), Some(&(61, 0)));
}
#[test]
fn line_representatives_pick_first_nonspace() {
let reps = line_representatives("ab\n cd\n\n \nx");
assert_eq!(reps[0], Some(0)); assert_eq!(reps[1], Some(7)); assert_eq!(reps[2], None); assert_eq!(reps[3], None); assert_eq!(reps[4], Some(14)); }
#[test]
fn line_representatives_skip_a_leading_export() {
let reps = line_representatives("export function f() {\n export const x = 1;\nexport\n");
assert_eq!(reps[0], Some(7)); assert_eq!(reps[1], Some(32)); assert_eq!(reps[2], Some(45)); }
#[test]
fn line_representatives_count_utf16_units_not_bytes() {
let reps = line_representatives("a — b\nx");
assert_eq!(reps[0], Some(0)); assert_eq!(reps[1], Some(6)); let reps = line_representatives("🎉\ny");
assert_eq!(reps[0], Some(0));
assert_eq!(reps[1], Some(3)); }
#[test]
fn format_ranges_compresses_runs() {
assert_eq!(format_ranges(&[6, 7, 8, 51]), "6-8, 51");
assert_eq!(format_ranges(&[3]), "3");
assert_eq!(format_ranges(&[1, 2, 4, 5, 6]), "1-2, 4-6");
assert_eq!(format_ranges(&[]), "");
}
#[test]
fn percent_rounds_and_guards_zero() {
assert_eq!(percent(0, 0), 100);
assert_eq!(percent(42, 49), 86);
assert_eq!(percent(2, 5), 40);
assert_eq!(percent(11, 11), 100);
}
#[test]
fn percent_never_reports_100_for_an_incomplete_run() {
assert_eq!(percent(995, 1000), 99);
assert_eq!(percent(199, 200), 99);
assert_eq!(percent(1000, 1000), 100); assert_eq!(percent(0, 5), 0);
}
#[test]
fn measurable_filter_drops_tests_workers_runtime() {
assert!(is_measurable_emitted(Path::new("src/limiter.js")));
assert!(is_measurable_emitted(Path::new("limiter.js")));
assert!(!is_measurable_emitted(Path::new("tests/main.js")));
assert!(!is_measurable_emitted(Path::new("workers/api/handlers.js")));
assert!(!is_measurable_emitted(Path::new("runtime.js")));
assert!(!is_measurable_emitted(Path::new("src/limiter.js.map")));
}
#[test]
fn file_url_round_trips_to_path() {
assert_eq!(
file_url_to_path("file:///private/tmp/a%20b/out-js/m.js"),
Some(PathBuf::from("/private/tmp/a b/out-js/m.js"))
);
assert_eq!(file_url_to_path("node:internal/modules"), None);
}
}