#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests use unwrap and expect to keep fixture setup concise"
)
)]
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 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 mut offset = 0u32;
let mut chars = source.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' => {
offset = offset.saturating_add(1);
line_starts.push(offset);
}
'\r' => {
offset = offset.saturating_add(1);
if chars.peek() == Some(&'\n') {
chars.next();
offset = offset.saturating_add(1);
}
line_starts.push(offset);
}
_ => offset = offset.saturating_add(ch.len_utf16() as u32),
}
}
Self { line_starts }
}
#[must_use]
pub fn from_v8_line_lengths(line_lengths: &[u32]) -> Option<Self> {
if line_lengths.is_empty() {
return None;
}
let mut line_starts = Vec::with_capacity(line_lengths.len());
line_starts.push(0);
let mut offset = 0u32;
for length in line_lengths
.iter()
.take(line_lengths.len().saturating_sub(1))
{
offset = offset.saturating_add(*length).saturating_add(1);
line_starts.push(offset);
}
Some(Self { line_starts })
}
#[must_use]
pub fn position(&self, source_offset: u32) -> IstanbulPosition {
let line_zero_indexed = match self.line_starts.binary_search(&source_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: source_offset.saturating_sub(line_start),
}
}
}
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_uses_utf16_offsets_for_non_ascii_source() {
let source = "const smile = \"😀\";\nfunction after_emoji() {}\n";
let function_byte_offset = source
.find("function")
.expect("test source should contain function");
let function_v8_offset = source[..function_byte_offset].encode_utf16().count() as u32;
assert_ne!(function_v8_offset, function_byte_offset as u32);
let table = LineOffsetTable::from_source(source);
let pos = table.position(function_v8_offset);
assert_eq!(pos.line, 2);
assert_eq!(pos.column, 0);
}
#[test]
fn line_table_maps_columns_in_utf16_units_within_a_line() {
let source = "const e = \"😀😀\"; function f(){}\n";
let function_byte_offset = source
.find("function")
.expect("test source should contain function")
as u32;
let function_v8_offset = source[..function_byte_offset as usize]
.encode_utf16()
.count() as u32;
assert!(
function_v8_offset < function_byte_offset,
"fixture must place a multibyte char before the function",
);
let table = LineOffsetTable::from_source(source);
let pos = table.position(function_v8_offset);
assert_eq!(pos.line, 1);
assert_eq!(pos.column, function_v8_offset);
assert!(
pos.column < function_byte_offset,
"column must be measured in UTF-16 units, not bytes",
);
}
#[test]
fn line_table_builds_from_v8_line_lengths() {
let table = LineOffsetTable::from_v8_line_lengths(&[20, 12])
.expect("line lengths should build table");
assert_eq!(table.position(20).line, 1);
assert_eq!(table.position(20).column, 20);
assert_eq!(table.position(21).line, 2);
assert_eq!(table.position(21).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 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 istanbul_position_normalizes_null_column_to_zero() {
let with_null: IstanbulPosition =
serde_json::from_value(serde_json::json!({ "line": 76, "column": null })).unwrap();
assert_eq!(with_null.line, 76);
assert_eq!(with_null.column, 0);
let with_value: IstanbulPosition =
serde_json::from_value(serde_json::json!({ "line": 66, "column": 4 })).unwrap();
assert_eq!(with_value.column, 4);
}
mod proptests {
use super::*;
use proptest::prelude::*;
fn line_body() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(vec!['a', 'b', ' ', '€', '😀']), 0..12)
.prop_map(|chars| chars.into_iter().collect())
}
fn utf16_len(s: &str) -> u32 {
s.encode_utf16().count() as u32
}
proptest! {
#[test]
fn position_is_monotonic_in_offset(
source in prop::collection::vec(any::<char>(), 0..200)
.prop_map(|chars| chars.into_iter().collect::<String>()),
a in any::<u32>(),
b in any::<u32>(),
) {
let table = LineOffsetTable::from_source(&source);
let (lo, hi) = (a.min(b), a.max(b));
let p_lo = table.position(lo);
let p_hi = table.position(hi);
prop_assert!(p_lo.line >= 1, "line numbers are 1-indexed");
prop_assert!(
(p_lo.line, p_lo.column) <= (p_hi.line, p_hi.column),
"position({lo}) = {p_lo:?} should not exceed position({hi}) = {p_hi:?}",
);
}
#[test]
fn line_starts_and_columns_round_trip(
bodies in prop::collection::vec(line_body(), 1..8),
ending in prop::sample::select(vec!["\n", "\r\n", "\r"]),
) {
let source = bodies.join(ending);
let table = LineOffsetTable::from_source(&source);
let ending_units = utf16_len(ending);
let mut line_start = 0u32;
for (index, body) in bodies.iter().enumerate() {
let body_units = utf16_len(body);
let at_start = table.position(line_start);
prop_assert_eq!(at_start.line, index as u32 + 1);
prop_assert_eq!(at_start.column, 0);
for column in 0..=body_units {
let pos = table.position(line_start + column);
prop_assert_eq!(pos.line, index as u32 + 1);
prop_assert_eq!(pos.column, column);
}
line_start += body_units;
if index + 1 < bodies.len() {
line_start += ending_units;
}
}
}
#[test]
fn v8_line_lengths_build_consistent_table(
lengths in prop::collection::vec(0u32..1000, 1..20),
) {
let table = LineOffsetTable::from_v8_line_lengths(&lengths)
.expect("non-empty lengths build a table");
let mut starts = vec![0u32];
let mut acc = 0u32;
for length in &lengths[..lengths.len() - 1] {
acc += length + 1;
starts.push(acc);
}
let mut previous: Option<u32> = None;
for (index, &start) in starts.iter().enumerate() {
if let Some(prev) = previous {
prop_assert!(start > prev, "line starts must strictly increase");
}
previous = Some(start);
let at_start = table.position(start);
prop_assert_eq!(at_start.line, index as u32 + 1);
prop_assert_eq!(at_start.column, 0);
if index + 1 < lengths.len() {
for column in 0..=lengths[index] {
let pos = table.position(start + column);
prop_assert_eq!(pos.line, index as u32 + 1);
prop_assert_eq!(pos.column, column);
}
}
}
}
}
}
}