← Back to index

lib/BDD/Behave/Coverage.rakumod

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> &mdash; %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> &mdash; %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">&larr; 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('&', '&amp;', :g)981    .subst('<', '&lt;',  :g)982    .subst('>', '&gt;',  :g)983    .subst('"', '&quot;', :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}