///|
/// An inclusive new-file line range extracted from a unified diff hunk.
pub(all) struct ChangedRange {
path : String
start_line : Int
end_line : Int
} derive(Debug, Eq)
///|
/// Instrumented line coverage selected by a collection of changed ranges.
pub(all) struct DiffSummary {
lines : CoverageCount
changed_ranges : Int
matched_files : Int
unmatched_files : Array[String]
} derive(Debug, Eq)
///|
/// The result of a changed-line coverage gate.
pub(all) struct DiffGateResult {
passed : Bool
summary : DiffSummary
actual : Double
required : Double
} derive(Debug)
///|
fn diff_owned(view : StringView) -> String {
"\{view}"
}
///|
fn clean_diff_path(raw : String, strip_prefix : String) -> String {
let without_timestamp = match raw.split_once("\t") {
Some((path, _)) => diff_owned(path)
None => raw
}
let trimmed = "\{without_timestamp.trim()}"
let unquoted = if trimmed.length() >= 2 &&
trimmed.has_prefix("\"") &&
trimmed.has_suffix("\"") {
"\{trimmed[1:trimmed.length() - 1]}"
} else {
trimmed
}
let without_git_prefix = if unquoted.has_prefix("a/") ||
unquoted.has_prefix("b/") {
"\{unquoted[2:]}"
} else {
unquoted
}
normalize_path(without_git_prefix, strip_prefix~)
}
///|
fn parse_hunk_range(
header : String,
input_line : Int,
) -> (Int, Int) raise CoverageError {
let fields : Array[String] = []
for field in header.split(" ") {
if field != "" {
fields.push(diff_owned(field))
}
}
if fields.length() < 4 ||
fields[0] != "@@" ||
!fields[2].has_prefix("+") ||
fields[3] != "@@" {
raise InvalidDiff(input_line, "malformed unified diff hunk header")
}
let token = "\{fields[2][1:]}"
let (start_text, count_text) = match token.split_once(",") {
Some((start, count)) => (diff_owned(start), diff_owned(count))
None => (token, "1")
}
let start : Int = @strconv.from_str(start_text) catch {
_ => raise InvalidDiff(input_line, "invalid new-file hunk start")
}
let count : Int = @strconv.from_str(count_text) catch {
_ => raise InvalidDiff(input_line, "invalid new-file hunk count")
}
if start < 0 || count < 0 {
raise InvalidDiff(input_line, "hunk start and count must not be negative")
}
(start, count)
}
///|
/// Parse new-file ranges from standard unified diff text.
///
/// `+++ b/path` headers select the destination path. Deleted files
/// (`+++ /dev/null`) and deletion-only hunks (`+start,0`) produce no ranges.
/// Git's conventional `a/` and `b/` prefixes are removed automatically.
pub fn parse_unified_diff(
input : String,
strip_prefix? : String = "",
) -> Array[ChangedRange] raise CoverageError {
let ranges : Array[ChangedRange] = []
let mut current_path : String? = None
let mut saw_destination_header = false
for index, raw_view in input.split("\n") {
let input_line = index + 1
let raw = diff_owned(raw_view)
let line = "\{raw.trim_end(chars="\r")}"
if line.has_prefix("+++ ") {
saw_destination_header = true
let raw_path = "\{line[4:]}"
current_path = if raw_path == "/dev/null" {
None
} else {
let path = clean_diff_path(raw_path, strip_prefix)
if path == "" {
raise InvalidDiff(input_line, "new-file path must not be empty")
}
Some(path)
}
} else if line.has_prefix("@@") {
if !saw_destination_header {
raise InvalidDiff(input_line, "hunk has no preceding +++ file header")
}
let (start, count) = parse_hunk_range(line, input_line)
if count > 0 && current_path is Some(path) {
if start <= 0 {
raise InvalidDiff(input_line, "non-empty hunk start must be positive")
}
let end_line = start + count - 1
if end_line < start {
raise InvalidDiff(input_line, "new-file hunk range exceeds Int")
}
ranges.push({ path, start_line: start, end_line })
}
}
}
ranges
}
///|
fn line_in_changed_ranges(
path : String,
line : Int,
ranges : ArrayView[ChangedRange],
) -> Bool {
for range in ranges {
if range.path == path && line >= range.start_line && line <= range.end_line {
return true
}
}
false
}
///|
fn range_path_matches(path : String, ranges : ArrayView[ChangedRange]) -> Bool {
ranges.any(range => range.path == path)
}
///|
/// Calculate changed-line coverage for instrumented lines.
///
/// Changed lines absent from the coverage report are not treated as misses,
/// because executable and non-executable source cannot be distinguished from
/// a unified diff alone. Their paths are exposed in `unmatched_files` when no
/// file-level coverage record exists at all.
pub fn summarize_diff(
report : CoverageReport,
ranges : ArrayView[ChangedRange],
) -> DiffSummary raise CoverageError {
for range in ranges {
if range.path == "" ||
range.start_line <= 0 ||
range.end_line < range.start_line {
raise InvalidModel("changed range must have a path and positive bounds")
}
}
let canonical = canonicalize_report(report)
let report_paths : Map[String, Unit] = Map([])
let mut covered = 0
let mut total = 0
let mut matched_files = 0
for file in canonical.files {
report_paths[file.path] = ()
if range_path_matches(file.path, ranges) {
matched_files = matched_files + 1
}
for line in file.lines {
if line_in_changed_ranges(file.path, line.line, ranges) {
total = total + 1
if line.is_covered() {
covered = covered + 1
}
}
}
}
let unmatched_set : Map[String, Unit] = Map([])
for range in ranges {
if !report_paths.contains(range.path) {
unmatched_set[range.path] = ()
}
}
let unmatched_files = [ for path, _ in unmatched_set => path ]
unmatched_files.sort()
{
lines: { covered, total },
changed_ranges: ranges.length(),
matched_files,
unmatched_files,
}
}
///|
/// Enforce a minimum percentage on instrumented changed lines.
pub fn check_diff_threshold(
report : CoverageReport,
ranges : ArrayView[ChangedRange],
minimum : Double,
) -> DiffGateResult raise CoverageError {
validate_threshold("diff lines", Some(minimum))
let summary = summarize_diff(report, ranges)
let actual = summary.lines.percentage()
{ passed: actual >= minimum, summary, actual, required: minimum }
}