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)]
148pub struct LineOffsetTable {
149 line_starts: Vec<u32>,
152}
153
154impl LineOffsetTable {
155 #[must_use]
158 pub fn from_source(source: &str) -> Self {
159 let mut line_starts = Vec::with_capacity(source.lines().count() + 1);
160 line_starts.push(0);
161 let mut offset = 0u32;
162 let mut chars = source.chars().peekable();
163 while let Some(ch) = chars.next() {
164 match ch {
165 '\n' => {
166 offset = offset.saturating_add(1);
167 line_starts.push(offset);
168 }
169 '\r' => {
170 offset = offset.saturating_add(1);
171 if chars.peek() == Some(&'\n') {
172 chars.next();
173 offset = offset.saturating_add(1);
174 }
175 line_starts.push(offset);
176 }
177 _ => offset = offset.saturating_add(ch.len_utf16() as u32),
178 }
179 }
180 Self { line_starts }
181 }
182
183 #[must_use]
189 pub fn from_v8_line_lengths(line_lengths: &[u32]) -> Option<Self> {
190 if line_lengths.is_empty() {
191 return None;
192 }
193
194 let mut line_starts = Vec::with_capacity(line_lengths.len());
195 line_starts.push(0);
196 let mut offset = 0u32;
197 for length in line_lengths
198 .iter()
199 .take(line_lengths.len().saturating_sub(1))
200 {
201 offset = offset.saturating_add(*length).saturating_add(1);
202 line_starts.push(offset);
203 }
204 Some(Self { line_starts })
205 }
206
207 #[must_use]
212 pub fn position(&self, source_offset: u32) -> IstanbulPosition {
213 let line_zero_indexed = match self.line_starts.binary_search(&source_offset) {
215 Ok(exact) => exact,
216 Err(insertion_point) => insertion_point.saturating_sub(1),
217 };
218 let line_start = self.line_starts[line_zero_indexed];
219 IstanbulPosition {
220 line: (line_zero_indexed as u32) + 1,
221 column: source_offset.saturating_sub(line_start),
222 }
223 }
224}
225
226pub struct ScriptInput<'a> {
230 pub path: &'a str,
232 pub source: &'a str,
234 pub script: &'a ScriptCoverage,
236}
237
238#[must_use]
246pub fn normalize_script(input: &ScriptInput<'_>) -> IstanbulFileCoverage {
247 let table = LineOffsetTable::from_source(input.source);
248 let mut fn_map = std::collections::BTreeMap::new();
249 let mut hits = std::collections::BTreeMap::new();
250 for (idx, function) in input.script.functions.iter().enumerate() {
251 let key = format!("f{idx}");
252 let outer = function.ranges.first().copied().unwrap_or(CoverageRange {
253 start_offset: 0,
254 end_offset: 0,
255 count: 0,
256 });
257 let start_pos = table.position(outer.start_offset);
258 let end_pos = table.position(outer.end_offset);
259 fn_map.insert(
260 key.clone(),
261 IstanbulFunction {
262 name: if function.function_name.is_empty() {
263 "(anonymous)".to_owned()
264 } else {
265 function.function_name.clone()
266 },
267 decl: IstanbulRange {
268 start: start_pos,
269 end: start_pos,
270 },
271 loc: IstanbulRange {
272 start: start_pos,
273 end: end_pos,
274 },
275 line: start_pos.line,
276 },
277 );
278 hits.insert(key, outer.count);
279 }
280 IstanbulFileCoverage {
281 path: input.path.to_owned(),
282 fn_map,
283 f: hits,
284 }
285}
286
287impl Copy for CoverageRange {}
289impl Copy for IstanbulPosition {}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn line_table_handles_lf() {
297 let table = LineOffsetTable::from_source("a\nbb\nccc");
298 assert_eq!(table.position(0).line, 1);
299 assert_eq!(table.position(0).column, 0);
300 assert_eq!(table.position(2).line, 2);
301 assert_eq!(table.position(2).column, 0);
302 assert_eq!(table.position(5).line, 3);
303 assert_eq!(table.position(5).column, 0);
304 }
305
306 #[test]
307 fn line_table_handles_crlf() {
308 let table = LineOffsetTable::from_source("a\r\nbb\r\nccc");
309 assert_eq!(table.position(3).line, 2);
310 assert_eq!(table.position(3).column, 0);
311 }
312
313 #[test]
314 fn line_table_handles_lone_cr() {
315 let table = LineOffsetTable::from_source("a\rbb");
316 assert_eq!(table.position(2).line, 2);
317 assert_eq!(table.position(2).column, 0);
318 }
319
320 #[test]
321 fn line_table_uses_utf16_offsets_for_non_ascii_source() {
322 let source = "const smile = \"😀\";\nfunction after_emoji() {}\n";
323 let function_byte_offset = source
324 .find("function")
325 .expect("test source should contain function");
326 let function_v8_offset = source[..function_byte_offset].encode_utf16().count() as u32;
327
328 assert_ne!(function_v8_offset, function_byte_offset as u32);
329
330 let table = LineOffsetTable::from_source(source);
331 let pos = table.position(function_v8_offset);
332
333 assert_eq!(pos.line, 2);
334 assert_eq!(pos.column, 0);
335 }
336
337 #[test]
338 fn line_table_builds_from_v8_line_lengths() {
339 let table = LineOffsetTable::from_v8_line_lengths(&[20, 12])
340 .expect("line lengths should build table");
341
342 assert_eq!(table.position(20).line, 1);
343 assert_eq!(table.position(20).column, 20);
344 assert_eq!(table.position(21).line, 2);
345 assert_eq!(table.position(21).column, 0);
346 }
347
348 #[test]
349 fn line_table_clamps_past_end() {
350 let table = LineOffsetTable::from_source("abc");
351 let pos = table.position(100);
352 assert_eq!(pos.line, 1);
353 assert_eq!(pos.column, 100);
354 }
355
356 #[test]
357 fn normalize_round_trips_function_hits() {
358 let source = "function alpha() {}\nfunction beta() {}\n";
359 let script = ScriptCoverage {
360 script_id: "1".into(),
361 url: "file:///t/foo.js".into(),
362 functions: vec![
363 FunctionCoverage {
364 function_name: "alpha".into(),
365 ranges: vec![CoverageRange {
366 start_offset: 0,
367 end_offset: 19,
368 count: 7,
369 }],
370 is_block_coverage: false,
371 },
372 FunctionCoverage {
373 function_name: "beta".into(),
374 ranges: vec![CoverageRange {
375 start_offset: 20,
376 end_offset: 39,
377 count: 0,
378 }],
379 is_block_coverage: false,
380 },
381 ],
382 };
383 let normalized = normalize_script(&ScriptInput {
384 path: "/t/foo.js",
385 source,
386 script: &script,
387 });
388 assert_eq!(normalized.f["f0"], 7);
389 assert_eq!(normalized.f["f1"], 0);
390 assert_eq!(normalized.fn_map["f0"].name, "alpha");
391 assert_eq!(normalized.fn_map["f1"].line, 2);
392 }
393
394 #[test]
395 fn anonymous_function_renamed() {
396 let source = "() => {}";
397 let script = ScriptCoverage {
398 script_id: "1".into(),
399 url: "file:///t/anon.js".into(),
400 functions: vec![FunctionCoverage {
401 function_name: String::new(),
402 ranges: vec![CoverageRange {
403 start_offset: 0,
404 end_offset: 8,
405 count: 1,
406 }],
407 is_block_coverage: false,
408 }],
409 };
410 let normalized = normalize_script(&ScriptInput {
411 path: "/t/anon.js",
412 source,
413 script: &script,
414 });
415 assert_eq!(normalized.fn_map["f0"].name, "(anonymous)");
416 }
417
418 #[test]
419 fn parse_node_v8_coverage_dump() {
420 let raw = serde_json::json!({
421 "result": [{
422 "scriptId": "42",
423 "url": "file:///t/x.js",
424 "functions": [{
425 "functionName": "a",
426 "ranges": [{"startOffset": 0, "endOffset": 10, "count": 3}],
427 "isBlockCoverage": false
428 }]
429 }]
430 });
431 let dump: V8CoverageDump = serde_json::from_value(raw).unwrap();
432 assert_eq!(dump.result.len(), 1);
433 assert_eq!(dump.result[0].functions[0].function_name, "a");
434 }
435
436 #[test]
437 fn parse_istanbul_coverage_with_null_columns() {
438 let raw = serde_json::json!({
439 "/t/linkUtils.ts": {
440 "path": "/t/linkUtils.ts",
441 "fnMap": {
442 "0": {
443 "name": "normalizeInternalLink",
444 "decl": {
445 "start": { "line": 66, "column": 0 },
446 "end": { "line": 66, "column": null }
447 },
448 "loc": {
449 "start": { "line": 66, "column": 0 },
450 "end": { "line": 76, "column": null }
451 },
452 "line": 66
453 }
454 },
455 "f": { "0": 9 }
456 }
457 });
458
459 let dump: std::collections::BTreeMap<String, IstanbulFileCoverage> =
460 serde_json::from_value(raw).unwrap();
461 let file = &dump["/t/linkUtils.ts"];
462 assert_eq!(file.fn_map["0"].decl.end.column, 0);
463 assert_eq!(file.fn_map["0"].loc.end.column, 0);
464 assert_eq!(file.f["0"], 9);
465 }
466}