Skip to content

Commit b646e7b

Browse files
brynaryclaude
andauthored
feat(coverage): add xccov-json coverage format parser (#2568)
## Summary - Add new `XccovJson` parser for Apple's xccov JSON format - Support coverage reports produced by `xcrun xccov view --json path/to/file.xccovarchive` - Add `xccov-json` to the `Formats` enum with Display, FromStr, and parser_for implementations ## Details The xccov JSON format has a root object mapping file paths to arrays of line coverage objects: ```json { "/path/to/File.swift": [ { "isExecutable": false, "line": 1 }, { "isExecutable": true, "line": 2, "executionCount": 5 } ] } ``` The parser maps: - `isExecutable: false` -> hits = -1 (non-executable line) - `isExecutable: true` -> hits = executionCount Usage: `qlty coverage publish --report-format=xccov-json coverage.json` ## Test plan - [x] Unit tests for basic file parsing - [x] Unit tests for multiple files - [x] Unit tests for empty reports - [x] Unit tests for all non-executable lines - [x] Unit tests for sparse line numbers - [x] Unit tests for zero execution count - [x] Unit tests for subranges (ignored, using line-level count) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fc1748b commit b646e7b

4 files changed

Lines changed: 232 additions & 0 deletions

File tree

docs/licenses/xccov2lcov.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
https://github.com/StanfordBDHG/xccov2lcov
2+
3+
MIT License
4+
5+
Copyright (c) 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
8+
9+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
10+
11+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

qlty-coverage/src/formats.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub enum Formats {
1717
Lcov,
1818
Jacoco,
1919
Qlty,
20+
XccovJson,
2021
}
2122

2223
impl std::fmt::Display for Formats {
@@ -30,6 +31,7 @@ impl std::fmt::Display for Formats {
3031
Formats::Lcov => write!(f, "lcov"),
3132
Formats::Jacoco => write!(f, "jacoco"),
3233
Formats::Qlty => write!(f, "qlty"),
34+
Formats::XccovJson => write!(f, "xccov-json"),
3335
}
3436
}
3537
}
@@ -81,6 +83,7 @@ impl FromStr for Formats {
8183
"lcov" => Ok(Formats::Lcov),
8284
"jacoco" => Ok(Formats::Jacoco),
8385
"qlty" => Ok(Formats::Qlty),
86+
"xccov-json" => Ok(Formats::XccovJson),
8487
_ => bail!("Unsupported coverage report format: {}", s),
8588
}
8689
}
@@ -96,6 +99,7 @@ pub fn parser_for(&format: &Formats) -> Box<dyn Parser> {
9699
Formats::Lcov => Box::new(parser::Lcov::new()),
97100
Formats::Jacoco => Box::new(parser::Jacoco::new()),
98101
Formats::Qlty => Box::new(parser::Qlty::new()),
102+
Formats::XccovJson => Box::new(parser::XccovJson::new()),
99103
}
100104
}
101105

qlty-coverage/src/parser.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod jacoco;
1010
mod lcov;
1111
mod qlty;
1212
mod simplecov;
13+
mod xccov;
1314

1415
pub use clover::Clover;
1516
pub use cobertura::Cobertura;
@@ -19,6 +20,7 @@ pub use jacoco::Jacoco;
1920
pub use lcov::Lcov;
2021
pub use qlty::Qlty;
2122
pub use simplecov::Simplecov;
23+
pub use xccov::XccovJson;
2224

2325
pub trait Parser {
2426
fn parse_file(&self, path: &Path) -> Result<Vec<FileCoverage>> {

qlty-coverage/src/parser/xccov.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
use crate::Parser;
2+
use anyhow::{Context, Result};
3+
use qlty_types::tests::v1::FileCoverage;
4+
use serde::Deserialize;
5+
use std::collections::HashMap;
6+
7+
#[derive(Debug, Deserialize)]
8+
struct XccovLine {
9+
line: i64,
10+
#[serde(rename = "isExecutable")]
11+
is_executable: bool,
12+
#[serde(rename = "executionCount")]
13+
execution_count: Option<i64>,
14+
}
15+
16+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
17+
pub struct XccovJson {}
18+
19+
impl XccovJson {
20+
pub fn new() -> Self {
21+
Self {}
22+
}
23+
}
24+
25+
impl Parser for XccovJson {
26+
fn parse_text(&self, text: &str) -> Result<Vec<FileCoverage>> {
27+
let report: HashMap<String, Vec<XccovLine>> =
28+
serde_json::from_str(text).with_context(|| "Failed to parse XCCov JSON")?;
29+
30+
let mut file_coverages: Vec<FileCoverage> = Vec::new();
31+
let mut sorted_paths: Vec<_> = report.keys().collect();
32+
sorted_paths.sort();
33+
34+
for path in sorted_paths {
35+
let lines = &report[path];
36+
let max_line = lines.iter().map(|l| l.line).max().unwrap_or(0);
37+
let mut hits: Vec<i64> = vec![-1; max_line as usize];
38+
39+
for line_data in lines {
40+
if line_data.line > 0 {
41+
let index = (line_data.line - 1) as usize;
42+
if index < hits.len() {
43+
hits[index] = if line_data.is_executable {
44+
line_data.execution_count.unwrap_or(0)
45+
} else {
46+
-1
47+
};
48+
}
49+
}
50+
}
51+
52+
file_coverages.push(FileCoverage {
53+
path: path.clone(),
54+
hits,
55+
..Default::default()
56+
});
57+
}
58+
59+
Ok(file_coverages)
60+
}
61+
}
62+
63+
#[cfg(test)]
64+
mod test {
65+
use super::*;
66+
67+
#[test]
68+
fn basic_file() {
69+
let input = r#"
70+
{
71+
"/path/to/File.swift": [
72+
{ "isExecutable": false, "line": 1 },
73+
{ "isExecutable": false, "line": 2 },
74+
{ "isExecutable": true, "line": 3, "executionCount": 5 },
75+
{ "isExecutable": true, "line": 4, "executionCount": 0 },
76+
{ "isExecutable": false, "line": 5 }
77+
]
78+
}
79+
"#;
80+
81+
let results = XccovJson::new().parse_text(input).unwrap();
82+
insta::assert_yaml_snapshot!(results, @r#"
83+
- path: /path/to/File.swift
84+
hits:
85+
- "-1"
86+
- "-1"
87+
- "5"
88+
- "0"
89+
- "-1"
90+
"#);
91+
}
92+
93+
#[test]
94+
fn multiple_files() {
95+
let input = r#"
96+
{
97+
"/path/to/First.swift": [
98+
{ "isExecutable": true, "line": 1, "executionCount": 1 },
99+
{ "isExecutable": true, "line": 2, "executionCount": 2 }
100+
],
101+
"/path/to/Second.swift": [
102+
{ "isExecutable": true, "line": 1, "executionCount": 3 },
103+
{ "isExecutable": false, "line": 2 }
104+
]
105+
}
106+
"#;
107+
108+
let results = XccovJson::new().parse_text(input).unwrap();
109+
insta::assert_yaml_snapshot!(results, @r#"
110+
- path: /path/to/First.swift
111+
hits:
112+
- "1"
113+
- "2"
114+
- path: /path/to/Second.swift
115+
hits:
116+
- "3"
117+
- "-1"
118+
"#);
119+
}
120+
121+
#[test]
122+
fn empty_report() {
123+
let input = r#"{}"#;
124+
let results = XccovJson::new().parse_text(input).unwrap();
125+
assert!(results.is_empty());
126+
}
127+
128+
#[test]
129+
fn all_non_executable() {
130+
let input = r#"
131+
{
132+
"/path/to/File.swift": [
133+
{ "isExecutable": false, "line": 1 },
134+
{ "isExecutable": false, "line": 2 },
135+
{ "isExecutable": false, "line": 3 }
136+
]
137+
}
138+
"#;
139+
140+
let results = XccovJson::new().parse_text(input).unwrap();
141+
insta::assert_yaml_snapshot!(results, @r#"
142+
- path: /path/to/File.swift
143+
hits:
144+
- "-1"
145+
- "-1"
146+
- "-1"
147+
"#);
148+
}
149+
150+
#[test]
151+
fn sparse_line_numbers() {
152+
let input = r#"
153+
{
154+
"/path/to/File.swift": [
155+
{ "isExecutable": true, "line": 5, "executionCount": 1 },
156+
{ "isExecutable": true, "line": 10, "executionCount": 2 }
157+
]
158+
}
159+
"#;
160+
161+
let results = XccovJson::new().parse_text(input).unwrap();
162+
insta::assert_yaml_snapshot!(results, @r#"
163+
- path: /path/to/File.swift
164+
hits:
165+
- "-1"
166+
- "-1"
167+
- "-1"
168+
- "-1"
169+
- "1"
170+
- "-1"
171+
- "-1"
172+
- "-1"
173+
- "-1"
174+
- "2"
175+
"#);
176+
}
177+
178+
#[test]
179+
fn zero_execution_count() {
180+
let input = r#"
181+
{
182+
"/path/to/File.swift": [
183+
{ "isExecutable": true, "line": 1, "executionCount": 0 },
184+
{ "isExecutable": true, "line": 2, "executionCount": 0 }
185+
]
186+
}
187+
"#;
188+
189+
let results = XccovJson::new().parse_text(input).unwrap();
190+
insta::assert_yaml_snapshot!(results, @r#"
191+
- path: /path/to/File.swift
192+
hits:
193+
- "0"
194+
- "0"
195+
"#);
196+
}
197+
198+
#[test]
199+
fn with_subranges() {
200+
let input = r#"
201+
{
202+
"/path/to/File.swift": [
203+
{ "isExecutable": true, "line": 1, "executionCount": 4, "subranges": [{"column": 36, "executionCount": 2, "length": 0}] }
204+
]
205+
}
206+
"#;
207+
208+
let results = XccovJson::new().parse_text(input).unwrap();
209+
insta::assert_yaml_snapshot!(results, @r#"
210+
- path: /path/to/File.swift
211+
hits:
212+
- "4"
213+
"#);
214+
}
215+
}

0 commit comments

Comments
 (0)