use std::{
collections::{BTreeMap, HashMap},
fmt,
};
use anyhow::{Context as _, Result};
use camino::Utf8PathBuf;
use regex::Regex;
use serde::ser::{Serialize, SerializeMap as _, Serializer};
use serde_derive::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
pub struct LlvmCovJsonExport {
pub data: Vec<Export>,
#[serde(rename = "type")]
type_: String,
version: String,
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
cargo_llvm_cov: Option<CargoLlvmCov>,
}
#[derive(Debug, Default)]
struct CodeCovCoverage {
count: u64,
covered: u64,
}
impl Serialize for CodeCovCoverage {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("{}/{}", self.covered, self.count))
}
}
#[derive(Default)]
struct CodeCovExport(BTreeMap<u64, CodeCovCoverage>);
impl Serialize for CodeCovExport {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (key, value) in &self.0 {
map.serialize_entry(&key.to_string(), value)?;
}
map.end()
}
}
#[derive(Default, Serialize)]
pub struct CodeCovJsonExport {
coverage: BTreeMap<String, CodeCovExport>,
}
impl CodeCovJsonExport {
fn from_export(value: Export, ignore_filename_regex: Option<&Regex>) -> Self {
let functions = value.functions.unwrap_or_default();
let mut regions = HashMap::new();
for func in functions {
for filename in func.filenames {
if let Some(re) = ignore_filename_regex {
if re.is_match(&filename) {
continue;
}
}
let coverage: &mut HashMap<RegionLocation, bool> =
regions.entry(filename).or_default();
for region in &func.regions {
let loc = RegionLocation::from(region);
let covered = coverage.entry(loc).or_default();
*covered = *covered || region.execution_count() > 0;
}
}
}
let mut coverage = BTreeMap::new();
for (filename, regions) in regions {
let coverage: &mut CodeCovExport = coverage.entry(filename).or_default();
for (loc, covered) in regions {
for line in loc.lines() {
let coverage = coverage.0.entry(line).or_default();
coverage.count += 1;
coverage.covered += covered as u64;
}
}
}
Self { coverage }
}
#[must_use]
pub fn from_llvm_cov_json_export(
value: LlvmCovJsonExport,
ignore_filename_regex: Option<&str>,
) -> Self {
let re = ignore_filename_regex.map(|s| Regex::new(s).unwrap());
let exports: Vec<_> =
value.data.into_iter().map(|v| Self::from_export(v, re.as_ref())).collect();
let mut combined = CodeCovJsonExport::default();
for export in exports {
for (filename, coverage) in export.coverage {
let combined = combined.coverage.entry(filename).or_default();
for (line, coverage) in coverage.0 {
let combined = combined
.0
.entry(line)
.or_insert_with(|| CodeCovCoverage { count: 0, covered: 0 });
combined.count += coverage.count;
combined.covered += coverage.covered;
}
}
}
combined
}
}
type UncoveredLines = BTreeMap<String, Vec<u64>>;
#[non_exhaustive]
#[derive(Clone, Copy)]
#[cfg_attr(test, derive(Debug))]
pub enum CoverageKind {
Functions,
Lines,
Regions,
}
impl CoverageKind {
fn as_str(self) -> &'static str {
match self {
Self::Functions => "functions",
Self::Lines => "lines",
Self::Regions => "regions",
}
}
}
impl LlvmCovJsonExport {
pub fn demangle(&mut self) {
for data in &mut self.data {
if let Some(functions) = &mut data.functions {
for func in functions {
func.name = format!("{:#}", rustc_demangle::demangle(&func.name));
}
}
}
}
pub fn inject(&mut self, manifest_path: Utf8PathBuf) {
self.cargo_llvm_cov = Some(CargoLlvmCov {
version: env!("CARGO_PKG_VERSION"),
manifest_path: manifest_path.into_string(),
});
}
pub fn get_coverage_percent(&self, kind: CoverageKind) -> Result<f64> {
let mut count = 0_f64;
let mut covered = 0_f64;
for data in &self.data {
let totals = &data.totals.as_object().context("totals is not an object")?;
let lines =
&totals[kind.as_str()].as_object().context(format!("no {}", kind.as_str()))?;
count += lines["count"].as_f64().context("no count")?;
covered += lines["covered"].as_f64().context("no covered")?;
}
if count == 0_f64 {
return Ok(0_f64);
}
Ok(covered * 100_f64 / count)
}
#[must_use]
pub fn get_uncovered_lines(&self, ignore_filename_regex: Option<&str>) -> UncoveredLines {
let mut uncovered_files: UncoveredLines = BTreeMap::new();
let mut covered_files: UncoveredLines = BTreeMap::new();
let re = ignore_filename_regex.map(|s| Regex::new(s).unwrap());
for data in &self.data {
if let Some(ref functions) = data.functions {
for function in functions {
if function.filenames.is_empty() {
continue;
}
let file_name = &function.filenames[0];
if let Some(ref re) = re {
if re.is_match(file_name) {
continue;
}
}
let mut lines: BTreeMap<u64, u64> = BTreeMap::new();
for region in &function.regions {
let line_start = region.0;
let line_end = region.2;
let exec_count = region.4;
for line in line_start..=line_end {
*lines.entry(line).or_insert(0) += exec_count;
}
}
let mut uncovered_lines: Vec<u64> = lines
.iter()
.filter(|(_line, exec_count)| **exec_count == 0)
.map(|(line, _exec_count)| *line)
.collect();
let mut covered_lines: Vec<u64> = lines
.iter()
.filter(|(_line, exec_count)| **exec_count > 0)
.map(|(line, _exec_count)| *line)
.collect();
if !uncovered_lines.is_empty() {
uncovered_files
.entry(file_name.clone())
.or_default()
.append(&mut uncovered_lines);
}
if !covered_lines.is_empty() {
covered_files
.entry(file_name.clone())
.or_default()
.append(&mut covered_lines);
}
}
}
}
for uncovered_file in &mut uncovered_files {
let file_name = uncovered_file.0;
let uncovered_lines = uncovered_file.1;
if let Some(covered_lines) = covered_files.get(file_name) {
uncovered_lines.retain(|&x| !covered_lines.contains(&x));
}
uncovered_lines.sort_unstable();
uncovered_lines.dedup();
}
uncovered_files.retain(|_, v| !v.is_empty());
uncovered_files
}
pub fn count_uncovered_functions(&self) -> Result<u64> {
let mut count = 0_u64;
let mut covered = 0_u64;
for data in &self.data {
let totals = &data.totals.as_object().context("totals is not an object")?;
let functions = &totals["functions"].as_object().context("no functions")?;
count += functions["count"].as_u64().context("no count")?;
covered += functions["covered"].as_u64().context("no covered")?;
}
Ok(count.saturating_sub(covered))
}
pub fn count_uncovered_lines(&self) -> Result<u64> {
let mut count = 0_u64;
let mut covered = 0_u64;
for data in &self.data {
let totals = &data.totals.as_object().context("totals is not an object")?;
let lines = &totals["lines"].as_object().context("no lines")?;
count += lines["count"].as_u64().context("no count")?;
covered += lines["covered"].as_u64().context("no covered")?;
}
Ok(count.saturating_sub(covered))
}
pub fn count_uncovered_regions(&self) -> Result<u64> {
let mut count = 0_u64;
let mut covered = 0_u64;
for data in &self.data {
let totals = &data.totals.as_object().context("totals is not an object")?;
let regions = &totals["regions"].as_object().context("no regions")?;
count += regions["count"].as_u64().context("no count")?;
covered += regions["covered"].as_u64().context("no covered")?;
}
Ok(count.saturating_sub(covered))
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
pub struct Export {
pub files: Vec<File>,
#[serde(skip_serializing_if = "Option::is_none")]
functions: Option<Vec<Function>>,
totals: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
pub struct File {
#[serde(skip_serializing_if = "Option::is_none")]
branches: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
mcdc_records: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
expansions: Option<Vec<serde_json::Value>>,
pub filename: String,
#[serde(skip_serializing_if = "Option::is_none")]
segments: Option<Vec<Segment>>,
summary: Summary,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
struct Segment(
u64,
u64,
u64,
bool,
bool,
bool,
);
impl Segment {
fn line(&self) -> u64 {
self.0
}
fn col(&self) -> u64 {
self.1
}
fn count(&self) -> u64 {
self.2
}
fn has_count(&self) -> bool {
self.3
}
fn is_region_entry(&self) -> bool {
self.4
}
fn is_gap_region(&self) -> bool {
self.5
}
}
impl fmt::Debug for Segment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Segment")
.field("line", &self.line())
.field("col", &self.col())
.field("count", &self.count())
.field("has_count", &self.has_count())
.field("is_region_entry", &self.is_region_entry())
.field("is_gap_region", &self.is_gap_region())
.finish()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
struct Function {
branches: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
mcdc_records: Option<Vec<serde_json::Value>>,
count: u64,
filenames: Vec<String>,
name: String,
regions: Vec<Region>,
}
#[derive(Copy, Clone, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
struct Region(
u64,
u64,
u64,
u64,
u64,
u64,
u64,
u64,
);
impl Region {
fn line_start(&self) -> u64 {
self.0
}
fn column_start(&self) -> u64 {
self.1
}
fn line_end(&self) -> u64 {
self.2
}
fn column_end(&self) -> u64 {
self.3
}
fn execution_count(&self) -> u64 {
self.4
}
fn file_id(&self) -> u64 {
self.5
}
fn expanded_file_id(&self) -> u64 {
self.6
}
fn kind(&self) -> u64 {
self.7
}
}
impl fmt::Debug for Region {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Region")
.field("line_start", &self.line_start())
.field("column_start", &self.column_start())
.field("line_end", &self.line_end())
.field("column_end", &self.column_end())
.field("execution_count", &self.execution_count())
.field("file_id", &self.file_id())
.field("expanded_file_id", &self.expanded_file_id())
.field("kind", &self.kind())
.finish()
}
}
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
struct RegionLocation {
start_line: u64,
end_line: u64,
start_column: u64,
end_column: u64,
}
impl From<&Region> for RegionLocation {
fn from(region: &Region) -> Self {
Self {
start_line: region.line_start(),
end_line: region.line_end(),
start_column: region.column_start(),
end_column: region.column_end(),
}
}
}
impl RegionLocation {
fn lines(&self) -> impl Iterator<Item = u64> {
self.start_line..=self.end_line
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
struct Summary {
branches: CoverageCounts,
#[serde(skip_serializing_if = "Option::is_none")]
mcdc: Option<CoverageCounts>,
functions: CoverageCounts,
instantiations: CoverageCounts,
lines: CoverageCounts,
regions: CoverageCounts,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, serde(deny_unknown_fields))]
struct CoverageCounts {
count: u64,
covered: u64,
#[serde(skip_serializing_if = "Option::is_none")]
notcovered: Option<u64>,
percent: f64,
}
#[derive(Debug, Default, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
struct CargoLlvmCov {
version: &'static str,
manifest_path: String,
}
#[cfg(test)]
mod tests {
use std::path::Path;
use fs_err as fs;
use super::*;
#[test]
fn test_parse_llvm_cov_json() {
let files: Vec<_> = glob::glob(&format!(
"{}/tests/fixtures/coverage-reports/**/*.json",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
.filter_map(Result::ok)
.filter(|path| !path.to_str().unwrap().contains("codecov.json"))
.collect();
assert!(!files.is_empty());
for file in files {
let s = fs::read_to_string(file).unwrap();
let json = serde_json::from_str::<LlvmCovJsonExport>(&s).unwrap();
assert_eq!(json.type_, "llvm.coverage.json.export");
assert!(json.version.starts_with("2.0."));
assert_eq!(json.cargo_llvm_cov, None);
serde_json::to_string(&json).unwrap();
}
}
fn test_get_coverage_percent(kind: CoverageKind) {
let expected = match kind {
CoverageKind::Functions => 100_f64,
CoverageKind::Lines => 57.142_857_142_857_146,
CoverageKind::Regions => 54.545_454_545_454_55,
};
let file = format!(
"{}/tests/fixtures/coverage-reports/no_coverage/no_coverage.json",
env!("CARGO_MANIFEST_DIR")
);
let s = fs::read_to_string(file).unwrap();
let json = serde_json::from_str::<LlvmCovJsonExport>(&s).unwrap();
let actual = json.get_coverage_percent(kind).unwrap();
assert_eq!(actual, expected, "kind={kind:?},actual={actual}");
}
#[test]
fn test_get_functions_percent() {
test_get_coverage_percent(CoverageKind::Functions);
}
#[test]
fn test_get_lines_percent() {
test_get_coverage_percent(CoverageKind::Lines);
}
#[test]
fn test_get_regions_percent() {
test_get_coverage_percent(CoverageKind::Regions);
}
#[test]
fn test_count_uncovered() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let cases = &[
("tests/fixtures/coverage-reports/no_coverage/no_coverage.json", 0, 6, 5),
("tests/fixtures/coverage-reports/no_test/no_test.json", 1, 7, 6),
];
for &(file, uncovered_functions, uncovered_lines, uncovered_regions) in cases {
let file = manifest_dir.join(file);
let s = fs::read_to_string(file).unwrap();
let json = serde_json::from_str::<LlvmCovJsonExport>(&s).unwrap();
assert_eq!(json.count_uncovered_functions().unwrap(), uncovered_functions);
assert_eq!(json.count_uncovered_lines().unwrap(), uncovered_lines);
assert_eq!(json.count_uncovered_regions().unwrap(), uncovered_regions);
}
}
#[test]
fn test_get_uncovered_lines() {
let file = format!("{}/tests/fixtures/show-missing-lines.json", env!("CARGO_MANIFEST_DIR"));
let s = fs::read_to_string(file).unwrap();
let json = serde_json::from_str::<LlvmCovJsonExport>(&s).unwrap();
let ignore_filename_regex = None;
let uncovered_lines = json.get_uncovered_lines(ignore_filename_regex);
let expected: UncoveredLines =
vec![("src/lib.rs".to_owned(), vec![7, 8, 9])].into_iter().collect();
assert_eq!(uncovered_lines, expected);
}
#[test]
fn test_get_uncovered_lines_complete() {
let file = format!(
"{}/tests/fixtures/show-missing-lines-complete.json",
env!("CARGO_MANIFEST_DIR")
);
let s = fs::read_to_string(file).unwrap();
let json = serde_json::from_str::<LlvmCovJsonExport>(&s).unwrap();
let ignore_filename_regex = None;
let uncovered_lines = json.get_uncovered_lines(ignore_filename_regex);
let expected: UncoveredLines = UncoveredLines::new();
assert_eq!(uncovered_lines, expected);
}
#[test]
fn test_get_uncovered_lines_multi_missing() {
let file = format!(
"{}/tests/fixtures/show-missing-lines-multi-missing.json",
env!("CARGO_MANIFEST_DIR")
);
let s = fs::read_to_string(file).unwrap();
let json = serde_json::from_str::<LlvmCovJsonExport>(&s).unwrap();
let ignore_filename_regex = None;
let uncovered_lines = json.get_uncovered_lines(ignore_filename_regex);
let expected: UncoveredLines =
vec![("src/lib.rs".to_owned(), vec![15, 17])].into_iter().collect();
assert_eq!(uncovered_lines, expected);
}
}