Created
April 16, 2023 20:49
-
-
Save thekid/436b1d4d67d9265f16ff7cbb1ec0fe88 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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('└────────────────────────────────────────────────────────────┴─────────┴──────┘'); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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