Skip to content

Instantly share code, notes, and snippets.

@thekid
Created April 16, 2023 20:49
Show Gist options
  • Save thekid/436b1d4d67d9265f16ff7cbb1ec0fe88 to your computer and use it in GitHub Desktop.
Save thekid/436b1d4d67d9265f16ff7cbb1ec0fe88 to your computer and use it in GitHub Desktop.
<?php namespace xp\test;
use io\{File, FolderEntries};
use lang\{Environment, IllegalStateException, IllegalArgumentException};
use util\cmd\Console;
class Coverage extends Report {
private $coverage= [];
private $include;
static function __static() {
if (!extension_loaded('xdebug')) {
throw new IllegalStateException('Code coverage requires the Xdebug extension');
}
}
public function __construct($include) {
if (false === ($this->include= realpath($include))) {
throw new IllegalArgumentException('Path `'.$include.'` does not exist');
}
xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [$this->include]);
}
public function running($group, $test, $n) {
xdebug_start_code_coverage(); // TODO: XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK);
}
public function finished($group, $test, $outcome) {
$coverage= xdebug_get_code_coverage();
xdebug_stop_code_coverage();
// Merge with already gathered coverage. Interestingly, although we
// set the filter above, sometimes still files from outside this path
// are included in the coverage.
foreach ($coverage as $file => $lines) {
if (0 !== strncmp($file, $this->include, strlen($this->include))) continue;
if (isset($this->coverage[$file])) {
foreach ($lines as $n => $executed) {
$this->coverage[$file][$n]= ($this->coverage[$file][$n] ?? 0) + $executed;
}
} else {
$this->coverage[$file]= $lines;
}
}
}
/**
* Returns all source files in a given path
*
* @param string $file
* @return iterable
*/
private function sourcesIn($path) {
foreach (new FolderEntries($path) as $entry) {
if ($entry->isFolder()) {
yield from $this->sourcesIn($entry);
} else if (0 === substr_compare($entry, '.php', -4, 4)) {
yield realpath($entry->asURI());
}
}
}
/**
* Returns all lines in a given file
*
* @param string $file
* @return iterable
*/
private function linesIn($file) {
$tokens= token_get_all(file_get_contents($file));
$n= 1;
$code= '';
$level= 0;
for ($i= 0, $s= sizeof($tokens); $i < $s; $i++) {
if ('{' === $tokens[$i] || T_CURLY_OPEN === $tokens[$i][0]) {
$level++;
$code.= '{';
} else if ('}' === $tokens[$i]) {
$level--;
$code.= '}';
} else if (is_array($tokens[$i])) {
if (false !== ($p= strpos($tokens[$i][1], "\n"))) {
$o= 0;
do {
yield $n++ => new Line($code.substr($tokens[$i][1], $o, $p - $o), $level);
$code= '';
$o= $p + 1;
} while (false !== ($p= strpos($tokens[$i][1], "\n", $o)));
$code= substr($tokens[$i][1], $o);
} else {
$code.= $tokens[$i][1];
}
} else {
$code.= $tokens[$i];
}
}
yield $n => new Line($code, $level);
}
public function summary($metrics, $overall, $failures) {
// Calculate summary
$count= 0;
$percents= 0.0;
$details= [];
foreach ($this->sourcesIn($this->include) as $file) {
$executed= $executable= 0;
if ($coverage= $this->coverage[$file] ?? null) {
foreach ($this->linesIn($file) as $n => $line) {
if ($line->executable()) {
if (isset($coverage[$n])) {
$covered= true;
} else if (preg_match('/^\s+[})\]]*;?$/', $line->code)) {
// Dangling braces
$covered= null !== ($coverage[$n]= $coverage[$n - 1] ?? null);
} else if (preg_match('/^\s+\$[a-z0-9_]+[,; ]*$/i', $line->code)) {
// Variable on a line by itself
$covered= null !== ($coverage[$n]= $coverage[$n - 1] ?? null);
} else if (preg_match('/^\s*(try|do|\}\s*else)\s*\{\s*$/', $line->code)) {
// Try and do blocks, dangling else
$covered= null !== ($coverage[$n]= $coverage[$n + 1] ?? null);
} else if (preg_match('/^\s*[a-z0-9_]+:\s*$/', $line->code)) {
// Switch/case and goto labels
$covered= null !== ($coverage[$n]= $coverage[$n + 1] ?? null);
} else {
$covered= false;
}
$executable++;
$covered && $executed++;
// Console::writeLinef('%04d | %3s | %s', $n, $covered ?: 'x', $line->code);
} else {
// Console::writeLinef('%04d | | %s', $n, $line->code);
}
}
$percent= $executable ? $executed / $executable * 100.0 : 100.0;
} else {
foreach ($this->linesIn($file) as $n => $line) {
$line->executable() && $executable++;
}
$percent= $executable ? 0.0 : 100.0;
}
$details[$file]= [$percent, $executable - $executed];
$percents+= $percent;
$count++;
}
Console::writeLinef("\033[37;1mCoverage: \033[0m %.2f%% (details below)\n", $percents / $count);
// Details per file
Console::writeLine('┌────────────────────────────────────────────────────────────┬─────────┬──────┐');
Console::writeLine('│ File │ % Lines │ Not │');
Console::writeLine('╞════════════════════════════════════════════════════════════╪═════════╪══════╡');
foreach ($details as $file => $detail) {
$path= Environment::path($file);
Console::writeLinef(
"│ %-58s │ %s%6.2f%%\033[0m │ %4s │",
strlen($path) > 58 ? '…'.substr($path, -57) : $path,
$detail[0] < 50.0 ? "\033[31;1m" : ($detail[0] < 90.0 ? "\033[33m" : "\033[1m"),
$detail[0],
$detail[1] ?: ''
);
}
Console::writeLine('└────────────────────────────────────────────────────────────┴─────────┴──────┘');
}
}
<?php namespace xp\test;
class Line {
public $code, $level;
public function __construct($code, $level) {
$this->code= $code;
$this->level= $level;
}
/** Returns whether this line is executable */
public function executable(): bool {
// One-line method declarations, except empty ones
if (1 === $this->level) return (
preg_match('/function [^{]+\{(.+)}$/', $this->code, $m) &&
'' !== trim($m[1])
);
// Empty lines, lines starting with a comment and method declarations
if ($this->level >= 2) return (
'' !== trim($this->code) &&
!preg_match('/^\s*\/\//', $this->code) &&
!preg_match('/function [^{]+\{\s*$/', $this->code)
);
return false;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment