///|
priv struct LcovBuilder {
mut test_name : String?
mut path : String?
lines : Array[CoverageLine]
branches : Array[CoverageBranch]
function_lines : Map[String, Int]
function_hits : Map[String, Int]
function_order : Array[String]
}
///|
fn LcovBuilder::new() -> LcovBuilder {
{
test_name: None,
path: None,
lines: [],
branches: [],
function_lines: {},
function_hits: {},
function_order: [],
}
}
///|
fn parse_lcov_int(
value : StringView,
input_line : Int,
field : String,
) -> Int raise CoverageError {
@strconv.from_str(value) catch {
_ => raise InvalidLcov(input_line, "invalid integer for \{field}: \{value}")
}
}
///|
fn remember_function(builder : LcovBuilder, name : String) -> Unit {
if !builder.function_lines.contains(name) &&
!builder.function_hits.contains(name) {
builder.function_order.push(name)
}
}
///|
fn parse_lcov_function_definition(
builder : LcovBuilder,
value : StringView,
input_line : Int,
) -> Unit raise CoverageError {
match value.split_once(",") {
Some((line_view, name_view)) => {
let line = parse_lcov_int(line_view, input_line, "FN line")
let name = "\{name_view}"
if line <= 0 {
raise InvalidLcov(input_line, "FN line must be positive")
}
if name == "" {
raise InvalidLcov(input_line, "FN name must not be empty")
}
remember_function(builder, name)
builder.function_lines[name] = line
}
None => raise InvalidLcov(input_line, "FN must contain line,name")
}
}
///|
fn parse_lcov_function_hits(
builder : LcovBuilder,
value : StringView,
input_line : Int,
) -> Unit raise CoverageError {
match value.split_once(",") {
Some((hits_view, name_view)) => {
let hits = parse_lcov_int(hits_view, input_line, "FNDA hits")
let name = "\{name_view}"
if hits < 0 {
raise InvalidLcov(input_line, "FNDA hits must not be negative")
}
if name == "" {
raise InvalidLcov(input_line, "FNDA name must not be empty")
}
remember_function(builder, name)
builder.function_hits.update_or_default(name, hits, old => old + hits)
}
None => raise InvalidLcov(input_line, "FNDA must contain hits,name")
}
}
///|
fn parse_lcov_line(
builder : LcovBuilder,
value : StringView,
input_line : Int,
) -> Unit raise CoverageError {
let fields = value.split(",").to_array()
if fields.length() < 2 {
raise InvalidLcov(input_line, "DA must contain line,hits")
}
let line = parse_lcov_int(fields[0], input_line, "DA line")
let hits = parse_lcov_int(fields[1], input_line, "DA hits")
if line <= 0 {
raise InvalidLcov(input_line, "DA line must be positive")
}
if hits < 0 {
raise InvalidLcov(input_line, "DA hits must not be negative")
}
builder.lines.push({ line, hits })
}
///|
fn parse_lcov_branch(
builder : LcovBuilder,
value : StringView,
input_line : Int,
) -> Unit raise CoverageError {
let fields = value.split(",").to_array()
if fields.length() != 4 {
raise InvalidLcov(input_line, "BRDA must contain line,block,branch,taken")
}
let line = parse_lcov_int(fields[0], input_line, "BRDA line")
if line <= 0 {
raise InvalidLcov(input_line, "BRDA line must be positive")
}
let taken = if fields[3] == "-" {
None
} else {
let count = parse_lcov_int(fields[3], input_line, "BRDA taken")
if count < 0 {
raise InvalidLcov(input_line, "BRDA taken must not be negative")
}
Some(count)
}
builder.branches.push({
line,
block: "\{fields[1]}",
branch: "\{fields[2]}",
taken,
})
}
///|
fn finish_lcov_record(
builder : LcovBuilder,
report : CoverageReport,
strip_prefix : String,
input_line : Int,
) -> Unit raise CoverageError {
guard builder.path is Some(path) else {
raise InvalidLcov(input_line, "record is missing SF")
}
let functions : Array[CoverageFunction] = []
for name in builder.function_order {
functions.push({
name,
line: builder.function_lines.get(name),
hits: builder.function_hits.get_or_default(name, 0),
})
}
report.files.push({
path: normalize_path(path, strip_prefix~),
test_name: builder.test_name,
lines: builder.lines,
branches: builder.branches,
functions,
})
}
///|
fn is_lcov_summary_tag(tag : String) -> Bool {
tag == "FNF" ||
tag == "FNH" ||
tag == "LF" ||
tag == "LH" ||
tag == "BRF" ||
tag == "BRH"
}
///|
/// Parse an LCOV tracefile into the format-neutral coverage model.
///
/// Aggregate summary tags (`LF`, `LH`, `FNF`, `FNH`, `BRF`, and `BRH`) are
/// accepted but recalculated by mooncov instead of trusted. A final
/// `end_of_record` is recommended but not required.
///
/// # Example
/// ```mbt check
/// test {
/// let report = parse_lcov(
/// "TN:unit\nSF:src/lib.mbt\nDA:1,3\nDA:2,0\nend_of_record\n",
/// )
/// inspect(report.files.length(), content="1")
/// inspect(report.files[0].lines[0].hits, content="3")
/// }
/// ```
pub fn parse_lcov(
input : String,
strip_prefix? : String = "",
) -> CoverageReport raise CoverageError {
let report = CoverageReport::new()
let mut builder = LcovBuilder::new()
let mut last_line = 0
for index, raw_view in input.split("\n") {
let input_line = index + 1
last_line = input_line
let raw = "\{raw_view}"
let line = "\{raw.trim_end(chars="\r")}"
if line == "" || line.has_prefix("#") {
continue
}
if line == "end_of_record" {
finish_lcov_record(builder, report, strip_prefix, input_line)
builder = LcovBuilder::new()
continue
}
guard line.split_once(":") is Some((tag_view, value)) else {
raise InvalidLcov(input_line, "expected TAG:value or end_of_record")
}
let tag = "\{tag_view}"
match tag {
"TN" =>
builder.test_name = if value == "" { None } else { Some("\{value}") }
"SF" =>
if builder.path is Some(_) {
raise InvalidLcov(input_line, "multiple SF entries in one record")
} else if value == "" {
raise InvalidLcov(input_line, "SF path must not be empty")
} else {
builder.path = Some("\{value}")
}
"FN" => parse_lcov_function_definition(builder, value, input_line)
"FNDA" => parse_lcov_function_hits(builder, value, input_line)
"DA" => parse_lcov_line(builder, value, input_line)
"BRDA" => parse_lcov_branch(builder, value, input_line)
"VER" => ()
_ =>
if !is_lcov_summary_tag(tag) {
raise InvalidLcov(input_line, "unsupported LCOV tag: \{tag}")
}
}
}
if builder.path is Some(_) {
finish_lcov_record(builder, report, strip_prefix, last_line + 1)
} else if builder.test_name is Some(_) ||
!builder.lines.is_empty() ||
!builder.branches.is_empty() ||
!builder.function_order.is_empty() {
raise InvalidLcov(last_line + 1, "incomplete record is missing SF")
}
report
}