#![forbid(unsafe_code)]
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V8CoverageDump {
pub result: Vec<ScriptCoverage>,
#[serde(default, rename = "source-map-cache")]
pub source_map_cache: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptCoverage {
#[serde(rename = "scriptId")]
pub script_id: String,
pub url: String,
pub functions: Vec<FunctionCoverage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCoverage {
#[serde(rename = "functionName")]
pub function_name: String,
pub ranges: Vec<CoverageRange>,
#[serde(rename = "isBlockCoverage", default)]
pub is_block_coverage: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageRange {
#[serde(rename = "startOffset")]
pub start_offset: u32,
#[serde(rename = "endOffset")]
pub end_offset: u32,
pub count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IstanbulFileCoverage {
pub path: String,
#[serde(rename = "fnMap")]
pub fn_map: std::collections::BTreeMap<String, IstanbulFunction>,
pub f: std::collections::BTreeMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IstanbulFunction {
pub name: String,
pub decl: IstanbulRange,
pub loc: IstanbulRange,
pub line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IstanbulRange {
pub start: IstanbulPosition,
pub end: IstanbulPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IstanbulPosition {
pub line: u32,
#[serde(deserialize_with = "deserialize_nullable_u32")]
pub column: u32,
}
fn deserialize_nullable_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<u32>::deserialize(deserializer)?.unwrap_or(0))
}
#[derive(Debug)]
pub struct LineOffsetTable {
line_starts: Vec<u32>,
}
impl LineOffsetTable {
#[must_use]
pub fn from_source(source: &str) -> Self {
let mut line_starts = Vec::with_capacity(source.lines().count() + 1);
line_starts.push(0);
let bytes = source.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\n' => {
line_starts.push((i + 1) as u32);
i += 1;
}
b'\r' => {
let next_offset = if bytes.get(i + 1) == Some(&b'\n') {
i + 2
} else {
i + 1
};
line_starts.push(next_offset as u32);
i = next_offset;
}
_ => i += 1,
}
}
Self { line_starts }
}
#[must_use]
pub fn position(&self, byte_offset: u32) -> IstanbulPosition {
let line_zero_indexed = match self.line_starts.binary_search(&byte_offset) {
Ok(exact) => exact,
Err(insertion_point) => insertion_point.saturating_sub(1),
};
let line_start = self.line_starts[line_zero_indexed];
IstanbulPosition {
line: (line_zero_indexed as u32) + 1,
column: byte_offset.saturating_sub(line_start),
}
}
}
pub struct ScriptInput<'a> {
pub path: &'a str,
pub source: &'a str,
pub script: &'a ScriptCoverage,
}
#[must_use]
pub fn normalize_script(input: &ScriptInput<'_>) -> IstanbulFileCoverage {
let table = LineOffsetTable::from_source(input.source);
let mut fn_map = std::collections::BTreeMap::new();
let mut hits = std::collections::BTreeMap::new();
for (idx, function) in input.script.functions.iter().enumerate() {
let key = format!("f{idx}");
let outer = function.ranges.first().copied().unwrap_or(CoverageRange {
start_offset: 0,
end_offset: 0,
count: 0,
});
let start_pos = table.position(outer.start_offset);
let end_pos = table.position(outer.end_offset);
fn_map.insert(
key.clone(),
IstanbulFunction {
name: if function.function_name.is_empty() {
"(anonymous)".to_owned()
} else {
function.function_name.clone()
},
decl: IstanbulRange {
start: start_pos,
end: start_pos,
},
loc: IstanbulRange {
start: start_pos,
end: end_pos,
},
line: start_pos.line,
},
);
hits.insert(key, outer.count);
}
IstanbulFileCoverage {
path: input.path.to_owned(),
fn_map,
f: hits,
}
}
impl Copy for CoverageRange {}
impl Copy for IstanbulPosition {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_table_handles_lf() {
let table = LineOffsetTable::from_source("a\nbb\nccc");
assert_eq!(table.position(0).line, 1);
assert_eq!(table.position(0).column, 0);
assert_eq!(table.position(2).line, 2);
assert_eq!(table.position(2).column, 0);
assert_eq!(table.position(5).line, 3);
assert_eq!(table.position(5).column, 0);
}
#[test]
fn line_table_handles_crlf() {
let table = LineOffsetTable::from_source("a\r\nbb\r\nccc");
assert_eq!(table.position(3).line, 2);
assert_eq!(table.position(3).column, 0);
}
#[test]
fn line_table_handles_lone_cr() {
let table = LineOffsetTable::from_source("a\rbb");
assert_eq!(table.position(2).line, 2);
assert_eq!(table.position(2).column, 0);
}
#[test]
fn line_table_clamps_past_end() {
let table = LineOffsetTable::from_source("abc");
let pos = table.position(100);
assert_eq!(pos.line, 1);
assert_eq!(pos.column, 100);
}
#[test]
fn normalize_round_trips_function_hits() {
let source = "function alpha() {}\nfunction beta() {}\n";
let script = ScriptCoverage {
script_id: "1".into(),
url: "file:///t/foo.js".into(),
functions: vec![
FunctionCoverage {
function_name: "alpha".into(),
ranges: vec![CoverageRange {
start_offset: 0,
end_offset: 19,
count: 7,
}],
is_block_coverage: false,
},
FunctionCoverage {
function_name: "beta".into(),
ranges: vec![CoverageRange {
start_offset: 20,
end_offset: 39,
count: 0,
}],
is_block_coverage: false,
},
],
};
let normalized = normalize_script(&ScriptInput {
path: "/t/foo.js",
source,
script: &script,
});
assert_eq!(normalized.f["f0"], 7);
assert_eq!(normalized.f["f1"], 0);
assert_eq!(normalized.fn_map["f0"].name, "alpha");
assert_eq!(normalized.fn_map["f1"].line, 2);
}
#[test]
fn anonymous_function_renamed() {
let source = "() => {}";
let script = ScriptCoverage {
script_id: "1".into(),
url: "file:///t/anon.js".into(),
functions: vec![FunctionCoverage {
function_name: String::new(),
ranges: vec![CoverageRange {
start_offset: 0,
end_offset: 8,
count: 1,
}],
is_block_coverage: false,
}],
};
let normalized = normalize_script(&ScriptInput {
path: "/t/anon.js",
source,
script: &script,
});
assert_eq!(normalized.fn_map["f0"].name, "(anonymous)");
}
#[test]
fn parse_node_v8_coverage_dump() {
let raw = serde_json::json!({
"result": [{
"scriptId": "42",
"url": "file:///t/x.js",
"functions": [{
"functionName": "a",
"ranges": [{"startOffset": 0, "endOffset": 10, "count": 3}],
"isBlockCoverage": false
}]
}]
});
let dump: V8CoverageDump = serde_json::from_value(raw).unwrap();
assert_eq!(dump.result.len(), 1);
assert_eq!(dump.result[0].functions[0].function_name, "a");
}
#[test]
fn parse_istanbul_coverage_with_null_columns() {
let raw = serde_json::json!({
"/t/linkUtils.ts": {
"path": "/t/linkUtils.ts",
"fnMap": {
"0": {
"name": "normalizeInternalLink",
"decl": {
"start": { "line": 66, "column": 0 },
"end": { "line": 66, "column": null }
},
"loc": {
"start": { "line": 66, "column": 0 },
"end": { "line": 76, "column": null }
},
"line": 66
}
},
"f": { "0": 9 }
}
});
let dump: std::collections::BTreeMap<String, IstanbulFileCoverage> =
serde_json::from_value(raw).unwrap();
let file = &dump["/t/linkUtils.ts"];
assert_eq!(file.fn_map["0"].decl.end.column, 0);
assert_eq!(file.fn_map["0"].loc.end.column, 0);
assert_eq!(file.f["0"], 9);
}
}