1#![forbid(unsafe_code)]
16
17use serde::{Deserialize, Deserializer, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct V8CoverageDump {
25 pub result: Vec<ScriptCoverage>,
27 #[serde(default, rename = "source-map-cache")]
29 pub source_map_cache: Option<serde_json::Value>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ScriptCoverage {
36 #[serde(rename = "scriptId")]
38 pub script_id: String,
39 pub url: String,
42 pub functions: Vec<FunctionCoverage>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct FunctionCoverage {
51 #[serde(rename = "functionName")]
54 pub function_name: String,
55 pub ranges: Vec<CoverageRange>,
57 #[serde(rename = "isBlockCoverage", default)]
61 pub is_block_coverage: bool,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct CoverageRange {
67 #[serde(rename = "startOffset")]
69 pub start_offset: u32,
70 #[serde(rename = "endOffset")]
72 pub end_offset: u32,
73 pub count: u64,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct IstanbulFileCoverage {
84 pub path: String,
86 #[serde(rename = "fnMap")]
88 pub fn_map: std::collections::BTreeMap<String, IstanbulFunction>,
89 pub f: std::collections::BTreeMap<String, u64>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct IstanbulFunction {
96 pub name: String,
98 pub decl: IstanbulRange,
100 pub loc: IstanbulRange,
102 pub line: u32,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct IstanbulRange {
109 pub start: IstanbulPosition,
111 pub end: IstanbulPosition,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct IstanbulPosition {
118 pub line: u32,
120 #[serde(deserialize_with = "deserialize_nullable_u32")]
126 pub column: u32,
127}
128
129fn deserialize_nullable_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
130where
131 D: Deserializer<'de>,
132{
133 Ok(Option::<u32>::deserialize(deserializer)?.unwrap_or(0))
134}
135
136#[derive(Debug)]
144pub struct LineOffsetTable {
145 line_starts: Vec<u32>,
148}
149
150impl LineOffsetTable {
151 #[must_use]
154 pub fn from_source(source: &str) -> Self {
155 let mut line_starts = Vec::with_capacity(source.lines().count() + 1);
156 line_starts.push(0);
157 let bytes = source.as_bytes();
158 let mut i = 0;
159 while i < bytes.len() {
160 match bytes[i] {
161 b'\n' => {
162 line_starts.push((i + 1) as u32);
163 i += 1;
164 }
165 b'\r' => {
166 let next_offset = if bytes.get(i + 1) == Some(&b'\n') {
167 i + 2
168 } else {
169 i + 1
170 };
171 line_starts.push(next_offset as u32);
172 i = next_offset;
173 }
174 _ => i += 1,
175 }
176 }
177 Self { line_starts }
178 }
179
180 #[must_use]
185 pub fn position(&self, byte_offset: u32) -> IstanbulPosition {
186 let line_zero_indexed = match self.line_starts.binary_search(&byte_offset) {
188 Ok(exact) => exact,
189 Err(insertion_point) => insertion_point.saturating_sub(1),
190 };
191 let line_start = self.line_starts[line_zero_indexed];
192 IstanbulPosition {
193 line: (line_zero_indexed as u32) + 1,
194 column: byte_offset.saturating_sub(line_start),
195 }
196 }
197}
198
199pub struct ScriptInput<'a> {
203 pub path: &'a str,
205 pub source: &'a str,
207 pub script: &'a ScriptCoverage,
209}
210
211#[must_use]
219pub fn normalize_script(input: &ScriptInput<'_>) -> IstanbulFileCoverage {
220 let table = LineOffsetTable::from_source(input.source);
221 let mut fn_map = std::collections::BTreeMap::new();
222 let mut hits = std::collections::BTreeMap::new();
223 for (idx, function) in input.script.functions.iter().enumerate() {
224 let key = format!("f{idx}");
225 let outer = function.ranges.first().copied().unwrap_or(CoverageRange {
226 start_offset: 0,
227 end_offset: 0,
228 count: 0,
229 });
230 let start_pos = table.position(outer.start_offset);
231 let end_pos = table.position(outer.end_offset);
232 fn_map.insert(
233 key.clone(),
234 IstanbulFunction {
235 name: if function.function_name.is_empty() {
236 "(anonymous)".to_owned()
237 } else {
238 function.function_name.clone()
239 },
240 decl: IstanbulRange {
241 start: start_pos,
242 end: start_pos,
243 },
244 loc: IstanbulRange {
245 start: start_pos,
246 end: end_pos,
247 },
248 line: start_pos.line,
249 },
250 );
251 hits.insert(key, outer.count);
252 }
253 IstanbulFileCoverage {
254 path: input.path.to_owned(),
255 fn_map,
256 f: hits,
257 }
258}
259
260impl Copy for CoverageRange {}
262impl Copy for IstanbulPosition {}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn line_table_handles_lf() {
270 let table = LineOffsetTable::from_source("a\nbb\nccc");
271 assert_eq!(table.position(0).line, 1);
272 assert_eq!(table.position(0).column, 0);
273 assert_eq!(table.position(2).line, 2);
274 assert_eq!(table.position(2).column, 0);
275 assert_eq!(table.position(5).line, 3);
276 assert_eq!(table.position(5).column, 0);
277 }
278
279 #[test]
280 fn line_table_handles_crlf() {
281 let table = LineOffsetTable::from_source("a\r\nbb\r\nccc");
282 assert_eq!(table.position(3).line, 2);
283 assert_eq!(table.position(3).column, 0);
284 }
285
286 #[test]
287 fn line_table_handles_lone_cr() {
288 let table = LineOffsetTable::from_source("a\rbb");
289 assert_eq!(table.position(2).line, 2);
290 assert_eq!(table.position(2).column, 0);
291 }
292
293 #[test]
294 fn line_table_clamps_past_end() {
295 let table = LineOffsetTable::from_source("abc");
296 let pos = table.position(100);
297 assert_eq!(pos.line, 1);
298 assert_eq!(pos.column, 100);
299 }
300
301 #[test]
302 fn normalize_round_trips_function_hits() {
303 let source = "function alpha() {}\nfunction beta() {}\n";
304 let script = ScriptCoverage {
305 script_id: "1".into(),
306 url: "file:///t/foo.js".into(),
307 functions: vec![
308 FunctionCoverage {
309 function_name: "alpha".into(),
310 ranges: vec![CoverageRange {
311 start_offset: 0,
312 end_offset: 19,
313 count: 7,
314 }],
315 is_block_coverage: false,
316 },
317 FunctionCoverage {
318 function_name: "beta".into(),
319 ranges: vec![CoverageRange {
320 start_offset: 20,
321 end_offset: 39,
322 count: 0,
323 }],
324 is_block_coverage: false,
325 },
326 ],
327 };
328 let normalized = normalize_script(&ScriptInput {
329 path: "/t/foo.js",
330 source,
331 script: &script,
332 });
333 assert_eq!(normalized.f["f0"], 7);
334 assert_eq!(normalized.f["f1"], 0);
335 assert_eq!(normalized.fn_map["f0"].name, "alpha");
336 assert_eq!(normalized.fn_map["f1"].line, 2);
337 }
338
339 #[test]
340 fn anonymous_function_renamed() {
341 let source = "() => {}";
342 let script = ScriptCoverage {
343 script_id: "1".into(),
344 url: "file:///t/anon.js".into(),
345 functions: vec![FunctionCoverage {
346 function_name: String::new(),
347 ranges: vec![CoverageRange {
348 start_offset: 0,
349 end_offset: 8,
350 count: 1,
351 }],
352 is_block_coverage: false,
353 }],
354 };
355 let normalized = normalize_script(&ScriptInput {
356 path: "/t/anon.js",
357 source,
358 script: &script,
359 });
360 assert_eq!(normalized.fn_map["f0"].name, "(anonymous)");
361 }
362
363 #[test]
364 fn parse_node_v8_coverage_dump() {
365 let raw = serde_json::json!({
366 "result": [{
367 "scriptId": "42",
368 "url": "file:///t/x.js",
369 "functions": [{
370 "functionName": "a",
371 "ranges": [{"startOffset": 0, "endOffset": 10, "count": 3}],
372 "isBlockCoverage": false
373 }]
374 }]
375 });
376 let dump: V8CoverageDump = serde_json::from_value(raw).unwrap();
377 assert_eq!(dump.result.len(), 1);
378 assert_eq!(dump.result[0].functions[0].function_name, "a");
379 }
380
381 #[test]
382 fn parse_istanbul_coverage_with_null_columns() {
383 let raw = serde_json::json!({
384 "/t/linkUtils.ts": {
385 "path": "/t/linkUtils.ts",
386 "fnMap": {
387 "0": {
388 "name": "normalizeInternalLink",
389 "decl": {
390 "start": { "line": 66, "column": 0 },
391 "end": { "line": 66, "column": null }
392 },
393 "loc": {
394 "start": { "line": 66, "column": 0 },
395 "end": { "line": 76, "column": null }
396 },
397 "line": 66
398 }
399 },
400 "f": { "0": 9 }
401 }
402 });
403
404 let dump: std::collections::BTreeMap<String, IstanbulFileCoverage> =
405 serde_json::from_value(raw).unwrap();
406 let file = &dump["/t/linkUtils.ts"];
407 assert_eq!(file.fn_map["0"].decl.end.column, 0);
408 assert_eq!(file.fn_map["0"].loc.end.column, 0);
409 assert_eq!(file.f["0"], 9);
410 }
411}