Skip to main content

omni_dev/coverage/
llvm_json.rs

1//! llvm-cov JSON export parser (`cargo llvm-cov report --json`).
2//!
3//! The export records per-file *segments* rather than per-line counts. Each
4//! segment is `[line, col, count, has_count, is_region_entry, is_gap_region]`
5//! and marks a point where the active region count changes. Reducing segments
6//! to per-line hit counts reproduces llvm's own `LineCoverageStats` algorithm:
7//! for each source line, take the region active at the line's start (the
8//! "wrapped" segment) together with any region-entry segments on the line, and
9//! the line's count is the maximum among them. A line is instrumented ("mapped")
10//! when a counted region is active over it and it is not the start of a skipped
11//! region.
12
13use anyhow::{Context, Result};
14use serde_json::Value;
15
16use super::model::{CoverageReport, FileCoverage};
17
18/// One coverage segment from the llvm-cov JSON export.
19#[derive(Debug, Clone, Copy)]
20struct Segment {
21    line: u32,
22    count: u64,
23    has_count: bool,
24    is_region_entry: bool,
25    is_gap: bool,
26}
27
28/// Parses llvm-cov JSON export text into a [`CoverageReport`].
29pub fn parse(content: &str) -> Result<CoverageReport> {
30    let root: Value = serde_json::from_str(content).context("invalid llvm-cov JSON")?;
31    let data = root
32        .get("data")
33        .and_then(Value::as_array)
34        .context("llvm-cov JSON: missing `data` array")?;
35
36    let mut report = CoverageReport::new();
37    for export in data {
38        let Some(files) = export.get("files").and_then(Value::as_array) else {
39            continue;
40        };
41        for file in files {
42            let Some(filename) = file.get("filename").and_then(Value::as_str) else {
43                continue;
44            };
45            let segments = file
46                .get("segments")
47                .and_then(Value::as_array)
48                .map(|s| parse_segments(s))
49                .unwrap_or_default();
50            let coverage = reduce_segments(filename, &segments);
51            if !coverage.lines.is_empty() {
52                report.insert(coverage);
53            }
54        }
55    }
56    Ok(report)
57}
58
59/// Parses the raw segment arrays into [`Segment`] values, skipping malformed rows.
60fn parse_segments(raw: &[Value]) -> Vec<Segment> {
61    raw.iter()
62        .filter_map(|seg| {
63            let arr = seg.as_array()?;
64            Some(Segment {
65                line: arr.first()?.as_u64()? as u32,
66                count: count_of(arr.get(2)),
67                has_count: arr.get(3).and_then(Value::as_bool).unwrap_or(false),
68                is_region_entry: arr.get(4).and_then(Value::as_bool).unwrap_or(false),
69                is_gap: arr.get(5).and_then(Value::as_bool).unwrap_or(false),
70            })
71        })
72        .collect()
73}
74
75/// Reads a segment count, tolerating both integer and float JSON encodings.
76fn count_of(v: Option<&Value>) -> u64 {
77    match v {
78        Some(Value::Number(n)) => n
79            .as_u64()
80            .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
81            .unwrap_or(0),
82        _ => 0,
83    }
84}
85
86/// Reduces a file's segments to per-line hit counts.
87fn reduce_segments(filename: &str, segments: &[Segment]) -> FileCoverage {
88    let mut file = FileCoverage::new(filename);
89    if segments.is_empty() {
90        return file;
91    }
92
93    let max_line = segments.iter().map(|s| s.line).max().unwrap_or(0);
94    let min_line = segments.iter().map(|s| s.line).min().unwrap_or(0);
95
96    // The region active at the start of the current line (last segment on an
97    // earlier line). Updated to the last segment of each line as we advance.
98    let mut wrapped: Option<Segment> = None;
99    let mut seg_idx = 0usize;
100
101    for line in min_line..=max_line {
102        // Collect segments that fall on this line (segments are source-ordered).
103        let start = seg_idx;
104        while seg_idx < segments.len() && segments[seg_idx].line == line {
105            seg_idx += 1;
106        }
107        let line_segments = &segments[start..seg_idx];
108
109        if let Some(count) = line_stat(wrapped.as_ref(), line_segments) {
110            file.record(line, count);
111        }
112
113        if let Some(last) = line_segments.last() {
114            wrapped = Some(*last);
115        }
116    }
117
118    file
119}
120
121/// Computes the execution count for one line, or `None` when the line is not
122/// instrumented. Mirrors llvm's `LineCoverageStats`.
123fn line_stat(wrapped: Option<&Segment>, line_segments: &[Segment]) -> Option<u64> {
124    let is_start_of_region = |s: &Segment| !s.is_gap && s.has_count && s.is_region_entry;
125
126    let start_of_skipped = line_segments
127        .first()
128        .is_some_and(|s| !s.has_count && s.is_region_entry);
129
130    let min_region_count = line_segments
131        .iter()
132        .filter(|s| is_start_of_region(s))
133        .count();
134
135    let wrapped_has_count = wrapped.is_some_and(|w| w.has_count);
136    let mapped = !start_of_skipped && (wrapped_has_count || min_region_count > 0);
137    if !mapped {
138        return None;
139    }
140
141    let mut count = 0u64;
142    if let Some(w) = wrapped {
143        if w.has_count {
144            count = w.count;
145        }
146    }
147    for s in line_segments {
148        if is_start_of_region(s) {
149            count = count.max(s.count);
150        }
151    }
152    Some(count)
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used, clippy::expect_used)]
157mod tests {
158    use super::*;
159
160    fn export(files: Value) -> String {
161        serde_json::to_string(&serde_json::json!({
162            "data": [{ "files": files }],
163            "type": "llvm.coverage.json.export",
164            "version": "2.0.1"
165        }))
166        .unwrap()
167    }
168
169    #[test]
170    fn single_region_spans_multiple_lines() {
171        // Region entry at line 1 (count 3), exit at line 4 (count 0, end of fn).
172        let json = export(serde_json::json!([{
173            "filename": "src/a.rs",
174            "segments": [
175                [1, 1, 3, true, true, false],
176                [4, 2, 0, false, false, false]
177            ]
178        }]));
179        let report = parse(&json).unwrap();
180        // Lines 1..=3 are wrapped by the count-3 region → covered.
181        assert_eq!(report.hits("src/a.rs", 1), Some(3));
182        assert_eq!(report.hits("src/a.rs", 2), Some(3));
183        assert_eq!(report.hits("src/a.rs", 3), Some(3));
184    }
185
186    #[test]
187    fn uncovered_region() {
188        let json = export(serde_json::json!([{
189            "filename": "src/a.rs",
190            "segments": [
191                [10, 1, 0, true, true, false],
192                [12, 1, 0, false, false, false]
193            ]
194        }]));
195        let report = parse(&json).unwrap();
196        // Lines 10 and 11 are wrapped by the count-0 region; line 12 is the
197        // region-closing line (llvm reports it as instrumented, count 0).
198        assert_eq!(report.hits("src/a.rs", 10), Some(0));
199        assert_eq!(report.hits("src/a.rs", 11), Some(0));
200        assert_eq!(report.hits("src/a.rs", 12), Some(0));
201        assert_eq!(report.covered_lines(), 0);
202        assert_eq!(report.total_lines(), 3);
203    }
204
205    #[test]
206    fn gap_region_is_not_a_region_start() {
207        // A gap region (is_gap=true) should not by itself mark a line covered;
208        // it only contributes through the wrapped count.
209        let json = export(serde_json::json!([{
210            "filename": "src/a.rs",
211            "segments": [
212                [1, 1, 5, true, true, false],
213                [2, 1, 0, true, true, true],
214                [3, 1, 5, true, true, false],
215                [4, 1, 0, false, false, false]
216            ]
217        }]));
218        let report = parse(&json).unwrap();
219        assert_eq!(report.hits("src/a.rs", 1), Some(5));
220        assert_eq!(report.hits("src/a.rs", 3), Some(5));
221    }
222
223    #[test]
224    fn nested_region_takes_max() {
225        // Outer region count 2 from line 1; inner region count 7 starts on line 2.
226        let json = export(serde_json::json!([{
227            "filename": "src/a.rs",
228            "segments": [
229                [1, 1, 2, true, true, false],
230                [2, 5, 7, true, true, false],
231                [2, 20, 2, true, false, false],
232                [3, 1, 0, false, false, false]
233            ]
234        }]));
235        let report = parse(&json).unwrap();
236        assert_eq!(report.hits("src/a.rs", 1), Some(2));
237        // Line 2 has the inner region entry (7) → max(2, 7) = 7.
238        assert_eq!(report.hits("src/a.rs", 2), Some(7));
239    }
240
241    #[test]
242    fn empty_segments_yields_no_lines() {
243        let json = export(serde_json::json!([{ "filename": "src/a.rs", "segments": [] }]));
244        let report = parse(&json).unwrap();
245        assert!(report.files.is_empty());
246    }
247
248    #[test]
249    fn skips_files_without_filename_and_malformed_segments() {
250        let json = export(serde_json::json!([
251            { "segments": [[1, 1, 5, true, true, false]] }, // no filename → skipped
252            {
253                "filename": "src/a.rs",
254                "segments": [
255                    [],                               // malformed → skipped
256                    [1, 1, 5, true, true, false],
257                    [2, 1, "x", true, true, false],   // non-numeric count → treated as 0
258                    [3, 1, 0, false, false, false]
259                ]
260            }
261        ]));
262        let report = parse(&json).unwrap();
263        assert_eq!(report.files.len(), 1);
264        assert_eq!(report.hits("src/a.rs", 1), Some(5));
265        // Line 2's region entry has a non-numeric count (→ 0), but it is still
266        // wrapped by line 1's count-5 region, so the line's count is max(5, 0).
267        assert_eq!(report.hits("src/a.rs", 2), Some(5));
268    }
269
270    #[test]
271    fn invalid_json_errors() {
272        assert!(parse("{ not json").is_err());
273    }
274
275    #[test]
276    fn missing_data_array_errors() {
277        assert!(parse(r#"{"version":"2"}"#).is_err());
278    }
279}