///|
priv struct FileAccumulator {
path : String
mut test_name : String?
lines : Map[Int, Int]
branches : Map[String, CoverageBranch]
functions : Map[String, CoverageFunction]
}
///|
fn FileAccumulator::new(path : String, test_name : String?) -> FileAccumulator {
{ path, test_name, lines: {}, branches: {}, functions: {} }
}
///|
fn combine_test_names(left : String?, right : String?) -> String? {
match (left, right) {
(None, value) => value
(value, None) => value
(Some(a), Some(b)) => if a == b { Some(a) } else { None }
}
}
///|
fn merge_taken(left : Int?, right : Int?) -> Int? {
match (left, right) {
(None, None) => None
(Some(value), None) | (None, Some(value)) => Some(value)
(Some(a), Some(b)) => Some(a + b)
}
}
///|
fn branch_key(branch : CoverageBranch) -> String {
"\{branch.line}:\{branch.block.length()}:\{branch.block}:\{branch.branch}"
}
///|
fn function_key(function : CoverageFunction) -> String {
let line = match function.line {
Some(value) => value.to_string()
None => "-"
}
"\{function.name.length()}:\{function.name}:\{line}"
}
///|
fn FileAccumulator::add_file(
self : FileAccumulator,
file : FileCoverage,
) -> Unit raise CoverageError {
self.test_name = combine_test_names(self.test_name, file.test_name)
for line in file.lines {
if line.line <= 0 {
raise InvalidModel("line number must be positive")
}
if line.hits < 0 {
raise InvalidModel("line hit count must not be negative")
}
self.lines.update_or_default(line.line, line.hits, previous => {
previous + line.hits
})
}
for branch in file.branches {
if branch.line <= 0 {
raise InvalidModel("branch line must be positive")
}
if branch.taken is Some(count) && count < 0 {
raise InvalidModel("branch hit count must not be negative")
}
let key = branch_key(branch)
match self.branches.get(key) {
Some(previous) =>
self.branches[key] = {
line: previous.line,
block: previous.block,
branch: previous.branch,
taken: merge_taken(previous.taken, branch.taken),
}
None => self.branches[key] = branch
}
}
for function in file.functions {
if function.line is Some(line) && line <= 0 {
raise InvalidModel("function line must be positive")
}
if function.hits < 0 {
raise InvalidModel("function hit count must not be negative")
}
let key = function_key(function)
match self.functions.get(key) {
Some(previous) =>
self.functions[key] = {
name: previous.name,
line: previous.line,
hits: previous.hits + function.hits,
}
None => self.functions[key] = function
}
}
}
///|
fn FileAccumulator::finish(self : FileAccumulator) -> FileCoverage {
let lines : Array[CoverageLine] = [
for line, hits in self.lines => { line, hits }
]
lines.sort_by(compare_lines)
let branches = [ for _, branch in self.branches => branch ]
branches.sort_by(compare_branches)
let functions = [ for _, function in self.functions => function ]
functions.sort_by(compare_functions)
{ path: self.path, test_name: self.test_name, lines, branches, functions }
}
///|
/// Merge any number of coverage reports.
///
/// Files are matched by normalized path. Duplicate line, branch, and function
/// entries have their hit counts added. If only one side knows a branch count,
/// the known count wins; two unknown counts stay unknown. Conflicting test
/// names are cleared rather than reporting a misleading single producer.
pub fn merge_reports(
reports : ArrayView[CoverageReport],
strip_prefix? : String = "",
) -> CoverageReport raise CoverageError {
let accumulators : Map[String, FileAccumulator] = Map([])
for report in reports {
for file in report.files {
let path = normalize_path(file.path, strip_prefix~)
if path == "" {
raise InvalidModel("source path must not be empty")
}
let accumulator = match accumulators.get(path) {
Some(existing) => existing
None => {
let created = FileAccumulator::new(path, file.test_name)
accumulators[path] = created
created
}
}
accumulator.add_file(file)
}
}
let files = [ for _, accumulator in accumulators => accumulator.finish() ]
files.sort_by((left, right) => left.path.compare(right.path))
{ files, }
}
///|
/// Deduplicate and deterministically sort one report.
pub fn canonicalize_report(
report : CoverageReport,
strip_prefix? : String = "",
) -> CoverageReport raise CoverageError {
merge_reports([report], strip_prefix~)
}