Path: blob/main/sys/contrib/openzfs/scripts/coverage_report.pl
289024 views
#!/usr/bin/env perl12# SPDX-License-Identifier: MIT3#4# Copyright (c) 2025, Rob Norris <[email protected]>5# Copyright (c) 2026, TrueNAS.6#7# Permission is hereby granted, free of charge, to any person obtaining a copy8# of this software and associated documentation files (the "Software"), to9# deal in the Software without restriction, including without limitation the10# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or11# sell copies of the Software, and to permit persons to whom the Software is12# furnished to do so, subject to the following conditions:13#14# The above copyright notice and this permission notice shall be included in15# all copies or substantial portions of the Software.16#17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING22# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS23# IN THE SOFTWARE.2425#26# usage: coverage_report.pl tests/unit/test_zap.info27# coverage_report.pl < tests/unit/test_zap.info28#29# This program takes an lcov/geninfo coverage tracefile and shows a summary30# of line, branch and function coverage for each file. It's focused on the31# specific needs of OpenZFS' unit test suite (see tests/unit/README.md) but32# it should be adaptable to any place where lcov's HTML output is too heavy33# or difficult to use (eg build/CI logs).34#35# The heart of this program is a small parser for the tracefile format as36# described in geninfo(1). The rest is concerned with constructing a useful37# colorised table output.38#3940#41# Typical output:42#43# Coverage: test_zap | By line | By branch | By function44# | Rate% Total Hit | Rate% Total Hit | Rate% Total Hit45# module/zfs/u8_textprep.c | 42.0% 802 337 | 33.5% 510 171 | 50.0% 12 646# module/zfs/zap.c | 52.1% 687 358 | 45.2% 250 113 | 41.1% 90 3747# module/zfs/zap_fat.c | 87.8% 665 584 | 58.5% 446 261 | 94.6% 37 3548# module/zfs/zap_impl.c | 81.9% 232 190 | 60.3% 146 88 | 92.0% 25 2349# module/zfs/zap_leaf.c | 86.7% 466 404 | 69.0% 216 149 | 95.7% 23 2250# module/zfs/zap_micro.c | 76.5% 238 182 | 54.2% 142 77 | 92.9% 14 1351#5253use 5.010;54use warnings;55use strict;56use Cwd qw(getcwd);57use Term::ANSIColor qw(colored);5859# Setup for color output. Perl has included Term::ANSIColor since 5.6 (~2000),60# but RGB support didn't arrive until v4 in 5.17.8 (~2012). We disable colors61# outright on versions < 4, or if output is not attached to a terminal.62my $use_colors = -t \*STDOUT && $Term::ANSIColor::VERSION >= 4;6364# Palette setup. If Term::ANSIColor and the terminal advertise support for65# it, then we set up a pleasant red -> green gradient for the coverage66# percentages. If not, we scale those colors down to the older RGB-240 colors67# (0-5 for each component), which is still quite nice.68my @palette = !$use_colors ? () : map {69state $has_truecolor =70$Term::ANSIColor::VERSION >= 5 && $ENV{COLORTERM};71my @rgb = map { hex } m/../g;72if ($has_truecolor) {73sprintf 'r%dg%db%d', @rgb;74} else {75sprintf 'rgb%d%d%d', map { $_ * 6 / 255 } @rgb;76}77} (78# Catppuccin Latte79# https://catppuccin.com/palette/80'd20f39', # Red81'e64553', # Maroon82'fe640b', # Peach83'df8e1d', # Yellow84'40a02b', # Green85'179299', # Teal86);8788# Test name, from the TN: field if present.89my $test_name = '';9091# Per-file data, initially sourced from the tracefile, then augmented92my %filedata;9394# Tracking for the longest (stringified) value for each key. These are used95# later when computing the output table column width.96my %len;97sub bump_len {98my ($k, $x) = @_;99my $l = length "".$x;100$len{$k} = $l if ($len{$k} // 0) < $l;101}102103###104# Parse the tracefile into per-file data records.105106# Current working directory. Expected to be the build root. Used to remove107# the leading part of the source filenames, so its not the end of the world108# if its wrong.109my $cwd = getcwd;110111# Loop over the input112while (my $line = <>) {113state $data = {};114chomp $line;115116# skip comments117next if $line =~ m/^#/;118119if ($line eq 'end_of_record') {120# end of this file, prep for next121$data = {};122next;123}124125# everything else should be a KEY:VALUE line126my ($k, $v) = $line =~ m/^([A-Z]+):(.*)$/;127unless (defined $k) {128say "W: $.: malformed line: $line";129next;130}131132if ($k eq 'TN') {133# TN:test_zap134135# Test name. This is actually per-record (a tracefile can136# carry multiple test results) but we only ever generate137# them for a single test, so we don't make any effort to138# notice or track changes.139$test_name = $v;140next;141}142143if ($k eq 'SF') {144# SF:/home/robn/code/zfs-unit/module/zfs/zap.c145146# Source file. Value is the name, and the rest of the record147# apply to it.148149# Remove the leading build root name.150my $path = $v;151$path =~ s{^$cwd/*}{};152153# If we haven't seen this file before, create a new data154# record for it.155$filedata{$v} //= { path => $path };156$data = $filedata{$v};157158# Increase path column width if necessary.159bump_len('path', $path);160next;161}162163# Handle the counter keys. These are single values for the entire164# record in the file. L, FN and BR are Line, Function and Branch,165# F and H are found (ie total) and hit (ie was executed).166if (grep { $_ eq $k } qw(LF LH FNF FNH BRF BRH)) {167$data->{lc $k} = $v;168bump_len(lc $k, $v);169next;170}171172# Older versions of lcov may not emit absolute found/hit counters. To173# handle this, we maintain our own counters from other events recorded174# in the info file, which we use if we don't get an absolute count.175176if ($k eq 'DA') {177# DA:<line number>,<execution count>[,<checksum>]178# DA:463,0179# DA:469,153180my ($l, $h) = split ',', $v;181182# One DA: record per actual code line (vs comment or other183# non-executable line), so we count records, not line number.184$data->{_lf}++;185186# Only increment the hit count if the line was executed.187$data->{_lh}++ if $h > 0;188next;189}190191if ($k eq 'FN') {192# FN:<start line>,[<end line>,]<function nname>193# FN:283,zap_lookup_by_dnode194195# One FN record per function196$data->{_fnf}++;197next;198}199if ($k eq 'FNDA') {200# FNDA:<execution count>,<function name>201# FNDA:0,zap_lookup202# FNDA:78,zap_lookup_by_dnode203204# Only count hit if more than one execution.205my ($c) = split ',', $v;206$data->{_fnh}++ if 0+$c > 0;207next;208}209210if ($k eq 'BRDA') {211# BRDA:<line_number>,[<exception>]<block>,<branch>,<taken>212# BRDA:365,0,0,-213# BRDA:365,0,1,-214my ($l, $b, $br, $c) = split ',', $v;215216# One BRDA: record per branch217$data->{_brf}++;218219# <taken> is number of times branch arm was taken, or '-' if220# never considered (eg surrounding block was never entered)221# they're both 0 for our purposes.222$c = 0 if $c eq '-';223224# Only count hit if more than one execution.225$data->{_brh}++ if 0+$c > 0;226next;227}228}229230###231# Synthesize missing counters232233for my $file (keys %filedata) {234my $data = $filedata{$file};235236for my $k (qw(lf lh fnf fnh brf brh)) {237# Get our own count, if one exists.238my $v = delete $data->{"_$k"} // 0;239240# If we didn't find a count in the info file, use our own.241# Note that this will also set legitimately unseen values to242# 0 (eg a source file with no branches). That's actually what243# we want.244unless (exists $data->{$k}) {245$data->{$k} = $v;246bump_len($k, $v);247}248}249}250251###252# Synthesize the "rate" percentage field from the "found" and "hit" fields.253254sub rate {255my ($data, $k, $kf, $kh) = @_;256my $rate = sprintf '%.01f%%',257$data->{$kf} ? (100 * $data->{$kh} / $data->{$kf}) : 0;258$data->{$k} = $rate;259bump_len($k, $rate);260}261262for my $file (keys %filedata) {263my $data = $filedata{$file};264rate($data, 'lr', 'lf', 'lh');265rate($data, 'brr', 'brf', 'brh');266rate($data, 'fnr', 'fnf', 'fnh');267}268269###270# Set up the header "rows".271272# We reuse our data record structure a little because outputting these needs to273# consider and sometimes contribute to column width.274275# The top row spans multiple columns. The pad functions below have extra tools276# to handle the math.277my $h1data = {278path => 'Coverage'.($test_name ? ": $test_name" : ''),279l => 'By line',280br => 'By branch',281fn => 'By function',282};283bump_len('path', $h1data->{path});284285# The second row is the actual header for each data column, and so may push286# the column widths out if necessary.287my $h2data = {288lr => 'Rate%', lf => 'Total', lh => 'Hit',289brr => 'Rate%', brf => 'Total', brh => 'Hit',290fnr => 'Rate%', fnf => 'Total', fnh => 'Hit',291};292bump_len($_, $h2data->{$_}) for keys %$h2data;293294###295# Table layout296297# Internal helper for padr() and padl() below. The idea is to compute the298# effective column width, and the string we want to place in it. If it would299# fit exactly, we return the string. If not, the passed-in function is called300# with the string, its length and the column width, and it will place it301# (by adding padding on either side).302#303# Most calls take a single column key, which makes it very simple - take304# the max width for that column (from %len, set by bump_len()), and the value305# of that key in this column, and that's all of it.306#307# For the top heading row (h1data above), a list of column keys can be passed308# in. In this case, the string will be constructed as a space-separated list309# of all the keys have have a value in the data row. The column width is the310# sum of max column widths for all columns that mave a max column width, plus311# one for each space separator. This allows us to provide a separate string312# to appear in the space, with the amount of space computed from the columns313# underneath it.314#315sub _pad {316my ($fn, $data, @k) = @_;317my $str = join ' ', map { $data->{$_} // () } @k;318my $strlen = length $str;319my $colwidth = -1;320$colwidth += ($len{$_} // -1)+1 for @k;321return $strlen == $colwidth ? $str : $fn->($str, $strlen, $colwidth);322}323324# Return the value of the named fields, with space-padding added to the right.325sub padr {326_pad(sub {327my ($str, $strlen, $colwidth) = @_;328$str . (' ' x ($colwidth - $strlen));329}, @_);330}331332# Return the value of the named fields, with space-padding added to the left.333sub padl {334_pad(sub {335my ($str, $strlen, $colwidth) = @_;336(' ' x ($colwidth - $strlen)) . $str;337}, @_);338}339340# Return the given % string, wrapped in terminal control codes that will give341# it an appropriate color from the palette.342sub colorpct {343my ($pct) = @_;344345# If colors are disabled, return the string as-is.346return $pct unless $use_colors;347348my ($n) = $pct =~ m/([0-9\.]+)/;349350# scale 0-100 into palette range351my $s = int(($#palette / 100) * $n);352my $c = $palette[$s];353354return colored([$c], $pct);355}356357my @rows;358359# Layout the first header row360push @rows, [361padr($h1data, 'path'),362'|', padr($h1data, 'l', 'lr', 'lf', 'lh'),363'|', padr($h1data, 'br', 'brr', 'brf', 'brh'),364'|', padr($h1data, 'fn', 'fnr', 'fnf', 'fnh'),365];366367# Layout the second header row368push @rows, [369padr($h2data, 'path'),370'|', padr($h2data, 'lr'), padl($h2data, 'lf'), padl($h2data, 'lh'),371'|', padr($h2data, 'brr'), padl($h2data, 'brf'), padl($h2data, 'brh'),372'|', padr($h2data, 'fnr'), padl($h2data, 'fnf'), padl($h2data, 'fnh'),373];374375# Layout the data rows, padding colorising as appropriate.376for my $file (sort keys %filedata) {377my $data = $filedata{$file};378379push @rows, [380padr($data, 'path'),381'|', colorpct(padl($data, 'lr')),382padl($data, 'lf'), padl($data, 'lh'),383'|', colorpct(padl($data, 'brr')),384padl($data, 'brf'), padl($data, 'brh'),385'|', colorpct(padl($data, 'fnr')),386padl($data, 'fnf'), padl($data, 'fnh'),387];388}389390# And print them all out!391say "@$_" for @rows;392393394