597 / 683 lines covered (87.4%)
1unit module BDD::Behave::Coverage;23use BDD::Behave::Colors;45# Remove the coverage env that behave assigns itself, so an inherited value6# can't interfere: the log path and control mode are set per worker (parallel)7# or per run (serial), and BEHAVE_COVERAGE_LOG mirrors the log path. An8# inherited MVM_COVERAGE_LOG would otherwise point every worker at one shared9# file. MVM_COVERAGE_FILES is a read-only source-file filter that behave does10# not manage, so it is left in place and passes through to the workers.11our sub scrub-managed-coverage-env(%env --> Hash) is export {12 %env<MVM_COVERAGE_LOG>:delete;13 %env<MVM_COVERAGE_CONTROL>:delete;14 %env<BEHAVE_COVERAGE_LOG>:delete;15 %env;16}1718# Best-effort removal of a coverage temp path: either a flat dir of per-worker19# logs or a single raw/filtered file. Used so an interrupted run does not20# strand gigabytes of raw coverage logs. A missing path is a no-op.21our sub remove-coverage-temp($path --> Bool) is export {22 return False unless $path.defined && $path.IO.e;23 try {24 if $path.IO.d {25 for $path.IO.dir -> $entry { $entry.unlink if $entry.f }26 $path.IO.rmdir;27 } else {28 $path.IO.unlink;29 }30 }31 !$path.IO.e;32}3334our class CoverageOptions {35 has Bool $.enabled is rw = False;36 has Real $.minimum is rw = 0.Real;37 has Str @.include-paths is rw;38 has Str @.exclude-paths is rw;39 has Str $.format is rw = 'text';40 has IO::Path $.output is rw;41 has IO::Path $.baseline is rw;42 has Bool $.branch is rw = False;43 has Bool $.counts is rw = False;4445 method include-path(*@paths) {46 @!include-paths.append: @paths.map(*.Str);47 self;48 }4950 method exclude-path(*@paths) {51 @!exclude-paths.append: @paths.map(*.Str);52 self;53 }54}5556our class FileCoverage {57 has Str $.path is required;58 has Str $.display-path is rw;59 has SetHash $.hits = SetHash.new;60 has SetHash $.executable = SetHash.new;61 has SetHash $.branch-lines = SetHash.new;62 has SetHash $.branches-hit = SetHash.new;63 has %.hit-counts;64 has List $!covered;65 has Str $!covered-signature;6667 method add-hit(Int $line, $count = 1) {68 $!hits{$line} = True;69 %!hit-counts{$line} += $count;70 }7172 # Rendering one file asks for its hit total, its covered count, and its73 # covered line numbers, and all three are the same intersection. The sizes of74 # the two sets identify the state they were computed from, so a file that75 # gains a hit or an executable line after a read is intersected again.76 method !covered-numbers(--> List) {77 my $signature = $!hits.elems ~ ':' ~ $!executable.elems;7879 # `.List` decontainerizes: handing back the attribute itself returns a80 # Scalar holding a List, which iterates as a single item.81 return $!covered.List82 if $!covered.defined && $!covered-signature eq $signature;8384 $!covered-signature = $signature;85 $!covered = ($!hits (&) $!executable).keys.map(*.Int).sort.List;8687 $!covered.List;88 }8990 method hit-count(Int $line --> Int) {91 %!hit-counts{$line} // 0;92 }9394 method total-hits(--> Int) {95 [+] self!covered-numbers.map({ %!hit-counts{$_} // 0 });96 }9798 method total-lines(--> Int) {99 $!executable.elems;100 }101102 method covered-lines(--> Int) {103 self!covered-numbers.elems;104 }105106 method missing-lines(--> List) {107 ($!executable (-) $!hits).keys.map(*.Int).sort.List;108 }109110 method covered-line-numbers(--> List) {111 self!covered-numbers;112 }113114 method percentage(--> Real) {115 my $total = self.total-lines;116 return 100e0 if $total == 0;117 (self.covered-lines * 100 / $total).Real;118 }119120 method total-branches(--> Int) {121 $!branch-lines.elems;122 }123124 method covered-branches(--> Int) {125 ($!branches-hit (&) $!branch-lines).elems;126 }127128 method branch-percentage(--> Real) {129 my $total = self.total-branches;130 return 100e0 if $total == 0;131 (self.covered-branches * 100 / $total).Real;132 }133}134135our class CoverageReport {136 has FileCoverage @.files is rw;137 has IO::Path $.root is rw;138 has Bool $.branch is rw = False;139 has Bool $.counts is rw = False;140141 method total-lines(--> Int) {142 [+] @!files.map(*.total-lines);143 }144145 method covered-lines(--> Int) {146 [+] @!files.map(*.covered-lines);147 }148149 method overall-percentage(--> Real) {150 my $t = self.total-lines;151 return 100e0 if $t == 0;152 (self.covered-lines * 100 / $t).Real;153 }154155 method total-branches(--> Int) {156 [+] @!files.map(*.total-branches);157 }158159 method covered-branches(--> Int) {160 [+] @!files.map(*.covered-branches);161 }162163 method overall-branch-percentage(--> Real) {164 my $t = self.total-branches;165 return 100e0 if $t == 0;166 (self.covered-branches * 100 / $t).Real;167 }168}169170# A line consisting solely of the comment `# :nocov:` toggles coverage171# exclusion. Lines between a pair of markers are dropped from the172# executable set, so they count as neither covered nor missed. An opener173# with no matching closer excludes through end of file.174our sub is-nocov-marker(Str $trimmed --> Bool) {175 return False unless $trimmed.starts-with('#');176 $trimmed.subst(/^ '#' \h* /, '') eq ':nocov:';177}178179our sub identify-executable-lines(IO::Path $file --> SetHash) {180 my $set = SetHash.new;181 return $set unless $file.f;182 my @lines = $file.lines;183 my @continuations = compute-continuation-lines($file);184 my $in-pod = False;185 my $in-nocov = False;186 my $prev-ended-cleanly = True;187188 for @lines.kv -> $idx, $line {189 my $lineno = $idx + 1;190 my $trimmed = $line.trim;191192 if $trimmed.starts-with('=begin pod') || $trimmed.starts-with('=pod') {193 $in-pod = True;194 next;195 }196 if $trimmed.starts-with('=end pod') {197 $in-pod = False;198 next;199 }200 next if $in-pod;201202 if is-nocov-marker($trimmed) {203 $in-nocov = !$in-nocov;204 next;205 }206 next if $in-nocov;207208 if $trimmed eq '' || $trimmed.starts-with('#') {209 next;210 }211212 # Update prev-ended-cleanly for the NEXT iteration based on what213 # this line's actual code (minus trailing comments) ends with.214 # `;` and `{` are unambiguous statement terminators. `}` is a clean215 # close only when it is itself the last token (i.e. preceded by216 # whitespace or alone on the line); a `}` glued to content is a217 # hash/array subscript close inside a multi-line expression.218 my sub update-state() {219 my $cleaned = strip-trailing-comment($line).trim-trailing;220 return if $cleaned eq '';221 my $last = $cleaned.substr(*-1);222 if $last eq ';' || $last eq '{' {223 $prev-ended-cleanly = True;224 } elsif $last eq '}' {225 if $cleaned.chars < 2 {226 $prev-ended-cleanly = True;227 } else {228 my $prev = $cleaned.substr(*-2, 1);229 $prev-ended-cleanly = ($prev eq ' ' || $prev eq "\t" || $prev eq '}');230 }231 } else {232 $prev-ended-cleanly = False;233 }234 }235236 if $trimmed eq 'else' | 'else {' | '} else {' | '}else{' {237 update-state();238 next;239 }240 if is-closing-continuation($trimmed)241 || is-bare-value-line($trimmed)242 || is-declarative-line($trimmed)243 || is-multiline-routine-header($trimmed)244 || (($idx < @continuations.elems) && @continuations[$idx])245 || !$prev-ended-cleanly {246 update-state();247 next;248 }249250 $set{$lineno} = True;251 update-state();252 }253 $set;254}255256our sub strip-trailing-comment(Str $line --> Str) {257 my $state = 'normal';258 my $i = 0;259 my $len = $line.chars;260 while $i < $len {261 my $c = $line.substr($i, 1);262 given $state {263 when 'normal' {264 if $c eq '#' { return $line.substr(0, $i) }265 elsif $c eq '"' { $state = 'dstr' }266 elsif $c eq "'" { $state = 'sstr' }267 }268 when 'dstr' {269 if $c eq '\\' { $i++ }270 elsif $c eq '"' { $state = 'normal' }271 }272 when 'sstr' {273 if $c eq '\\' { $i++ }274 elsif $c eq "'" { $state = 'normal' }275 }276 }277 $i++;278 }279 $line;280}281282# Returns an array whose i-th element is True iff line (i + 1) starts with283# the innermost open bracket being a ( or [, meaning the line is a284# continuation of a multi-line paren/bracket expression that MoarVM285# attributes to its starting line. Once a { opens (e.g. a lambda body286# passed to .map(...)), the new top is { and inner lines are NOT287# continuations — they are real statements MoarVM emits HITs for.288our sub compute-continuation-lines(IO::Path $file --> Array) {289 my @result;290 return @result unless $file.f;291 my $content = $file.slurp;292 my @stack;293 my $state = 'normal'; # normal | sstr | dstr | line-comment294 @result.push: False; # line 1 is never a continuation295 my $i = 0;296 my $len = $content.chars;297 while $i < $len {298 my $c = $content.substr($i, 1);299 given $state {300 when 'normal' {301 if $c eq '#' { $state = 'line-comment' }302 elsif $c eq '"' { $state = 'dstr' }303 elsif $c eq "'" { $state = 'sstr' }304 elsif $c eq '(' { @stack.push: '(' }305 elsif $c eq '[' { @stack.push: '[' }306 elsif $c eq '{' { @stack.push: '{' }307 elsif $c eq ')' | ']' | '}' { @stack.pop if @stack.elems }308 }309 when 'dstr' {310 if $c eq '\\' { $i++ }311 elsif $c eq '"' { $state = 'normal' }312 }313 when 'sstr' {314 if $c eq '\\' { $i++ }315 elsif $c eq "'" { $state = 'normal' }316 }317 when 'line-comment' {318 $state = 'normal' if $c eq "\n";319 }320 }321 if $c eq "\n" {322 my $top = @stack.elems ?? @stack[*-1] !! '';323 @result.push: ($top eq '(' || $top eq '[');324 }325 $i++;326 }327 @result;328}329330our sub is-closing-continuation(Str $trimmed --> Bool) {331 return False unless $trimmed.chars;332 my $first = $trimmed.substr(0, 1);333 return False unless $first eq '}' | ')' | ']';334 # `} else {` and friends open a new block; let them through so the335 # existing else-handling skip rules apply.336 return False if $trimmed.ends-with('{');337 True;338}339340our sub is-bare-value-line(Str $trimmed --> Bool) {341 # A single identifier, optionally with sigil and twigil, optionally342 # followed by `;`. Examples: `%rec`, `$result;`, `@list;`, `result`.343 # Literals like `True;`, `42;`, `'foo';` also count.344 return True if $trimmed ~~345 / ^ <[@%$&]>? <[!^.?=~:*]>? \w [ \w | '-' ]* ';'? $ /;346 return True if $trimmed ~~ / ^ \d+ ';'? $ /;347 False;348}349350our sub is-multiline-routine-header(Str $trimmed --> Bool) {351 # `multi method foo(...` style line whose signature continues to the352 # next line (no `{` on this line). Marking as non-executable avoids353 # the source-rewriter putting code before a method/sub declarator,354 # which Raku doesn't accept in class scope.355 return False if $trimmed.contains('{');356 return True if $trimmed ~~357 / ^ [ 'multi' \s+ | 'proto' \s+ | 'only' \s+ ]?358 [ 'sub' | 'method' | 'submethod' ]359 \s+ \S /;360 False;361}362363our sub is-declarative-line(Str $trimmed --> Bool) {364 return True if $trimmed.starts-with('use ')365 || $trimmed.starts-with('need ')366 || $trimmed.starts-with('require ')367 || $trimmed.starts-with('import ');368 return True if $trimmed.starts-with('unit ');369 return True if $trimmed.starts-with('has ')370 || $trimmed.starts-with('has(');371 return True if $trimmed.starts-with('constant ');372 return True if $trimmed.starts-with('subset ');373 return True if $trimmed.starts-with('enum ');374375 # `class Foo`, `role Foo`, `grammar Foo`, optionally with `my` or `our`,376 # and any trailing trait / signature.377 return True if $trimmed ~~378 / ^ [ 'my' \s+ | 'our' \s+ ]?379 [ 'class' | 'role' | 'grammar' ]380 \s+ \S /;381382 # Pure `my` / `our` / `state` declarations with no initializer compile to383 # zero runtime bytecode (just a lexical slot), so MoarVM never emits a384 # HIT for the line. Heuristic: the line starts with one of those keywords385 # AND, after stripping `=>` and `==`, contains no `=` (no assignment,386 # no `:=` bind, no `.=` mutator).387 if $trimmed.starts-with('my ' | 'our ' | 'state ') {388 my $stripped = $trimmed.subst('=>', '', :g).subst('==', '', :g);389 return True unless $stripped.contains('=');390 }391392 False;393}394395our sub identify-branch-lines(IO::Path $file --> SetHash) {396 my $set = SetHash.new;397 return $set unless $file.f;398 my @lines = $file.lines;399 my $rx = / ^ \h* [400 'if' | 'elsif' | 'unless' | 'with' | 'without'401 | 'when' | 'while' | 'until' | 'given' | 'for'402 ] [ \h | '(' ] /;403 my $rx-postfix = / \h [404 'if' | 'unless' | 'with' | 'without' | 'while' | 'until'405 ] \h \S /;406 my $in-pod = False;407 my $in-nocov = False;408 for @lines.kv -> $idx, $line {409 my $lineno = $idx + 1;410 my $trimmed = $line.trim;411 if $trimmed.starts-with('=begin pod') || $trimmed.starts-with('=pod') {412 $in-pod = True;413 next;414 }415 if $trimmed.starts-with('=end pod') {416 $in-pod = False;417 next;418 }419 next if $in-pod;420 if is-nocov-marker($trimmed) {421 $in-nocov = !$in-nocov;422 next;423 }424 next if $in-nocov;425 next if $trimmed eq '';426 next if $trimmed.starts-with('#');427 if $line ~~ $rx || $line ~~ $rx-postfix {428 $set{$lineno} = True;429 }430 }431 $set;432}433434# Render a single progress line for the coverage filtering stage: a percent,435# a fixed-width bar, and a rough ETA derived from bytes processed so far over436# elapsed wall time. Returns the line body only; the caller prefixes a437# carriage return and clears to end of line. An unknown total yields ''.438our sub format-progress-bar(439 Int $done, Int $total, Real $elapsed, Int :$width = 24 --> Str440) {441 return '' if $total <= 0;442 my $frac = $done >= $total ?? 1e0 !! ($done / $total).Real;443 my $pct = ($frac * 100).floor;444 my $filled = ($frac * $width).floor;445 my $bar = ('#' x $filled) ~ ('.' x ($width - $filled));446447 my $eta = '';448 if $done > 0 && $frac < 1e0 && $elapsed > 0 {449 my $remaining = (($elapsed / $frac) - $elapsed).round;450 $eta = " ~{$remaining}s";451 }452 sprintf ' filtering coverage: %3d%% [%s]%s', $pct, $bar, $eta;453}454455our sub matches-path-filter(Str $file, @include, @exclude --> Bool) {456 if @exclude {457 for @exclude -> $pat {458 return False if $file.contains($pat);459 }460 }461 if @include {462 my $any = False;463 for @include -> $pat {464 if $file.starts-with($pat) {465 $any = True;466 last;467 }468 }469 return False unless $any;470 }471 True;472}473474# A coverage log reaches millions of lines, so every test here is an index,475# a substr, or a character comparison rather than a regex.476sub all-digits(Str $text --> Bool) {477 return False unless $text.chars;478479 for ^$text.chars -> $offset {480 my $ch = $text.substr($offset, 1);481 return False unless '0' le $ch le '9';482 }483484 True;485}486487# `HIT /path/to/file.rakumod (Foo::Bar)` names the module the file was loaded488# as. The file is what the report is keyed by, so the suffix comes off.489sub strip-module-suffix(Str $file-part --> Str) {490 return $file-part unless $file-part.ends-with(')');491492 my $open = $file-part.rindex(' (');493 return $file-part without $open;494495 my $close = $file-part.index(')', $open);496 return $file-part unless $close == $file-part.chars - 1;497 return $file-part unless $close > $open + 2;498499 $file-part.substr(0, $open);500}501502our sub process-hit-line(503 Str $line,504 %hits,505 :@include-paths,506 :@exclude-paths,507 # The same file part repeats for every line of a file, so a caller reading a508 # whole log hands in a hash and pays for the filter once per file.509 :%filter-memo,510) {511 my $count = 1;512 my $entry = $line;513514 with $line.index("\t") -> $tab {515 my $prefix = $line.substr(0, $tab);516517 if all-digits($prefix) {518 $count = $prefix.Int;519 $entry = $line.substr($tab + 1);520 }521 }522523 return Nil unless $entry.starts-with('HIT') && $entry.chars >= 4;524525 my $rest = $entry.substr(4).trim-leading;526 my $last-space = $rest.rindex(' ');527 return Nil unless $last-space.defined;528529 my $line-token = $rest.substr($last-space + 1);530 return Nil unless all-digits($line-token);531 my $line-num = $line-token.Int;532533 my $file-part = strip-module-suffix($rest.substr(0, $last-space).trim);534535 my $allowed = %filter-memo{$file-part};536 without $allowed {537 $allowed = matches-path-filter($file-part, @include-paths, @exclude-paths);538 %filter-memo{$file-part} = $allowed;539 }540541 return Nil unless $allowed;542543 %hits{$file-part}{$line-num} += $count;544 Nil;545}546547our sub parse-coverage-log(548 IO::Path $log-path,549 :@include-paths,550 :@exclude-paths,551 --> Hash552) {553 my %hits;554 return %hits unless $log-path.defined && $log-path.e;555556 my %filter-memo;557 for $log-path.lines -> $line {558 process-hit-line($line, %hits, :@include-paths, :@exclude-paths, :%filter-memo);559 }560561 %hits;562}563564our sub parse-coverage-stream(565 IO::Handle $fh,566 :@include-paths,567 :@exclude-paths,568 --> Hash569) {570 my %hits;571572 my %filter-memo;573 for $fh.lines -> $line {574 process-hit-line($line, %hits, :@include-paths, :@exclude-paths, :%filter-memo);575 }576577 %hits;578}579580our sub merge-coverage-logs(581 @log-paths,582 :@include-paths,583 :@exclude-paths,584 --> Hash585) {586 my %hits;587588 my %filter-memo;589 for @log-paths -> $p {590 my $io = $p ~~ IO::Path ?? $p !! $p.IO;591 next unless $io.defined && $io.e;592593 for $io.lines -> $line {594 process-hit-line($line, %hits, :@include-paths, :@exclude-paths, :%filter-memo);595 }596 }597598 %hits;599}600601our sub normalize-display-path(Str $file, IO::Path $root --> Str) {602 my $abs = $file.IO.absolute;603 my $root-abs = $root.absolute ~ '/';604 return $abs.substr($root-abs.chars) if $abs.starts-with($root-abs);605 $file;606}607608our sub build-report-from-hits(609 %hits,610 CoverageOptions $opts,611 IO::Path $root,612 --> CoverageReport613) {614 my %by-abs;615 for %hits.keys -> $path {616 my $io = $path.IO;617 next unless $io.f;618 my $abs = $io.absolute;619 %by-abs{$abs} //= FileCoverage.new(:path($abs));620 my $fc = %by-abs{$abs};621 $fc.display-path = normalize-display-path($abs, $root);622 $fc.executable = identify-executable-lines($io) unless $fc.executable.elems;623 if $opts.branch && !$fc.branch-lines.elems {624 $fc.branch-lines = identify-branch-lines($io);625 }626 for %hits{$path}.kv -> $ln, $count {627 $fc.add-hit($ln.Int, $count);628 $fc.branches-hit{$ln.Int} = True if $opts.branch;629 }630 }631632 my @candidate-files = enumerate-include-files(633 $opts.include-paths, $opts.exclude-paths, $root,634 );635 for @candidate-files -> $io {636 my $abs = $io.absolute;637 next if %by-abs{$abs}:exists;638 my $fc = FileCoverage.new(:path($abs));639 $fc.display-path = normalize-display-path($abs, $root);640 $fc.executable = identify-executable-lines($io);641 if $opts.branch {642 $fc.branch-lines = identify-branch-lines($io);643 }644 %by-abs{$abs} = $fc;645 }646647 my @files = %by-abs.values.sort(*.display-path).List;648 CoverageReport.new(:@files, :$root, :branch($opts.branch), :counts($opts.counts));649}650651our sub build-report(652 IO::Path $log-path,653 CoverageOptions $opts,654 IO::Path $root,655 --> CoverageReport656) {657 my %hits = parse-coverage-log(658 $log-path,659 :include-paths(@($opts.include-paths)),660 :exclude-paths(@($opts.exclude-paths)),661 );662 build-report-from-hits(%hits, $opts, $root);663}664665our sub enumerate-include-files(@include, @exclude, IO::Path $root --> List) {666 my @result;667 my @roots = @include.elems ?? @include !! ('lib',);668 for @roots -> $r {669 my $io = $r.IO.is-absolute ?? $r.IO !! $root.add($r);670 next unless $io.e;671 if $io.f {672 next unless $io.basename ~~ / '.' (rakumod | raku | pm6) $/;673 next if @exclude.first({ $io.absolute.contains($_) });674 @result.push: $io;675 } elsif $io.d {676 for find-source-files($io) -> $f {677 next if @exclude.first({ $f.absolute.contains($_) });678 @result.push: $f;679 }680 }681 }682 @result.List;683}684685our sub find-source-files(IO::Path $dir --> List) {686 my @out;687 return @out.List unless $dir.e && $dir.d;688 for $dir.dir -> $entry {689 if $entry.d {690 @out.append: find-source-files($entry);691 } elsif $entry.f {692 @out.push: $entry if $entry.basename ~~ / '.' (rakumod | raku | pm6) $/;693 }694 }695 @out.List;696}697698# Renderers699700our sub render-text(CoverageReport $report, Bool :$color = True --> Str) {701 my @lines;702 @lines.push: 'Coverage report';703 @lines.push: '===============';704705 my $name-width = max(20, |($report.files.map(*.display-path.chars)));706 my $header = $report.counts707 ?? sprintf('%-*s %8s %5s %10s', $name-width, 'File', 'Lines', 'Cov%', 'Hits')708 !! sprintf('%-*s %8s %5s', $name-width, 'File', 'Lines', 'Cov%');709 @lines.push: $header;710 @lines.push: '-' x $header.chars;711712 for $report.files -> $f {713 my $pct = sprintf '%5.1f', $f.percentage;714 my $row = $report.counts715 ?? sprintf('%-*s %4d/%-3d %s %10d',716 $name-width, $f.display-path, $f.covered-lines, $f.total-lines, $pct, $f.total-hits)717 !! sprintf('%-*s %4d/%-3d %s',718 $name-width, $f.display-path, $f.covered-lines, $f.total-lines, $pct);719 if $color {720 if $f.percentage >= 90 { $row = green($row) }721 elsif $f.percentage >= 75 { $row = yellow($row) }722 else { $row = red($row) }723 }724 @lines.push: $row;725 }726727 @lines.push: '-' x $header.chars;728 my $overall = sprintf 'Overall: %d/%d lines (%.1f%%)',729 $report.covered-lines, $report.total-lines, $report.overall-percentage;730 @lines.push: $color ?? bright($overall, $report.overall-percentage) !! $overall;731732 if $report.branch {733 my $bline = sprintf 'Branches: %d/%d (%.1f%%)',734 $report.covered-branches, $report.total-branches, $report.overall-branch-percentage;735 @lines.push: $color ?? bright($bline, $report.overall-branch-percentage) !! $bline;736 }737738 @lines.join("\n") ~ "\n";739}740741our sub bright(Str $s, Real $pct --> Str) {742 return green($s) if $pct >= 90;743 return yellow($s) if $pct >= 75;744 red($s);745}746747our sub compress-line-ranges(@nums --> Str) {748 return '' unless @nums;749 my @ranges;750 my $start = @nums[0];751 my $prev = @nums[0];752 for @nums[1..*] -> $n {753 if $n == $prev + 1 {754 $prev = $n;755 } else {756 @ranges.push: $start == $prev ?? "$start" !! "{$start}-{$prev}";757 $start = $n;758 $prev = $n;759 }760 }761 @ranges.push: $start == $prev ?? "$start" !! "{$start}-{$prev}";762 @ranges.join(', ');763}764765our sub default-html-css(--> Str) {766 q:to/CSS/;767 body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;768 margin: 2em; color: #1a1a1a; }769 h1 { margin: 0 0 0.25em; }770 h2 { margin: 0 0 0.5em; font-size: 1.1em;771 font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }772 a { color: #0366d6; text-decoration: none; }773 a:hover { text-decoration: underline; }774 .summary { font-size: 1.1em; margin-bottom: 1.5em; }775 .back { margin-bottom: 1em; font-size: 0.9em; }776 table { border-collapse: collapse; width: 100%; }777 th, td { padding: 4px 10px; border-bottom: 1px solid #eee; text-align: left;778 vertical-align: top; }779 th { background: #f4f4f6; }780 tr.high td { background: #e6ffec; }781 tr.medium td { background: #fff7d6; }782 tr.low td { background: #ffe6e6; }783 .pct { font-weight: bold; text-align: right; }784 pre.source {785 background: #fafafa; border: 1px solid #ddd; padding: 0; margin: 0;786 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;787 font-size: 12px; line-height: 1.3;788 overflow-x: auto;789 }790 .src-line {791 display: block;792 padding: 0 8px;793 white-space: pre;794 line-height: 1.3;795 }796 .src-line.hit { background: #d3f8d3; }797 .src-line.miss { background: #ffd3d3; }798 .src-line.skip { color: #999; }799 .src-line .ln {800 display: inline-block;801 width: 4em;802 color: #999;803 user-select: none;804 text-align: right;805 margin-right: 1em;806 line-height: 1.3;807 }808 .src-line .hits {809 display: inline-block;810 width: 5em;811 color: #2a7a2a;812 user-select: none;813 text-align: right;814 margin-right: 1em;815 line-height: 1.3;816 }817 th.sortable { cursor: pointer; user-select: none; }818 th.sortable:hover { background: #e8e8ec; }819 .sort-arrow { color: #666; font-size: 0.85em; margin-left: 0.3em; }820 CSS821}822823our sub render-html-index(CoverageReport $report --> Str) {824 my @parts;825 @parts.push: '<!DOCTYPE html>';826 @parts.push: '<html lang="en"><head><meta charset="utf-8">';827 @parts.push: '<title>BDD::Behave Coverage Report</title>';828 @parts.push: '<link rel="stylesheet" href="style.css">';829 @parts.push: '</head><body>';830 @parts.push: '<h1>BDD::Behave Coverage Report</h1>';831 @parts.push: sprintf '<p class="summary">Overall: <strong>%.1f%%</strong> — %d / %d lines covered',832 $report.overall-percentage, $report.covered-lines, $report.total-lines;833 if $report.branch {834 @parts.push: sprintf '<br>Branches: <strong>%.1f%%</strong> — %d / %d branches covered',835 $report.overall-branch-percentage, $report.covered-branches, $report.total-branches;836 }837 @parts.push: '</p>';838839 @parts.push: '<table id="coverage-table">';840 @parts.push: '<thead><tr>'841 ~ '<th class="sortable" data-sort-type="text">File</th>'842 ~ '<th class="sortable" data-sort-type="num">Lines</th>'843 ~ '<th class="pct sortable" data-sort-type="num">Coverage</th>'844 ~ ($report.counts ?? '<th class="pct sortable" data-sort-type="num">Hits</th>' !! '')845 ~ '</tr></thead>';846 @parts.push: '<tbody>';847 for $report.files -> $f {848 my $klass = $f.percentage >= 90 ?? 'high'849 !! $f.percentage >= 75 ?? 'medium'850 !! 'low';851 my $hits-cell = $report.counts852 ?? sprintf('<td class="pct" data-sort="%d">%d</td>', $f.total-hits, $f.total-hits)853 !! '';854 @parts.push: sprintf855 '<tr class="%s"><td data-sort="%s"><a href="%s">%s</a></td>'856 ~ '<td data-sort="%d">%d / %d</td>'857 ~ '<td class="pct" data-sort="%.4f">%.1f%%</td>%s</tr>',858 $klass,859 html-escape($f.display-path),860 file-page-name($f.display-path), html-escape($f.display-path),861 $f.total-lines, $f.covered-lines, $f.total-lines,862 $f.percentage, $f.percentage,863 $hits-cell;864 }865 @parts.push: '</tbody></table>';866 @parts.push: index-sort-script();867 @parts.push: '</body></html>';868 @parts.join("\n") ~ "\n";869}870871our sub render-html-file-page(FileCoverage $f, Bool :$counts = False --> Str) {872 my @parts;873 @parts.push: '<!DOCTYPE html>';874 @parts.push: '<html lang="en"><head><meta charset="utf-8">';875 @parts.push: '<title>' ~ html-escape($f.display-path) ~ ' — Coverage</title>';876 @parts.push: '<link rel="stylesheet" href="style.css">';877 @parts.push: '</head><body>';878 @parts.push: '<p class="back"><a href="index.html">← Back to index</a></p>';879 @parts.push: '<h2>' ~ html-escape($f.display-path) ~ '</h2>';880 @parts.push: sprintf '<p class="summary">%d / %d lines covered (<strong>%.1f%%</strong>)</p>',881 $f.covered-lines, $f.total-lines, $f.percentage;882 @parts.push: render-html-source($f, :$counts);883 @parts.push: '</body></html>';884 @parts.join("\n") ~ "\n";885}886887our sub render-html-source(FileCoverage $f, Bool :$counts = False --> Str) {888 my $io = $f.path.IO;889 return '<p><em>(source unavailable)</em></p>' unless $io.e;890 my @rows;891 my %hit-set = $f.hits.keys.map(* => True);892 my %exec-set = $f.executable.keys.map(* => True);893 for $io.lines.kv -> $idx, $line {894 my $ln = $idx + 1;895 my $klass = !%exec-set{$ln}896 ?? 'skip'897 !! (%hit-set{$ln} ?? 'hit' !! 'miss');898 my $gutter = '';899 if $counts {900 my $count = $klass eq 'hit' ?? $f.hit-count($ln).Str !! '';901 $gutter = sprintf '<span class="hits">%s</span>', $count;902 }903 @rows.push: sprintf '<span class="src-line %s"><span class="ln">%d</span>%s%s</span>',904 $klass, $ln, $gutter, html-escape($line);905 }906 '<pre class="source">' ~ @rows.join('') ~ '</pre>';907}908909our sub file-page-name(Str $display-path --> Str) {910 html-anchor($display-path) ~ '.html';911}912913our sub index-sort-script(--> Str) {914 q:to/JS/;915 <script>916 (function () {917 var table = document.getElementById('coverage-table');918 if (!table) return;919 var ths = table.querySelectorAll('th.sortable');920 var state = { col: -1, asc: true };921922 function sortBy(idx, asc, type) {923 var tbody = table.querySelector('tbody');924 var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));925 rows.sort(function (a, b) {926 var ca = a.cells[idx], cb = b.cells[idx];927 var va = ca.getAttribute('data-sort');928 var vb = cb.getAttribute('data-sort');929 if (va === null) va = ca.textContent;930 if (vb === null) vb = cb.textContent;931 var cmp;932 if (type === 'num') {933 cmp = parseFloat(va) - parseFloat(vb);934 } else {935 cmp = String(va).localeCompare(String(vb));936 }937 return asc ? cmp : -cmp;938 });939 rows.forEach(function (r) { tbody.appendChild(r); });940 }941942 function updateArrows(activeIdx, asc) {943 ths.forEach(function (th, idx) {944 var existing = th.querySelector('.sort-arrow');945 if (existing) existing.remove();946 if (idx === activeIdx) {947 var s = document.createElement('span');948 s.className = 'sort-arrow';949 s.textContent = asc ? '▲' : '▼';950 th.appendChild(s);951 }952 });953 }954955 ths.forEach(function (th, idx) {956 th.addEventListener('click', function () {957 var type = th.getAttribute('data-sort-type') || 'text';958 var asc = state.col === idx ? !state.asc : true;959 state = { col: idx, asc: asc };960 sortBy(idx, asc, type);961 updateArrows(idx, asc);962 });963 });964 })();965 </script>966 JS967}968969our sub write-html-tree(CoverageReport $report, IO::Path $out-dir --> Nil) {970 $out-dir.mkdir;971 $out-dir.add('style.css').spurt(default-html-css());972 $out-dir.add('index.html').spurt(render-html-index($report));973 for $report.files -> $f {974 $out-dir.add(file-page-name($f.display-path)).spurt(975 render-html-file-page($f, :counts($report.counts)));976 }977}978979our sub html-escape(Str $s --> Str) {980 $s.subst('&', '&', :g)981 .subst('<', '<', :g)982 .subst('>', '>', :g)983 .subst('"', '"', :g);984}985986our sub html-anchor(Str $s --> Str) {987 $s.subst(/<-[A..Za..z0..9-]>/, '-', :g);988}989990our sub render-json(CoverageReport $report --> Str) {991 my %summary;992 %summary<lines> = %(993 :total($report.total-lines),994 :covered($report.covered-lines),995 :percentage($report.overall-percentage.Real.round(0.001)),996 );997 if $report.branch {998 %summary<branches> = %(999 :total($report.total-branches),1000 :covered($report.covered-branches),1001 :percentage($report.overall-branch-percentage.Real.round(0.001)),1002 );1003 }10041005 my @files;1006 for $report.files -> $f {1007 my %row;1008 %row<path> = $f.display-path;1009 %row<absolute-path> = $f.path;1010 %row<total-lines> = $f.total-lines;1011 %row<covered-lines> = $f.covered-lines;1012 %row<percentage> = $f.percentage.Real.round(0.001);1013 %row<missing-lines> = $f.missing-lines;1014 %row<covered-line-numbers> = $f.covered-line-numbers;10151016 if $report.counts {1017 %row<total-hits> = $f.total-hits;10181019 my %line-hits;1020 for $f.covered-line-numbers -> $ln {1021 %line-hits{$ln} = $f.hit-count($ln);1022 }1023 %row<line-hits> = %line-hits;1024 }1025 if $report.branch {1026 %row<total-branches> = $f.total-branches;1027 %row<covered-branches> = $f.covered-branches;1028 %row<branch-percentage> = $f.branch-percentage.Real.round(0.001);1029 }1030 @files.push: %row;1031 }10321033 my %data = :summary(%summary), :@files;1034 naive-json(%data);1035}10361037our sub naive-json(Mu $val, Int :$indent = 0 --> Str) {1038 my $pad = ' ' x $indent;1039 my $npad = ' ' x ($indent + 1);1040 given $val {1041 when Bool { return $_ ?? 'true' !! 'false' }1042 when Numeric { return ~$_ }1043 when Str { return naive-json-str($_) }1044 when Positional {1045 return '[]' unless $_.elems;1046 my @parts = $_.map: { $npad ~ naive-json($_, :indent($indent + 1)) };1047 return "[\n" ~ @parts.join(",\n") ~ "\n$pad]";1048 }1049 when Associative {1050 return '{}' unless $_.elems;1051 my @parts;1052 for $_.keys.sort -> $k {1053 my $v = $_{$k};1054 next if $v ~~ Nil;1055 @parts.push: $npad ~ naive-json-str($k) ~ ': ' ~ naive-json($v, :indent($indent + 1));1056 }1057 return "\{\n" ~ @parts.join(",\n") ~ "\n$pad\}";1058 }1059 when Nil { return 'null' }1060 default { return naive-json-str(~$_) }1061 }1062}10631064our sub naive-json-str(Str $s --> Str) {1065 my $escaped = $s1066 .subst('\\', '\\\\', :g)1067 .subst('"', '\\"', :g)1068 .subst("\n", '\\n', :g)1069 .subst("\r", '\\r', :g)1070 .subst("\t", '\\t', :g);1071 '"' ~ $escaped ~ '"';1072}10731074our sub render-lcov(CoverageReport $report --> Str) {1075 my @out;1076 for $report.files -> $f {1077 @out.push: 'TN:';1078 @out.push: 'SF:' ~ $f.path;1079 my @exec = $f.executable.keys.map(*.Int).sort;1080 for @exec -> $ln {1081 my $hit = $report.counts ?? $f.hit-count($ln) !! ($f.hits{$ln} ?? 1 !! 0);1082 @out.push: "DA:$ln,$hit";1083 }1084 @out.push: 'LF:' ~ $f.total-lines;1085 @out.push: 'LH:' ~ $f.covered-lines;1086 if $report.branch {1087 my @br = $f.branch-lines.keys.map(*.Int).sort;1088 for @br -> $ln {1089 my $hit = $f.branches-hit{$ln} ?? 1 !! 0;1090 @out.push: "BRDA:$ln,0,0,$hit";1091 }1092 @out.push: 'BRF:' ~ $f.total-branches;1093 @out.push: 'BRH:' ~ $f.covered-branches;1094 }1095 @out.push: 'end_of_record';1096 }1097 @out.join("\n") ~ "\n";1098}10991100our sub render-cobertura(CoverageReport $report --> Str) {1101 my $line-rate = $report.total-lines > 01102 ?? ($report.covered-lines / $report.total-lines).Real1103 !! 1e0;1104 my $branch-rate = $report.total-branches > 01105 ?? ($report.covered-branches / $report.total-branches).Real1106 !! 1e0;11071108 my @out;1109 @out.push: '<?xml version="1.0" encoding="UTF-8"?>';1110 @out.push: sprintf '<coverage line-rate="%.4f" branch-rate="%.4f" lines-covered="%d" lines-valid="%d" branches-covered="%d" branches-valid="%d" complexity="0" timestamp="%d" version="behave-1.0">',1111 $line-rate, $branch-rate,1112 $report.covered-lines, $report.total-lines,1113 $report.covered-branches, $report.total-branches,1114 now.Int;1115 @out.push: '<sources><source>' ~ html-escape($report.root.absolute) ~ '</source></sources>';1116 @out.push: '<packages><package name="behave-coverage" line-rate="' ~ sprintf('%.4f', $line-rate) ~ '" branch-rate="' ~ sprintf('%.4f', $branch-rate) ~ '" complexity="0">';1117 @out.push: '<classes>';1118 for $report.files -> $f {1119 my $fr = $f.total-lines > 0 ?? ($f.covered-lines / $f.total-lines).Real !! 1e0;1120 my $br = $f.total-branches > 0 ?? ($f.covered-branches / $f.total-branches).Real !! 1e0;1121 @out.push: sprintf '<class name="%s" filename="%s" line-rate="%.4f" branch-rate="%.4f" complexity="0">',1122 html-escape($f.display-path), html-escape($f.display-path), $fr, $br;1123 @out.push: '<methods/>';1124 @out.push: '<lines>';1125 for $f.executable.keys.map(*.Int).sort -> $ln {1126 my $hits = $report.counts ?? $f.hit-count($ln) !! ($f.hits{$ln} ?? 1 !! 0);1127 my $branch = ($report.branch && $f.branch-lines{$ln}) ?? 'true' !! 'false';1128 @out.push: sprintf '<line number="%d" hits="%d" branch="%s"/>', $ln, $hits, $branch;1129 }1130 @out.push: '</lines>';1131 @out.push: '</class>';1132 }1133 @out.push: '</classes>';1134 @out.push: '</package></packages>';1135 @out.push: '</coverage>';1136 @out.join("\n") ~ "\n";1137}11381139our sub render-report(CoverageReport $report, Str $format, Bool :$color = True --> Str) {1140 given $format {1141 when 'text' { render-text($report, :$color) }1142 when 'json' { render-json($report) }1143 when 'lcov' { render-lcov($report) }1144 when 'cobertura' { render-cobertura($report) }1145 when 'html' {1146 die "html coverage format writes a directory tree; use write-html-tree";1147 }1148 default {1149 die "Unknown coverage format: '$format' (available: text, html, json, lcov, cobertura)";1150 }1151 }1152}11531154our sub valid-format(Str $format --> Bool) {1155 so $format eq 'text' | 'html' | 'json' | 'lcov' | 'cobertura';1156}11571158# Diff against baseline11591160our class CoverageDiff {1161 has Real $.previous-percentage is rw = 0e0;1162 has Real $.current-percentage is rw = 0e0;1163 has Real $.delta is rw = 0e0;1164 has Int $.newly-covered is rw = 0;1165 has Int $.newly-uncovered is rw = 0;1166 has @.regressed-files is rw;1167 has @.improved-files is rw;1168}11691170our sub load-baseline(IO::Path $path --> Hash) {1171 return %() unless $path.defined && $path.e;1172 my $content = $path.slurp;1173 parse-baseline-json($content);1174}11751176our sub parse-baseline-json(Str $content --> Hash) {1177 # Try JSON::Fast first; fall back to our minimal parser.1178 try {1179 require ::('JSON::Fast');1180 return ::('JSON::Fast').can('from-json').head.($content);1181 CATCH { default { } }1182 }1183 minimal-json-parse($content);1184}11851186our sub minimal-json-parse(Str $content --> Hash) {1187 my $pos = 0;1188 my $len = $content.chars;11891190 my &skip-ws = sub () {1191 while $pos < $len && $content.substr($pos, 1) ~~ /\s/ { $pos++ }1192 };1193 my &parse-string;1194 my &parse-number;1195 my &parse-array;1196 my &parse-object;1197 my &parse-value;11981199 &parse-string = sub () {1200 skip-ws();1201 die "expected string at pos $pos" unless $content.substr($pos, 1) eq '"';1202 $pos++;1203 my $start = $pos;1204 my $buf = '';1205 while $pos < $len {1206 my $c = $content.substr($pos, 1);1207 if $c eq '\\' {1208 my $next = $content.substr($pos + 1, 1);1209 $buf ~= $content.substr($start, $pos - $start);1210 given $next {1211 when 'n' { $buf ~= "\n" }1212 when 'r' { $buf ~= "\r" }1213 when 't' { $buf ~= "\t" }1214 when '"' { $buf ~= '"' }1215 when '\\' { $buf ~= '\\' }1216 when '/' { $buf ~= '/' }1217 default { $buf ~= $next }1218 }1219 $pos += 2;1220 $start = $pos;1221 } elsif $c eq '"' {1222 $buf ~= $content.substr($start, $pos - $start);1223 $pos++;1224 return $buf;1225 } else {1226 $pos++;1227 }1228 }1229 die "unterminated string";1230 };12311232 &parse-number = sub () {1233 my $start = $pos;1234 while $pos < $len && $content.substr($pos, 1) ~~ /<[\d.+\-eE]>/ { $pos++ }1235 my $s = $content.substr($start, $pos - $start);1236 return $s.contains('.') || $s.contains('e') || $s.contains('E')1237 ?? $s.Num !! $s.Int;1238 };12391240 &parse-array = sub () {1241 $pos++;1242 my @arr;1243 skip-ws();1244 if $content.substr($pos, 1) eq ']' { $pos++; return @arr }1245 loop {1246 skip-ws();1247 @arr.push: parse-value();1248 skip-ws();1249 my $c = $content.substr($pos, 1);1250 if $c eq ',' { $pos++; next }1251 elsif $c eq ']' { $pos++; last }1252 else { die "expected , or ] at pos $pos" }1253 }1254 @arr;1255 };12561257 &parse-object = sub () {1258 $pos++;1259 my %obj;1260 skip-ws();1261 if $content.substr($pos, 1) eq '}' { $pos++; return %obj }1262 loop {1263 skip-ws();1264 my $k = parse-string();1265 skip-ws();1266 die "expected : at pos $pos" unless $content.substr($pos, 1) eq ':';1267 $pos++;1268 skip-ws();1269 %obj{$k} = parse-value();1270 skip-ws();1271 my $c = $content.substr($pos, 1);1272 if $c eq ',' { $pos++; next }1273 elsif $c eq '}' { $pos++; last }1274 else { die "expected , or } at pos $pos" }1275 }1276 %obj;1277 };12781279 &parse-value = sub () {1280 skip-ws();1281 return Nil if $pos >= $len;1282 my $c = $content.substr($pos, 1);1283 if $c eq '{' { return parse-object() }1284 elsif $c eq '[' { return parse-array() }1285 elsif $c eq '"' { return parse-string() }1286 elsif $c eq 't' && $content.substr($pos, 4) eq 'true' { $pos += 4; return True }1287 elsif $c eq 'f' && $content.substr($pos, 5) eq 'false' { $pos += 5; return False }1288 elsif $c eq 'n' && $content.substr($pos, 4) eq 'null' { $pos += 4; return Nil }1289 elsif $c ~~ /<[\d\-]>/ { return parse-number() }1290 else { die "unexpected char '$c' at pos $pos" }1291 };12921293 parse-value();1294}12951296our sub compute-diff(CoverageReport $current, IO::Path $baseline --> CoverageDiff) {1297 my %prev = load-baseline($baseline);1298 my $prev-pct = (%prev<summary><lines><percentage> // 0e0).Real;1299 my $cur-pct = $current.overall-percentage;1300 my $diff = CoverageDiff.new(1301 :previous-percentage($prev-pct),1302 :current-percentage($cur-pct),1303 :delta($cur-pct - $prev-pct),1304 );13051306 my %prev-by-path;1307 for (%prev<files> // []).list -> $row {1308 %prev-by-path{$row<path>} = $row;1309 }13101311 for $current.files -> $f {1312 my $prev-row = %prev-by-path{$f.display-path};1313 if $prev-row {1314 my @prev-covered = ($prev-row<covered-line-numbers> // []).list;1315 my %prev-covered-set = @prev-covered.map(* => True);1316 my @cur-covered = $f.covered-line-numbers;1317 my %cur-covered-set = @cur-covered.map(* => True);13181319 for @cur-covered -> $ln {1320 $diff.newly-covered++ unless %prev-covered-set{$ln};1321 }1322 for @prev-covered -> $ln {1323 $diff.newly-uncovered++ unless %cur-covered-set{$ln};1324 }13251326 my $prev-pct-file = ($prev-row<percentage> // 0e0).Real;1327 if $f.percentage < $prev-pct-file - 0.001 {1328 $diff.regressed-files.push: %(1329 :path($f.display-path),1330 :previous($prev-pct-file),1331 :current($f.percentage),1332 );1333 } elsif $f.percentage > $prev-pct-file + 0.001 {1334 $diff.improved-files.push: %(1335 :path($f.display-path),1336 :previous($prev-pct-file),1337 :current($f.percentage),1338 );1339 }1340 } else {1341 # new file - all covered lines are newly covered1342 $diff.newly-covered += $f.covered-lines;1343 }1344 }1345 $diff;1346}13471348our sub render-diff(CoverageDiff $d --> Str) {1349 my @out;1350 my $arrow = $d.delta > 0.05 ?? '↑'1351 !! $d.delta < -0.05 ?? '↓'1352 !! '=';1353 @out.push: sprintf 'Coverage diff: %.1f%% → %.1f%% (%s%.1f%%)',1354 $d.previous-percentage, $d.current-percentage, $arrow, $d.delta.abs;1355 @out.push: sprintf ' Newly covered lines: %d', $d.newly-covered;1356 @out.push: sprintf ' Newly uncovered lines: %d', $d.newly-uncovered;1357 if $d.regressed-files.elems {1358 @out.push: ' Regressed files:';1359 for $d.regressed-files.list -> $r {1360 @out.push: sprintf ' %s %.1f%% → %.1f%%', $r<path>, $r<previous>, $r<current>;1361 }1362 }1363 if $d.improved-files.elems {1364 @out.push: ' Improved files:';1365 for $d.improved-files.list -> $r {1366 @out.push: sprintf ' %s %.1f%% → %.1f%%', $r<path>, $r<previous>, $r<current>;1367 }1368 }1369 @out.join("\n") ~ "\n";1370}