Skip to main content

oxc_coverage_v8/
lib.rs

1//! V8 inspector coverage to Istanbul `FileCoverage` conversion.
2//!
3//! V8's inspector protocol reports coverage as `[startOffset, endOffset, count]`
4//! ranges grouped by function. Istanbul reporters consume per-statement,
5//! per-function, and per-branch hit counts keyed by (line, column). This crate
6//! takes a pre-built `FileCoverage` (typically produced by an AST-traversal
7//! pass in an instrumenter) and fills in its hit-count vectors from V8 ranges.
8//!
9//! ## Position semantics
10//!
11//! Istanbul's `Position` is 1-based line + 0-based UTF-16 column. V8 ranges are
12//! absolute UTF-16 code-unit offsets into the V8-visible source. Oxc branch
13//! body spans use UTF-8 byte offsets, so this crate converts source-side
14//! coordinates to absolute UTF-16 offsets before intersecting V8 ranges.
15//!
16//! ## Wrapper base
17//!
18//! Node wraps every CommonJS module in `(function(exports,require,module,...){`.
19//! Some coverage producers report offsets relative to a wrapped source. Pass
20//! their wrapper prefix length in UTF-16 code units so source-side coordinates
21//! use the same base. Node inspector coverage is normally source-relative, so
22//! its wrapper length is zero.
23//!
24//! ## Companion helpers for inline / external source maps
25//!
26//! V8 coverage data does not carry source-map information; the source itself
27//! does, via a `//# sourceMappingURL=` trailer. [`extract_inline_source_map`]
28//! decodes the inline `data:application/json` form and
29//! [`extract_external_source_mapping_url`] reports the trailer URL when it is
30//! not a data URL. Callers can attach the result as `FileCoverage.input_source_map`
31//! and chain through `oxc_coverage_source_maps::remap_coverage` to resolve
32//! positions back to the original source.
33
34mod source_index;
35mod source_map_url;
36
37use std::collections::BTreeMap;
38
39use oxc_coverage_types::{FileCoverage, Location};
40use serde::{Deserialize, Serialize};
41
42use crate::source_index::SourceIndex;
43
44pub use crate::source_map_url::{extract_external_source_mapping_url, extract_inline_source_map};
45
46/// A function's coverage data as reported by the V8 inspector.
47///
48/// Serializes to / from the same JSON shape as the Chrome DevTools Protocol's
49/// [`Profiler.FunctionCoverage`](https://chromedevtools.github.io/devtools-protocol/tot/Profiler/#type-FunctionCoverage)
50/// so callers can hand the V8 inspector's output straight through.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct V8FunctionCoverage {
53    /// Function name as reported by V8 (may be empty for anonymous functions
54    /// or the implicit top-level module function).
55    #[serde(rename = "functionName")]
56    pub function_name: String,
57    /// One or more UTF-16 code-unit ranges. With `is_block_coverage = false` there is
58    /// exactly one range (the whole function); with `is_block_coverage = true`
59    /// the outermost range covers the function and inner ranges cover blocks.
60    pub ranges: Vec<V8CoverageRange>,
61    /// When true, `ranges` includes block-level subdivisions. When false, the
62    /// only count is at function granularity.
63    #[serde(rename = "isBlockCoverage")]
64    pub is_block_coverage: bool,
65}
66
67/// A single V8 coverage range.
68#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
69pub struct V8CoverageRange {
70    /// UTF-16 code-unit offset of the range start (inclusive).
71    #[serde(rename = "startOffset")]
72    pub start_offset: u32,
73    /// UTF-16 code-unit offset of the range end (exclusive).
74    #[serde(rename = "endOffset")]
75    pub end_offset: u32,
76    /// Hit count. Zero means the range was reachable but never executed.
77    pub count: u32,
78}
79
80#[derive(Clone, Copy)]
81struct CoverageRangeWithMode {
82    range: V8CoverageRange,
83    is_block_coverage: bool,
84}
85
86struct CoverageContext<'a> {
87    source_index: &'a SourceIndex,
88    ranges: &'a [V8CoverageRange],
89    arm_ranges: &'a [V8CoverageRange],
90    inheritance_ranges: &'a [CoverageRangeWithMode],
91    wrapper_length_utf16: u32,
92}
93
94impl CoverageContext<'_> {
95    fn count_for_location(&self, loc: &Location) -> u32 {
96        let (start, end) = self.location_utf16_span(loc);
97        smallest_containing_range_count(start, end, self.ranges)
98    }
99
100    /// Resolve one branch arm's hit count.
101    ///
102    /// An expression arm needs a tight V8 block range because an enclosing
103    /// count cannot distinguish it from its siblings. A concrete `if` body
104    /// inherits the smallest enclosing range instead, because V8 omits a child
105    /// range whose count equals its parent's.
106    fn arm_count_for_arm(
107        &self,
108        arm_loc: &Location,
109        body_byte_span: Option<(u32, u32)>,
110        inherit_enclosing_count: bool,
111    ) -> u32 {
112        if body_byte_span.is_some_and(|(start, end)| start == end) {
113            return 0;
114        }
115        let (arm_start, arm_end) = self.arm_utf16_span(arm_loc, body_byte_span);
116        let (location_start, location_end) = self.location_utf16_span(arm_loc);
117        self.best_arm_range_count(arm_start, arm_end)
118            .or_else(|| self.best_arm_range_count(location_start, location_end))
119            .unwrap_or_else(|| {
120                let has_concrete_body = body_byte_span.is_some_and(|(start, end)| start < end);
121                if inherit_enclosing_count && has_concrete_body {
122                    self.smallest_inheritable_range_count(arm_start, arm_end)
123                } else {
124                    0
125                }
126            })
127    }
128
129    fn smallest_inheritable_range_count(&self, start: u32, end: u32) -> u32 {
130        self.inheritance_ranges
131            .iter()
132            .find(|entry| {
133                entry.range.start_offset <= start
134                    && entry.range.end_offset >= end
135                    && (entry.range.start_offset < start || entry.range.end_offset > end)
136            })
137            .filter(|entry| entry.is_block_coverage)
138            .map_or(0, |entry| entry.range.count)
139    }
140
141    fn arm_utf16_span(&self, arm_loc: &Location, body_byte_span: Option<(u32, u32)>) -> (u32, u32) {
142        match body_byte_span {
143            Some((start, end)) => (
144                self.source_index.byte_to_utf16(start).saturating_add(self.wrapper_length_utf16),
145                self.source_index.byte_to_utf16(end).saturating_add(self.wrapper_length_utf16),
146            ),
147            None => self.location_utf16_span(arm_loc),
148        }
149    }
150
151    fn location_utf16_span(&self, loc: &Location) -> (u32, u32) {
152        (
153            self.source_index
154                .position_to_utf16(loc.start.line, loc.start.column)
155                .saturating_add(self.wrapper_length_utf16),
156            self.source_index
157                .position_to_utf16(loc.end.line, loc.end.column)
158                .saturating_add(self.wrapper_length_utf16),
159        )
160    }
161
162    /// The count of the nearest V8 range whose start and end both sit within
163    /// `TOLERANCE` code units of `[arm_start, arm_end)`, breaking ties towards
164    /// the narrower range.
165    fn best_arm_range_count(&self, arm_start: u32, arm_end: u32) -> Option<u32> {
166        const TOLERANCE: u32 = 4;
167
168        let mut best: Option<(V8CoverageRange, u32)> = None;
169        let lower = arm_start.saturating_sub(TOLERANCE);
170        let upper = arm_start.saturating_add(TOLERANCE);
171        let start = self.arm_ranges.partition_point(|r| r.start_offset < lower);
172        for r in &self.arm_ranges[start..] {
173            if r.start_offset > upper {
174                break;
175            }
176            let dist_start = r.start_offset.abs_diff(arm_start);
177            let dist_end = r.end_offset.abs_diff(arm_end);
178            if dist_end > TOLERANCE {
179                continue;
180            }
181            let distance = dist_start + dist_end;
182            match best {
183                None => best = Some((*r, distance)),
184                Some((prev, prev_distance)) => {
185                    let prev_width = prev.end_offset.saturating_sub(prev.start_offset);
186                    let this_width = r.end_offset.saturating_sub(r.start_offset);
187                    if distance < prev_distance
188                        || (distance == prev_distance && this_width < prev_width)
189                    {
190                        best = Some((*r, distance));
191                    }
192                }
193            }
194        }
195        best.map(|(r, _)| r.count)
196    }
197}
198
199/// Fill in the statement, function and branch hit counts of a pre-built
200/// [`FileCoverage`] from V8's coverage ranges.
201///
202/// The caller builds `file_coverage`, typically from an instrumenter's
203/// AST-traversal pass, and supplies `arm_body_byte_spans`:
204/// `arm_body_byte_spans["<branch_id>"][<arm_idx>]` is the `(start, end)` UTF-8
205/// byte range of that arm's body. A `(0, 0)` entry marks a synthetic body (a
206/// synthesized else-arm, for instance) and resolves to a count of 0; a
207/// non-empty body span is matched against V8's block ranges before falling back
208/// to the arm's [`Location`].
209///
210/// `wrapper_length_utf16` is the UTF-16 code-unit base for producers that report
211/// wrapper-shifted ranges. Pass 0 for source-relative inspector output.
212pub fn apply_v8_coverage(
213    file_coverage: &mut FileCoverage,
214    source: &str,
215    functions: &[V8FunctionCoverage],
216    wrapper_length_utf16: u32,
217    arm_body_byte_spans: &BTreeMap<String, Vec<(u32, u32)>>,
218) {
219    let source_index = SourceIndex::new(source);
220    let mut ranges: Vec<V8CoverageRange> =
221        functions.iter().flat_map(|f| f.ranges.iter().copied()).collect();
222    ranges.sort_by_key(|r| r.end_offset.saturating_sub(r.start_offset));
223    // The first range in every FunctionCoverage record is that function's outer
224    // range, not a branch range. A nested function declaration can sit inside an
225    // if arm, so only child ranges from block-coverage records are eligible for
226    // tight arm matching.
227    let mut arm_ranges: Vec<V8CoverageRange> = functions
228        .iter()
229        .filter(|function| function.is_block_coverage)
230        .flat_map(|function| function.ranges.iter().skip(1).copied())
231        .collect();
232    arm_ranges.sort_by_key(|r| r.start_offset);
233    let mut inheritance_ranges: Vec<CoverageRangeWithMode> = functions
234        .iter()
235        .flat_map(|function| {
236            function.ranges.iter().copied().map(|range| CoverageRangeWithMode {
237                range,
238                is_block_coverage: function.is_block_coverage,
239            })
240        })
241        .collect();
242    inheritance_ranges
243        .sort_by_key(|entry| entry.range.end_offset.saturating_sub(entry.range.start_offset));
244    let context = CoverageContext {
245        source_index: &source_index,
246        ranges: &ranges,
247        arm_ranges: &arm_ranges,
248        inheritance_ranges: &inheritance_ranges,
249        wrapper_length_utf16,
250    };
251
252    apply_statement_counts(file_coverage, &context);
253    apply_function_counts(file_coverage, &context);
254    apply_branch_counts(file_coverage, arm_body_byte_spans, &context);
255}
256
257fn apply_statement_counts(file_coverage: &mut FileCoverage, context: &CoverageContext<'_>) {
258    for (id, loc) in &file_coverage.statement_map {
259        let count = context.count_for_location(loc);
260        if let Some(slot) = file_coverage.s.get_mut(id) {
261            *slot = count;
262        }
263    }
264}
265
266fn apply_function_counts(file_coverage: &mut FileCoverage, context: &CoverageContext<'_>) {
267    for (id, fn_entry) in &file_coverage.fn_map {
268        let count = context.count_for_location(&fn_entry.loc);
269        if let Some(slot) = file_coverage.f.get_mut(id) {
270            *slot = count;
271        }
272    }
273}
274
275fn apply_branch_counts(
276    file_coverage: &mut FileCoverage,
277    arm_body_byte_spans: &BTreeMap<String, Vec<(u32, u32)>>,
278    context: &CoverageContext<'_>,
279) {
280    for (id, branch_entry) in &file_coverage.branch_map {
281        let Some(slot) = file_coverage.b.get_mut(id) else {
282            continue;
283        };
284        let body_spans = arm_body_byte_spans.get(id);
285        let inherit_enclosing_count = branch_entry.branch_type == "if";
286        slot.clear();
287        slot.reserve(branch_entry.locations.len());
288        for (arm_idx, loc) in branch_entry.locations.iter().enumerate() {
289            slot.push(context.arm_count_for_arm(
290                loc,
291                body_spans.and_then(|spans| spans.get(arm_idx).copied()),
292                inherit_enclosing_count,
293            ));
294        }
295    }
296}
297
298/// Pick the count of the smallest V8 range that fully contains `[start, end)`.
299/// Smaller ranges represent inner blocks (with their own counts under
300/// `isBlockCoverage`) and override the outer function-level count.
301///
302/// Both V8 ranges and the statement UTF-16 span use the half-open convention
303/// (`endOffset` / `end` are exclusive). The containment predicate is therefore
304/// `r.start <= start && r.end >= end`: a range whose exclusive end is equal
305/// to the statement's exclusive end is the smallest possible exact container.
306fn smallest_containing_range_count(start: u32, end: u32, ranges: &[V8CoverageRange]) -> u32 {
307    for r in ranges {
308        if r.start_offset <= start && r.end_offset >= end {
309            return r.count;
310        }
311    }
312    0
313}