use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use super::{CoverageParser, Format};
use crate::model::*;
pub struct LcovParser;
impl CoverageParser for LcovParser {
fn format(&self) -> Format {
Format::Lcov
}
fn can_parse(&self, path: &Path, content: &[u8]) -> bool {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext = ext.to_lowercase();
if ext == "info" || ext == "lcov" {
return true;
}
}
let head_len = content.len().min(4096);
let head = String::from_utf8_lossy(&content[..head_len]);
let has_sf = head.lines().any(|l| l.starts_with("SF:"));
let has_da_or_fn = head
.lines()
.any(|l| l.starts_with("DA:") || l.starts_with("FN:"));
has_sf && has_da_or_fn
}
fn parse(&self, input: &[u8]) -> Result<CoverageData> {
parse(input)
}
}
pub fn parse(input: &[u8]) -> Result<CoverageData> {
let text = std::str::from_utf8(input).context("Invalid UTF-8 in LCOV data")?;
parse_inner(text)
}
fn parse_inner(text: &str) -> Result<CoverageData> {
let mut data = CoverageData::new();
let mut current_file: Option<FileCoverage> = None;
let mut branch_indices: HashMap<u32, u32> = HashMap::new();
let mut fn_defs: HashMap<String, Option<u32>> = HashMap::new();
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if line == "end_of_record" {
if let Some(file) = current_file.take() {
data.files.push(file);
}
branch_indices.clear();
fn_defs.clear();
continue;
}
let (tag, value) = match line.split_once(':') {
Some(pair) => pair,
None => continue, };
match tag {
"TN" => {
}
"SF" => {
current_file = Some(FileCoverage::new(value.to_string()));
branch_indices.clear();
fn_defs.clear();
}
"FN" => {
if let Some((line_str, name)) = value.split_once(',') {
if let Ok(start_line) = line_str.parse::<u32>() {
fn_defs.insert(name.to_string(), Some(start_line));
}
}
}
"FNDA" => {
if let Some(file) = current_file.as_mut() {
if let Some((count_str, name)) = value.split_once(',') {
let hit_count = count_str.parse::<u64>().unwrap_or(0);
let start_line = fn_defs.get(name).copied().flatten();
file.functions.push(FunctionCoverage {
name: name.to_string(),
start_line,
end_line: None,
hit_count,
});
}
}
}
"DA" => {
if let Some(file) = current_file.as_mut() {
let parts: Vec<&str> = value.splitn(3, ',').collect();
if parts.len() >= 2 {
if let Ok(line_number) = parts[0].parse::<u32>() {
match parts[1].parse::<i64>() {
Ok(count) if count >= 0 => {
file.lines.push(LineCoverage {
line_number,
hit_count: count as u64,
});
}
_ => {
}
}
}
}
}
}
"BRDA" => {
if let Some(file) = current_file.as_mut() {
let parts: Vec<&str> = value.splitn(4, ',').collect();
if parts.len() == 4 {
if let Ok(line_number) = parts[0].parse::<u32>() {
let hit_count = if parts[3] == "-" {
0
} else {
parts[3].parse::<u64>().unwrap_or(0)
};
let idx = branch_indices.entry(line_number).or_insert(0);
file.branches.push(BranchCoverage {
line_number,
branch_index: *idx,
hit_count,
});
*idx += 1;
}
}
}
}
_ => {}
}
}
if let Some(file) = current_file.take() {
data.files.push(file);
}
Ok(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_lcov() {
let input = include_bytes!("../../tests/fixtures/sample.lcov");
let data = parse(input).unwrap();
assert_eq!(data.files.len(), 2);
let lib = &data.files[0];
assert_eq!(lib.path, "/src/lib.rs");
assert_eq!(lib.lines.len(), 5);
assert_eq!(lib.lines[0].line_number, 1);
assert_eq!(lib.lines[0].hit_count, 5);
assert_eq!(lib.lines[2].line_number, 3);
assert_eq!(lib.lines[2].hit_count, 0);
assert_eq!(lib.branches.len(), 2);
assert_eq!(lib.branches[0].line_number, 2);
assert_eq!(lib.branches[0].branch_index, 0);
assert_eq!(lib.branches[0].hit_count, 5);
assert_eq!(lib.branches[1].branch_index, 1);
assert_eq!(lib.branches[1].hit_count, 0);
assert_eq!(lib.functions.len(), 2);
assert_eq!(lib.functions[0].name, "main");
assert_eq!(lib.functions[0].hit_count, 5);
assert_eq!(lib.functions[0].start_line, Some(1));
assert_eq!(lib.functions[1].name, "helper");
assert_eq!(lib.functions[1].hit_count, 0);
let util = &data.files[1];
assert_eq!(util.path, "/src/util.rs");
assert_eq!(util.lines.len(), 2);
assert_eq!(util.branches.len(), 0);
assert_eq!(util.functions.len(), 0);
}
#[test]
fn test_parse_lcov_no_end_of_record() {
let input = include_bytes!("../../tests/fixtures/lcov_no_end_of_record.lcov");
let data = parse(input).unwrap();
assert_eq!(data.files.len(), 1);
assert_eq!(data.files[0].lines.len(), 2);
}
#[test]
fn test_parse_lcov_negative_counts() {
let input = include_bytes!("../../tests/fixtures/lcov_negative_counts.lcov");
let data = parse(input).unwrap();
assert_eq!(data.files.len(), 1);
let file = &data.files[0];
assert_eq!(file.lines.len(), 3);
assert_eq!(file.lines[0].line_number, 1);
assert_eq!(file.lines[0].hit_count, 5);
assert_eq!(file.lines[1].line_number, 3);
assert_eq!(file.lines[1].hit_count, 0);
assert_eq!(file.lines[2].line_number, 4);
assert_eq!(file.lines[2].hit_count, 3);
}
#[test]
fn test_parse_lcov_empty() {
let input = include_bytes!("../../tests/fixtures/empty.lcov");
let data = parse(input).unwrap();
assert_eq!(data.files.len(), 0);
}
}