use std::fmt::Write as _;
use anyhow::{bail, Context, Result};
use super::model::{CoverageReport, FileCoverage};
pub fn parse(content: &str) -> Result<CoverageReport> {
parse_impl(content, false)
}
pub fn parse_with_branches(content: &str) -> Result<CoverageReport> {
parse_impl(content, true)
}
fn parse_impl(content: &str, branch_coverage: bool) -> Result<CoverageReport> {
let mut report = CoverageReport::new();
let mut current: Option<FileCoverage> = None;
for (lineno, raw) in content.lines().enumerate() {
let mut line = raw.trim();
while let Some(rest) = line.strip_prefix("end_of_record") {
if let Some(file) = current.take() {
report.insert(file);
}
line = rest.trim_start();
}
if line.is_empty() {
continue;
}
if let Some(path) = line.strip_prefix("SF:") {
if let Some(unfinished) = current.replace(FileCoverage::new(path.trim())) {
report.insert(unfinished);
}
} else if let Some(rest) = line.strip_prefix("DA:") {
let file = current.as_mut().with_context(|| {
format!("lcov line {}: DA record outside of an SF block", lineno + 1)
})?;
let (number, hits) = parse_da(rest)
.with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
file.record(number, hits);
} else if branch_coverage && line.starts_with("BRDA:") {
let file = current
.as_mut()
.context("BRDA record outside of an SF block")?;
let parts: Vec<_> = line[5..].split(',').map(str::trim).collect();
anyhow::ensure!(
parts.len() == 4 && !parts[1].is_empty() && !parts[2].is_empty(),
"lcov line {}: malformed BRDA record",
lineno + 1
);
let number: u32 = parts[0].parse().context("invalid BRDA line number")?;
let covered = if parts[3] == "-" {
false
} else {
parts[3]
.parse::<u64>()
.context("invalid BRDA execution count")?
> 0
};
file.branches
.entry((number, parts[1].to_string(), parts[2].to_string()))
.and_modify(|v| *v |= covered)
.or_insert(covered);
}
}
if let Some(file) = current.take() {
report.insert(file);
}
Ok(report)
}
pub fn write(report: &CoverageReport) -> Result<String> {
let mut out = String::new();
for (path, file) in &report.files {
if path.contains(['\n', '\r']) || path.trim() != path {
bail!(
"cannot write {path:?} to lcov: a path with a line break or surrounding \
whitespace would read back as a different path"
);
}
let _ = writeln!(out, "TN:\nSF:{path}");
for (line, hits) in &file.lines {
let _ = writeln!(out, "DA:{line},{hits}");
}
let _ = writeln!(
out,
"LF:{}\nLH:{}\nend_of_record",
file.total_lines(),
file.covered_lines()
);
}
Ok(out)
}
fn parse_da(rest: &str) -> Result<(u32, u64)> {
let mut parts = rest.split(',');
let number: u32 = parts
.next()
.context("missing line number")?
.trim()
.parse()
.context("invalid line number")?;
let hits: u64 = parts
.next()
.context("missing hit count")?
.trim()
.parse()
.unwrap_or(0);
Ok((number, hits))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parses_single_file() {
let lcov = "\
TN:
SF:/repo/src/a.rs
DA:1,5
DA:2,0
DA:3,1
LF:3
LH:2
end_of_record
";
let report = parse(lcov).unwrap();
assert_eq!(report.files.len(), 1);
assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
assert_eq!(report.total_lines(), 3);
assert_eq!(report.covered_lines(), 2);
}
#[test]
fn parses_multiple_files() {
let lcov = "\
SF:src/a.rs
DA:1,1
end_of_record
SF:src/b.rs
DA:1,0
DA:2,2
end_of_record
";
let report = parse(lcov).unwrap();
assert_eq!(report.files.len(), 2);
assert_eq!(report.hits("src/a.rs", 1), Some(1));
assert_eq!(report.hits("src/b.rs", 2), Some(2));
}
#[test]
fn newline_joined_shards_parse_as_their_merge() {
let shard_a = "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record";
let shard_b = "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nDA:3,0\nend_of_record\n\
SF:src/b.rs\nDA:1,1\nend_of_record";
let mut merged = parse(shard_a).unwrap();
merged.merge(parse(shard_b).unwrap());
let concatenated = parse(&format!("{shard_a}\n{shard_b}")).unwrap();
assert_eq!(concatenated, merged);
assert_eq!(concatenated.hits("src/a.rs", 1), Some(3));
assert_eq!(concatenated.hits("src/a.rs", 2), Some(2));
assert_eq!(concatenated.hits("src/a.rs", 3), Some(0));
}
#[test]
fn a_glued_end_of_record_does_not_lose_the_next_file() {
let lcov = "SF:src/a.rs\nDA:1,1\nend_of_recordSF:src/b.rs\nDA:1,0\nDA:2,3\nend_of_record";
let report = parse(lcov).unwrap();
assert_eq!(report.files.len(), 2);
assert_eq!(report.hits("src/a.rs", 1), Some(1));
assert_eq!(report.hits("src/a.rs", 2), None);
assert_eq!(report.hits("src/b.rs", 1), Some(0));
assert_eq!(report.hits("src/b.rs", 2), Some(3));
}
#[test]
fn plainly_concatenated_shards_parse_as_their_merge() {
let shard_a =
"TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record";
let shard_b =
"TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/c.rs\nDA:1,1\nend_of_record";
let mut merged = parse(shard_a).unwrap();
merged.merge(parse(shard_b).unwrap());
let catted = parse(&format!("{shard_a}{shard_b}")).unwrap();
assert_eq!(catted, merged);
assert_eq!(catted.hits("src/c.rs", 1), Some(1));
}
#[test]
fn a_block_without_end_of_record_is_kept_when_another_file_follows() {
let lcov = "SF:src/a.rs\nDA:1,1\nSF:src/b.rs\nDA:1,0\nend_of_record\n";
let report = parse(lcov).unwrap();
assert_eq!(report.hits("src/a.rs", 1), Some(1));
assert_eq!(report.hits("src/b.rs", 1), Some(0));
}
#[test]
fn ignores_branch_and_function_records() {
let lcov = "\
SF:src/a.rs
FN:1,foo
FNDA:3,foo
DA:1,3
BRDA:1,0,0,1
end_of_record
";
let report = parse(lcov).unwrap();
let f = &report.files["src/a.rs"];
assert_eq!(f.lines.len(), 1);
assert_eq!(f.lines.get(&1), Some(&3));
}
#[test]
fn da_with_checksum() {
let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
let report = parse(lcov).unwrap();
assert_eq!(report.hits("src/a.rs", 1), Some(4));
}
#[test]
fn tolerates_missing_end_of_record() {
let lcov = "SF:src/a.rs\nDA:1,1\n";
let report = parse(lcov).unwrap();
assert_eq!(report.hits("src/a.rs", 1), Some(1));
}
#[test]
fn da_outside_sf_is_error() {
let lcov = "DA:1,1\n";
assert!(parse(lcov).is_err());
}
#[test]
fn skips_blank_lines() {
let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
let report = parse(lcov).unwrap();
assert_eq!(report.hits("a.rs", 1), Some(1));
}
#[test]
fn negative_hit_count_saturates_to_zero() {
let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
assert_eq!(report.hits("a.rs", 1), Some(0));
}
fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
let mut report = CoverageReport::new();
for (path, lines) in files {
let mut file = FileCoverage::new(*path);
for &(line, hits) in *lines {
file.record(line, hits);
}
report.insert(file);
}
report
}
#[test]
fn write_emits_one_sorted_record_per_file() {
let report = report(&[("src/b.rs", &[(2, 0), (1, 3)]), ("src/a.rs", &[(5, 1)])]);
assert_eq!(
write(&report).unwrap(),
"TN:\nSF:src/a.rs\nDA:5,1\nLF:1\nLH:1\nend_of_record\n\
TN:\nSF:src/b.rs\nDA:1,3\nDA:2,0\nLF:2\nLH:1\nend_of_record\n"
);
}
#[test]
fn write_ends_every_record_with_a_newline() {
let text = write(&report(&[("src/a.rs", &[(1, 1)])])).unwrap();
assert!(text.ends_with("end_of_record\n"), "{text:?}");
}
#[test]
fn write_of_an_empty_report_is_empty() {
assert_eq!(write(&CoverageReport::new()).unwrap(), "");
}
#[test]
fn write_keeps_a_file_with_no_executable_lines_as_an_empty_record() {
let report = report(&[("src/empty.rs", &[]), ("src/a.rs", &[(1, 0)])]);
let text = write(&report).unwrap();
assert!(
text.contains("SF:src/empty.rs\nLF:0\nLH:0\nend_of_record\n"),
"{text}"
);
assert_eq!(parse(&text).unwrap(), report);
}
#[test]
fn write_reads_back_as_the_same_report() {
let original = report(&[
("src/a.rs", &[(1, 0), (2, 7), (40, 1)]),
("/abs/b.rs", &[(3, 2)]),
("src/empty.rs", &[]),
]);
assert_eq!(parse(&write(&original).unwrap()).unwrap(), original);
}
#[test]
fn write_counts_lf_and_lh_from_its_own_da_records() {
let text = write(&report(&[("a.rs", &[(1, 0), (2, 5), (3, 9)])])).unwrap();
assert!(text.contains("\nLF:3\nLH:2\n"), "{text}");
}
#[test]
fn write_is_independent_of_the_order_shards_were_merged_in() {
let shards = [
"SF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record",
"SF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/b.rs\nDA:9,1\nend_of_record",
"SF:src/c.rs\nDA:1,4\nDA:7,0\nend_of_record",
];
let merged_in = |order: [usize; 3]| {
let mut merged = CoverageReport::new();
for index in order {
merged.merge(parse(shards[index]).unwrap());
}
write(&merged).unwrap()
};
let reference = merged_in([0, 1, 2]);
for order in [[0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
assert_eq!(merged_in(order), reference, "{order:?}");
}
}
#[test]
fn write_rejects_a_path_that_would_not_read_back() {
for path in [
"src/a\nSF:evil.rs",
"src/a\rb.rs",
" src/a.rs",
"src/a.rs\t",
] {
let message = write(&report(&[(path, &[(1, 1)])]))
.unwrap_err()
.to_string();
assert!(
message.contains("read back as a different path"),
"{message}"
);
}
}
#[test]
fn branch_mode_scores_outcomes_and_unions_duplicate_identities() {
let text = "SF:a.rs\nDA:1,3\nDA:2,1\nDA:3,1\nDA:4,1\nBRDA:1,0,0,2\nBRDA:1,0,1,0\nBRDA:2,0,0,-\nBRDA:3,0,0,0\nBRDA:3,0,0,1\nend_of_record";
let mut report = parse_with_branches(text).unwrap();
assert_eq!(report.hits("a.rs", 1), Some(3));
report.apply_branch_coverage();
assert_eq!(report.hits("a.rs", 1), Some(0));
assert_eq!(report.hits("a.rs", 2), Some(0));
assert_eq!(report.hits("a.rs", 3), Some(1));
assert_eq!(report.hits("a.rs", 4), Some(1));
assert_eq!(parse(text).unwrap().covered_lines(), 4);
}
#[test]
fn malformed_branches_fail_only_in_branch_mode() {
for record in ["BRDA:1,0,0", "BRDA:x,0,0,1", "BRDA:1,0,0,x", "BRDA:1,,0,1"] {
let text = format!("SF:a.rs\nDA:1,1\n{record}\nend_of_record");
assert!(parse_with_branches(&text).is_err(), "{record}");
assert!(parse(&text).is_ok());
}
assert!(parse_with_branches("BRDA:1,0,0,1").is_err());
}
}