1#!/usr/bin/env perl 2 3# SPDX-License-Identifier: MIT 4# 5# Copyright (c) 2025, Rob Norris <robn@despairlabs.com> 6# Copyright (c) 2026, TrueNAS. 7# 8# Permission is hereby granted, free of charge, to any person obtaining a copy 9# of this software and associated documentation files (the "Software"), to 10# deal in the Software without restriction, including without limitation the 11# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 12# sell copies of the Software, and to permit persons to whom the Software is 13# furnished to do so, subject to the following conditions: 14# 15# The above copyright notice and this permission notice shall be included in 16# all copies or substantial portions of the Software. 17# 18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24# IN THE SOFTWARE. 25 26# 27# usage: coverage_report.pl tests/unit/test_zap.info 28# coverage_report.pl < tests/unit/test_zap.info 29# 30# This program takes an lcov/geninfo coverage tracefile and shows a summary 31# of line, branch and function coverage for each file. It's focused on the 32# specific needs of OpenZFS' unit test suite (see tests/unit/README.md) but 33# it should be adaptable to any place where lcov's HTML output is too heavy 34# or difficult to use (eg build/CI logs). 35# 36# The heart of this program is a small parser for the tracefile format as 37# described in geninfo(1). The rest is concerned with constructing a useful 38# colorised table output. 39# 40 41# 42# Typical output: 43# 44# Coverage: test_zap | By line | By branch | By function 45# | Rate% Total Hit | Rate% Total Hit | Rate% Total Hit 46# module/zfs/u8_textprep.c | 42.0% 802 337 | 33.5% 510 171 | 50.0% 12 6 47# module/zfs/zap.c | 52.1% 687 358 | 45.2% 250 113 | 41.1% 90 37 48# module/zfs/zap_fat.c | 87.8% 665 584 | 58.5% 446 261 | 94.6% 37 35 49# module/zfs/zap_impl.c | 81.9% 232 190 | 60.3% 146 88 | 92.0% 25 23 50# module/zfs/zap_leaf.c | 86.7% 466 404 | 69.0% 216 149 | 95.7% 23 22 51# module/zfs/zap_micro.c | 76.5% 238 182 | 54.2% 142 77 | 92.9% 14 13 52# 53 54use 5.010; 55use warnings; 56use strict; 57use Cwd qw(getcwd); 58use Term::ANSIColor qw(colored); 59 60# Setup for color output. Perl has included Term::ANSIColor since 5.6 (~2000), 61# but RGB support didn't arrive until v4 in 5.17.8 (~2012). We disable colors 62# outright on versions < 4, or if output is not attached to a terminal. 63my $use_colors = -t \*STDOUT && $Term::ANSIColor::VERSION >= 4; 64 65# Palette setup. If Term::ANSIColor and the terminal advertise support for 66# it, then we set up a pleasant red -> green gradient for the coverage 67# percentages. If not, we scale those colors down to the older RGB-240 colors 68# (0-5 for each component), which is still quite nice. 69my @palette = !$use_colors ? () : map { 70 state $has_truecolor = 71 $Term::ANSIColor::VERSION >= 5 && $ENV{COLORTERM}; 72 my @rgb = map { hex } m/../g; 73 if ($has_truecolor) { 74 sprintf 'r%dg%db%d', @rgb; 75 } else { 76 sprintf 'rgb%d%d%d', map { $_ * 6 / 255 } @rgb; 77 } 78} ( 79 # Catppuccin Latte 80 # https://catppuccin.com/palette/ 81 'd20f39', # Red 82 'e64553', # Maroon 83 'fe640b', # Peach 84 'df8e1d', # Yellow 85 '40a02b', # Green 86 '179299', # Teal 87); 88 89# Test name, from the TN: field if present. 90my $test_name = ''; 91 92# Per-file data, initially sourced from the tracefile, then augmented 93my %filedata; 94 95# Tracking for the longest (stringified) value for each key. These are used 96# later when computing the output table column width. 97my %len; 98sub bump_len { 99 my ($k, $x) = @_; 100 my $l = length "".$x; 101 $len{$k} = $l if ($len{$k} // 0) < $l; 102} 103 104### 105# Parse the tracefile into per-file data records. 106 107# Current working directory. Expected to be the build root. Used to remove 108# the leading part of the source filenames, so its not the end of the world 109# if its wrong. 110my $cwd = getcwd; 111 112# Loop over the input 113while (my $line = <>) { 114 state $data = {}; 115 chomp $line; 116 117 # skip comments 118 next if $line =~ m/^#/; 119 120 if ($line eq 'end_of_record') { 121 # end of this file, prep for next 122 $data = {}; 123 next; 124 } 125 126 # everything else should be a KEY:VALUE line 127 my ($k, $v) = $line =~ m/^([A-Z]+):(.*)$/; 128 unless (defined $k) { 129 say "W: $.: malformed line: $line"; 130 next; 131 } 132 133 if ($k eq 'TN') { 134 # TN:test_zap 135 136 # Test name. This is actually per-record (a tracefile can 137 # carry multiple test results) but we only ever generate 138 # them for a single test, so we don't make any effort to 139 # notice or track changes. 140 $test_name = $v; 141 next; 142 } 143 144 if ($k eq 'SF') { 145 # SF:/home/robn/code/zfs-unit/module/zfs/zap.c 146 147 # Source file. Value is the name, and the rest of the record 148 # apply to it. 149 150 # Remove the leading build root name. 151 my $path = $v; 152 $path =~ s{^$cwd/*}{}; 153 154 # If we haven't seen this file before, create a new data 155 # record for it. 156 $filedata{$v} //= { path => $path }; 157 $data = $filedata{$v}; 158 159 # Increase path column width if necessary. 160 bump_len('path', $path); 161 next; 162 } 163 164 # Handle the counter keys. These are single values for the entire 165 # record in the file. L, FN and BR are Line, Function and Branch, 166 # F and H are found (ie total) and hit (ie was executed). 167 if (grep { $_ eq $k } qw(LF LH FNF FNH BRF BRH)) { 168 $data->{lc $k} = $v; 169 bump_len(lc $k, $v); 170 next; 171 } 172 173 # Older versions of lcov may not emit absolute found/hit counters. To 174 # handle this, we maintain our own counters from other events recorded 175 # in the info file, which we use if we don't get an absolute count. 176 177 if ($k eq 'DA') { 178 # DA:<line number>,<execution count>[,<checksum>] 179 # DA:463,0 180 # DA:469,153 181 my ($l, $h) = split ',', $v; 182 183 # One DA: record per actual code line (vs comment or other 184 # non-executable line), so we count records, not line number. 185 $data->{_lf}++; 186 187 # Only increment the hit count if the line was executed. 188 $data->{_lh}++ if $h > 0; 189 next; 190 } 191 192 if ($k eq 'FN') { 193 # FN:<start line>,[<end line>,]<function nname> 194 # FN:283,zap_lookup_by_dnode 195 196 # One FN record per function 197 $data->{_fnf}++; 198 next; 199 } 200 if ($k eq 'FNDA') { 201 # FNDA:<execution count>,<function name> 202 # FNDA:0,zap_lookup 203 # FNDA:78,zap_lookup_by_dnode 204 205 # Only count hit if more than one execution. 206 my ($c) = split ',', $v; 207 $data->{_fnh}++ if 0+$c > 0; 208 next; 209 } 210 211 if ($k eq 'BRDA') { 212 # BRDA:<line_number>,[<exception>]<block>,<branch>,<taken> 213 # BRDA:365,0,0,- 214 # BRDA:365,0,1,- 215 my ($l, $b, $br, $c) = split ',', $v; 216 217 # One BRDA: record per branch 218 $data->{_brf}++; 219 220 # <taken> is number of times branch arm was taken, or '-' if 221 # never considered (eg surrounding block was never entered) 222 # they're both 0 for our purposes. 223 $c = 0 if $c eq '-'; 224 225 # Only count hit if more than one execution. 226 $data->{_brh}++ if 0+$c > 0; 227 next; 228 } 229} 230 231### 232# Synthesize missing counters 233 234for my $file (keys %filedata) { 235 my $data = $filedata{$file}; 236 237 for my $k (qw(lf lh fnf fnh brf brh)) { 238 # Get our own count, if one exists. 239 my $v = delete $data->{"_$k"} // 0; 240 241 # If we didn't find a count in the info file, use our own. 242 # Note that this will also set legitimately unseen values to 243 # 0 (eg a source file with no branches). That's actually what 244 # we want. 245 unless (exists $data->{$k}) { 246 $data->{$k} = $v; 247 bump_len($k, $v); 248 } 249 } 250} 251 252### 253# Synthesize the "rate" percentage field from the "found" and "hit" fields. 254 255sub rate { 256 my ($data, $k, $kf, $kh) = @_; 257 my $rate = sprintf '%.01f%%', 258 $data->{$kf} ? (100 * $data->{$kh} / $data->{$kf}) : 0; 259 $data->{$k} = $rate; 260 bump_len($k, $rate); 261} 262 263for my $file (keys %filedata) { 264 my $data = $filedata{$file}; 265 rate($data, 'lr', 'lf', 'lh'); 266 rate($data, 'brr', 'brf', 'brh'); 267 rate($data, 'fnr', 'fnf', 'fnh'); 268} 269 270### 271# Set up the header "rows". 272 273# We reuse our data record structure a little because outputting these needs to 274# consider and sometimes contribute to column width. 275 276# The top row spans multiple columns. The pad functions below have extra tools 277# to handle the math. 278my $h1data = { 279 path => 'Coverage'.($test_name ? ": $test_name" : ''), 280 l => 'By line', 281 br => 'By branch', 282 fn => 'By function', 283}; 284bump_len('path', $h1data->{path}); 285 286# The second row is the actual header for each data column, and so may push 287# the column widths out if necessary. 288my $h2data = { 289 lr => 'Rate%', lf => 'Total', lh => 'Hit', 290 brr => 'Rate%', brf => 'Total', brh => 'Hit', 291 fnr => 'Rate%', fnf => 'Total', fnh => 'Hit', 292}; 293bump_len($_, $h2data->{$_}) for keys %$h2data; 294 295### 296# Table layout 297 298# Internal helper for padr() and padl() below. The idea is to compute the 299# effective column width, and the string we want to place in it. If it would 300# fit exactly, we return the string. If not, the passed-in function is called 301# with the string, its length and the column width, and it will place it 302# (by adding padding on either side). 303# 304# Most calls take a single column key, which makes it very simple - take 305# the max width for that column (from %len, set by bump_len()), and the value 306# of that key in this column, and that's all of it. 307# 308# For the top heading row (h1data above), a list of column keys can be passed 309# in. In this case, the string will be constructed as a space-separated list 310# of all the keys have have a value in the data row. The column width is the 311# sum of max column widths for all columns that mave a max column width, plus 312# one for each space separator. This allows us to provide a separate string 313# to appear in the space, with the amount of space computed from the columns 314# underneath it. 315# 316sub _pad { 317 my ($fn, $data, @k) = @_; 318 my $str = join ' ', map { $data->{$_} // () } @k; 319 my $strlen = length $str; 320 my $colwidth = -1; 321 $colwidth += ($len{$_} // -1)+1 for @k; 322 return $strlen == $colwidth ? $str : $fn->($str, $strlen, $colwidth); 323} 324 325# Return the value of the named fields, with space-padding added to the right. 326sub padr { 327 _pad(sub { 328 my ($str, $strlen, $colwidth) = @_; 329 $str . (' ' x ($colwidth - $strlen)); 330 }, @_); 331} 332 333# Return the value of the named fields, with space-padding added to the left. 334sub padl { 335 _pad(sub { 336 my ($str, $strlen, $colwidth) = @_; 337 (' ' x ($colwidth - $strlen)) . $str; 338 }, @_); 339} 340 341# Return the given % string, wrapped in terminal control codes that will give 342# it an appropriate color from the palette. 343sub colorpct { 344 my ($pct) = @_; 345 346 # If colors are disabled, return the string as-is. 347 return $pct unless $use_colors; 348 349 my ($n) = $pct =~ m/([0-9\.]+)/; 350 351 # scale 0-100 into palette range 352 my $s = int(($#palette / 100) * $n); 353 my $c = $palette[$s]; 354 355 return colored([$c], $pct); 356} 357 358my @rows; 359 360# Layout the first header row 361push @rows, [ 362 padr($h1data, 'path'), 363 '|', padr($h1data, 'l', 'lr', 'lf', 'lh'), 364 '|', padr($h1data, 'br', 'brr', 'brf', 'brh'), 365 '|', padr($h1data, 'fn', 'fnr', 'fnf', 'fnh'), 366]; 367 368# Layout the second header row 369push @rows, [ 370 padr($h2data, 'path'), 371 '|', padr($h2data, 'lr'), padl($h2data, 'lf'), padl($h2data, 'lh'), 372 '|', padr($h2data, 'brr'), padl($h2data, 'brf'), padl($h2data, 'brh'), 373 '|', padr($h2data, 'fnr'), padl($h2data, 'fnf'), padl($h2data, 'fnh'), 374]; 375 376# Layout the data rows, padding colorising as appropriate. 377for my $file (sort keys %filedata) { 378 my $data = $filedata{$file}; 379 380 push @rows, [ 381 padr($data, 'path'), 382 '|', colorpct(padl($data, 'lr')), 383 padl($data, 'lf'), padl($data, 'lh'), 384 '|', colorpct(padl($data, 'brr')), 385 padl($data, 'brf'), padl($data, 'brh'), 386 '|', colorpct(padl($data, 'fnr')), 387 padl($data, 'fnf'), padl($data, 'fnh'), 388 ]; 389} 390 391# And print them all out! 392say "@$_" for @rows; 393