<?php declare(strict_types=1);
/*
* This file is part of phpunit/php-code-coverage.
*
* (c) Sebastian Bergmann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\CodeCoverage\Report\Html;
use const ENT_COMPAT;
use const ENT_HTML401;
use const ENT_SUBSTITUTE;
use const T_ABSTRACT;
use const T_ARRAY;
use const T_AS;
use const T_BREAK;
use const T_CALLABLE;
use const T_CASE;
use const T_CATCH;
use const T_CLASS;
use const T_CLONE;
use const T_COMMENT;
use const T_CONST;
use const T_CONTINUE;
use const T_DECLARE;
use const T_DEFAULT;
use const T_DO;
use const T_DOC_COMMENT;
use const T_ECHO;
use const T_ELSE;
use const T_ELSEIF;
use const T_EMPTY;
use const T_ENDDECLARE;
use const T_ENDFOR;
use const T_ENDFOREACH;
use const T_ENDIF;
use const T_ENDSWITCH;
use const T_ENDWHILE;
use const T_EVAL;
use const T_EXIT;
use const T_EXTENDS;
use const T_FINAL;
use const T_FINALLY;
use const T_FOR;
use const T_FOREACH;
use const T_FUNCTION;
use const T_GLOBAL;
use const T_GOTO;
use const T_HALT_COMPILER;
use const T_IF;
use const T_IMPLEMENTS;
use const T_INCLUDE;
use const T_INCLUDE_ONCE;
use const T_INLINE_HTML;
use const T_INSTANCEOF;
use const T_INSTEADOF;
use const T_INTERFACE;
use const T_ISSET;
use const T_LIST;
use const T_NAMESPACE;
use const T_NEW;
use const T_PRINT;
use const T_PRIVATE;
use const T_PROTECTED;
use const T_PUBLIC;
use const T_REQUIRE;
use const T_REQUIRE_ONCE;
use const T_RETURN;
use const T_STATIC;
use const T_SWITCH;
use const T_THROW;
use const T_TRAIT;
use const T_TRY;
use const T_UNSET;
use const T_USE;
use const T_VAR;
use const T_WHILE;
use const T_YIELD;
use const T_YIELD_FROM;
use function array_key_exists;
use function array_pop;
use function array_unique;
use function constant;
use function count;
use function defined;
use function explode;
use function file_get_contents;
use function htmlspecialchars;
use function is_string;
use function sprintf;
use function str_replace;
use function substr;
use function token_get_all;
use function trim;
use PHPUnit\Runner\BaseTestRunner;
use SebastianBergmann\CodeCoverage\Node\File as FileNode;
use SebastianBergmann\CodeCoverage\Util\Percentage;
use SebastianBergmann\Template\Template;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class File extends Renderer
{
/**
* @psalm-var array<int,true>
*/
private static $keywordTokens = [];
/**
* @var array
*/
private static $formattedSourceCache = [];
/**
* @var int
*/
private $htmlSpecialCharsFlags = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE;
public function render(FileNode $node, string $file): void
{
$templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_branch.html' : 'file.html');
$template = new Template($templateName, '{{', '}}');
$this->setCommonTemplateVariables($template, $node);
$template->setVar(
[
'items' => $this->renderItems($node),
'lines' => $this->renderSourceWithLineCoverage($node),
'legend' => '<p><span class="success"><strong>Executed</strong></span><span class="danger"><strong>Not Executed</strong></span><span class="warning"><strong>Dead Code</strong></span></p>',
'structure' => '',
]
);
$template->renderTo($file . '.html');
if ($this->hasBranchCoverage) {
$template->setVar(
[
'items' => $this->renderItems($node),
'lines' => $this->renderSourceWithBranchCoverage($node),
'legend' => '<p><span class="success"><strong>Fully covered</strong></span><span class="warning"><strong>Partially covered</strong></span><span class="danger"><strong>Not covered</strong></span></p>',
'structure' => $this->renderBranchStructure($node),
]
);
$template->renderTo($file . '_branch.html');
$template->setVar(
[
'items' => $this->renderItems($node),
'lines' => $this->renderSourceWithPathCoverage($node),
'legend' => '<p><span class="success"><strong>Fully covered</strong></span><span class="warning"><strong>Partially covered</strong></span><span class="danger"><strong>Not covered</strong></span></p>',
'structure' => $this->renderPathStructure($node),
]
);
$template->renderTo($file . '_path.html');
}
}
private function renderItems(FileNode $node): string
{
$templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_item_branch.html' : 'file_item.html');
$template = new Template($templateName, '{{', '}}');
$methodTemplateName = $this->templatePath . ($this->hasBranchCoverage ? 'method_item_branch.html' : 'method_item.html');
$methodItemTemplate = new Template(
$methodTemplateName,
'{{',
'}}'
);
$items = $this->renderItemTemplate(
$template,
[
'name' => 'Total',
'numClasses' => $node->numberOfClassesAndTraits(),
'numTestedClasses' => $node->numberOfTestedClassesAndTraits(),
'numMethods' => $node->numberOfFunctionsAndMethods(),
'numTestedMethods' => $node->numberOfTestedFunctionsAndMethods(),
'linesExecutedPercent' => $node->percentageOfExecutedLines()->asFloat(),
'linesExecutedPercentAsString' => $node->percentageOfExecutedLines()->asString(),
'numExecutedLines' => $node->numberOfExecutedLines(),
'numExecutableLines' => $node->numberOfExecutableLines(),
'branchesExecutedPercent' => $node->percentageOfExecutedBranches()->asFloat(),
'branchesExecutedPercentAsString' => $node->percentageOfExecutedBranches()->asString(),
'numExecutedBranches' => $node->numberOfExecutedBranches(),
'numExecutableBranches' => $node->numberOfExecutableBranches(),
'pathsExecutedPercent' => $node->percentageOfExecutedPaths()->asFloat(),
'pathsExecutedPercentAsString' => $node->percentageOfExecutedPaths()->asString(),
'numExecutedPaths' => $node->numberOfExecutedPaths(),
'numExecutablePaths' => $node->numberOfExecutablePaths(),
'testedMethodsPercent' => $node->percentageOfTestedFunctionsAndMethods()->asFloat(),
'testedMethodsPercentAsString' => $node->percentageOfTestedFunctionsAndMethods()->asString(),
'testedClassesPercent' => $node->percentageOfTestedClassesAndTraits()->asFloat(),
'testedClassesPercentAsString' => $node->percentageOfTestedClassesAndTraits()->asString(),
'crap' => '<abbr title="Change Risk Anti-Patterns (CRAP) Index">CRAP</abbr>',
]
);
$items .= $this->renderFunctionItems(
$node->functions(),
$methodItemTemplate
);
$items .= $this->renderTraitOrClassItems(
$node->traits(),
$template,
$methodItemTemplate
);
$items .= $this->renderTraitOrClassItems(
$node->classes(),
$template,
$methodItemTemplate
);
return $items;
}
private function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string
{
$buffer = '';
if (empty($items)) {
return $buffer;
}
foreach ($items as $name => $item) {
$numMethods = 0;
$numTestedMethods = 0;
foreach ($item['methods'] as $method) {
if ($method['executableLines'] > 0) {
$numMethods++;
if ($method['executedLines'] === $method['executableLines']) {
$numTestedMethods++;
}
}
}
if ($item['executableLines'] > 0) {
$numClasses = 1;
$numTestedClasses = $numTestedMethods === $numMethods ? 1 : 0;
$linesExecutedPercentAsString = Percentage::fromFractionAndTotal(
$item['executedLines'],
$item['executableLines']
)->asString();
$branchesExecutedPercentAsString = Percentage::fromFractionAndTotal(
$item['executedBranches'],
$item['executableBranches']
)->asString();
$pathsExecutedPercentAsString = Percentage::fromFractionAndTotal(
$item['executedPaths'],
$item['executablePaths']
)->asString();
} else {
$numClasses = 0;
$numTestedClasses = 0;
$linesExecutedPercentAsString = 'n/a';
$branchesExecutedPercentAsString = 'n/a';
$pathsExecutedPercentAsString = 'n/a';
}
$testedMethodsPercentage = Percentage::fromFractionAndTotal(
$numTestedMethods,
$numMethods
);
$testedClassesPercentage = Percentage::fromFractionAndTotal(
$numTestedMethods === $numMethods ? 1 : 0,
1
);
$buffer .= $this->renderItemTemplate(
$template,
[
'name' => $this->abbreviateClassName($name),
'numClasses' => $numClasses,
'numTestedClasses' => $numTestedClasses,
'numMethods' => $numMethods,
'numTestedMethods' => $numTestedMethods,
'linesExecutedPercent' => Percentage::fromFractionAndTotal(
$item['executedLines'],
$item['executableLines'],
)->asFloat(),
'linesExecutedPercentAsString' => $linesExecutedPercentAsString,
'numExecutedLines' => $item['executedLines'],
'numExecutableLines' => $item['executableLines'],
'branchesExecutedPercent' => Percentage::fromFractionAndTotal(
$item['executedBranches'],
$item['executableBranches'],
)->asFloat(),
'branchesExecutedPercentAsString' => $branchesExecutedPercentAsString,
'numExecutedBranches' => $item['executedBranches'],
'numExecutableBranches' => $item['executableBranches'],
'pathsExecutedPercent' => Percentage::fromFractionAndTotal(
$item['executedPaths'],
$item['executablePaths']
)->asFloat(),
'pathsExecutedPercentAsString' => $pathsExecutedPercentAsString,
'numExecutedPaths' => $item['executedPaths'],
'numExecutablePaths' => $item['executablePaths'],
'testedMethodsPercent' => $testedMethodsPercentage->asFloat(),
'testedMethodsPercentAsString' => $testedMethodsPercentage->asString(),
'testedClassesPercent' => $testedClassesPercentage->asFloat(),
'testedClassesPercentAsString' => $testedClassesPercentage->asString(),
'crap' => $item['crap'],
]
);
foreach ($item['methods'] as $method) {
$buffer .= $this->renderFunctionOrMethodItem(
$methodItemTemplate,
$method,
' '
);
}
}
return $buffer;
}
private function renderFunctionItems(array $functions, Template $template): string
{
if (empty($functions)) {
return '';
}
$buffer = '';
foreach ($functions as $function) {
$buffer .= $this->renderFunctionOrMethodItem(
$template,
$function
);
}
return $buffer;
}
private function renderFunctionOrMethodItem(Template $template, array $item, string $indent = ''): string
{
$numMethods = 0;
$numTestedMethods = 0;
if ($item['executableLines'] > 0) {
$numMethods = 1;
if ($item['executedLines'] === $item['executableLines']) {
$numTestedMethods = 1;
}
}
$executedLinesPercentage = Percentage::fromFractionAndTotal(
$item['executedLines'],
$item['executableLines']
);
$executedBranchesPercentage = Percentage::fromFractionAndTotal(
$item['executedBranches'],
$item['executableBranches']
);
$executedPathsPercentage = Percentage::fromFractionAndTotal(
$item['executedPaths'],
$item['executablePaths']
);
$testedMethodsPercentage = Percentage::fromFractionAndTotal(
$numTestedMethods,
1
);
return $this->renderItemTemplate(
$template,
[
'name' => sprintf(
'%s<a href="#%d"><abbr title="%s">%s</abbr></a>',
$indent,
$item['startLine'],
htmlspecialchars($item['signature'], $this->htmlSpecialCharsFlags),
$item['functionName'] ?? $item['methodName']
),
'numMethods' => $numMethods,
'numTestedMethods' => $numTestedMethods,
'linesExecutedPercent' => $executedLinesPercentage->asFloat(),
'linesExecutedPercentAsString' => $executedLinesPercentage->asString(),
'numExecutedLines' => $item['executedLines'],
'numExecutableLines' => $item['executableLines'],
'branchesExecutedPercent' => $executedBranchesPercentage->asFloat(),
'branchesExecutedPercentAsString' => $executedBranchesPercentage->asString(),
'numExecutedBranches' => $item['executedBranches'],
'numExecutableBranches' => $item['executableBranches'],
'pathsExecutedPercent' => $executedPathsPercentage->asFloat(),
'pathsExecutedPercentAsString' => $executedPathsPercentage->asString(),
'numExecutedPaths' => $item['executedPaths'],
'numExecutablePaths' => $item['executablePaths'],
'testedMethodsPercent' => $testedMethodsPercentage->asFloat(),
'testedMethodsPercentAsString' => $testedMethodsPercentage->asString(),
'crap' => $item['crap'],
]
);
}
private function renderSourceWithLineCoverage(FileNode $node): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
$singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
$coverageData = $node->lineCoverageData();
$testData = $node->testData();
$codeLines = $this->loadFile($node->pathAsString());
$lines = '';
$i = 1;
foreach ($codeLines as $line) {
$trClass = '';
$popoverContent = '';
$popoverTitle = '';
if (array_key_exists($i, $coverageData)) {
$numTests = ($coverageData[$i] ? count($coverageData[$i]) : 0);
if ($coverageData[$i] === null) {
$trClass = 'warning';
} elseif ($numTests === 0) {
$trClass = 'danger';
} else {
if ($numTests > 1) {
$popoverTitle = $numTests . ' tests cover line ' . $i;
} else {
$popoverTitle = '1 test covers line ' . $i;
}
$lineCss = 'covered-by-large-tests';
$popoverContent = '<ul>';
foreach ($coverageData[$i] as $test) {
if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') {
$lineCss = 'covered-by-medium-tests';
} elseif ($testData[$test]['size'] === 'small') {
$lineCss = 'covered-by-small-tests';
}
$popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
}
$popoverContent .= '</ul>';
$trClass = $lineCss . ' popin';
}
}
$popover = '';
if (!empty($popoverTitle)) {
$popover = sprintf(
' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
);
}
$lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover);
$i++;
}
$linesTemplate->setVar(['lines' => $lines]);
return $linesTemplate->render();
}
private function renderSourceWithBranchCoverage(FileNode $node): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
$singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
$functionCoverageData = $node->functionCoverageData();
$testData = $node->testData();
$codeLines = $this->loadFile($node->pathAsString());
$lineData = [];
/** @var int $line */
foreach (array_keys($codeLines) as $line) {
$lineData[$line + 1] = [
'includedInBranches' => 0,
'includedInHitBranches' => 0,
'tests' => [],
];
}
foreach ($functionCoverageData as $method) {
foreach ($method['branches'] as $branch) {
foreach (range($branch['line_start'], $branch['line_end']) as $line) {
if (!isset($lineData[$line])) { // blank line at end of file is sometimes included here
continue;
}
$lineData[$line]['includedInBranches']++;
if ($branch['hit']) {
$lineData[$line]['includedInHitBranches']++;
$lineData[$line]['tests'] = array_unique(array_merge($lineData[$line]['tests'], $branch['hit']));
}
}
}
}
$lines = '';
$i = 1;
/** @var string $line */
foreach ($codeLines as $line) {
$trClass = '';
$popover = '';
if ($lineData[$i]['includedInBranches'] > 0) {
$lineCss = 'success';
if ($lineData[$i]['includedInHitBranches'] === 0) {
$lineCss = 'danger';
} elseif ($lineData[$i]['includedInHitBranches'] !== $lineData[$i]['includedInBranches']) {
$lineCss = 'warning';
}
$popoverContent = '<ul>';
if (count($lineData[$i]['tests']) === 1) {
$popoverTitle = '1 test covers line ' . $i;
} else {
$popoverTitle = count($lineData[$i]['tests']) . ' tests cover line ' . $i;
}
$popoverTitle .= '. These are covering ' . $lineData[$i]['includedInHitBranches'] . ' out of the ' . $lineData[$i]['includedInBranches'] . ' code branches.';
foreach ($lineData[$i]['tests'] as $test) {
$popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
}
$popoverContent .= '</ul>';
$trClass = $lineCss . ' popin';
$popover = sprintf(
' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
);
}
$lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover);
$i++;
}
$linesTemplate->setVar(['lines' => $lines]);
return $linesTemplate->render();
}
private function renderSourceWithPathCoverage(FileNode $node): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
$singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
$functionCoverageData = $node->functionCoverageData();
$testData = $node->testData();
$codeLines = $this->loadFile($node->pathAsString());
$lineData = [];
/** @var int $line */
foreach (array_keys($codeLines) as $line) {
$lineData[$line + 1] = [
'includedInPaths' => [],
'includedInHitPaths' => [],
'tests' => [],
];
}
foreach ($functionCoverageData as $method) {
foreach ($method['paths'] as $pathId => $path) {
foreach ($path['path'] as $branchTaken) {
foreach (range($method['branches'][$branchTaken]['line_start'], $method['branches'][$branchTaken]['line_end']) as $line) {
if (!isset($lineData[$line])) {
continue;
}
$lineData[$line]['includedInPaths'][] = $pathId;
if ($path['hit']) {
$lineData[$line]['includedInHitPaths'][] = $pathId;
$lineData[$line]['tests'] = array_unique(array_merge($lineData[$line]['tests'], $path['hit']));
}
}
}
}
}
$lines = '';
$i = 1;
/** @var string $line */
foreach ($codeLines as $line) {
$trClass = '';
$popover = '';
$includedInPathsCount = count(array_unique($lineData[$i]['includedInPaths']));
$includedInHitPathsCount = count(array_unique($lineData[$i]['includedInHitPaths']));
if ($includedInPathsCount > 0) {
$lineCss = 'success';
if ($includedInHitPathsCount === 0) {
$lineCss = 'danger';
} elseif ($includedInHitPathsCount !== $includedInPathsCount) {
$lineCss = 'warning';
}
$popoverContent = '<ul>';
if (count($lineData[$i]['tests']) === 1) {
$popoverTitle = '1 test covers line ' . $i;
} else {
$popoverTitle = count($lineData[$i]['tests']) . ' tests cover line ' . $i;
}
$popoverTitle .= '. These are covering ' . $includedInHitPathsCount . ' out of the ' . $includedInPathsCount . ' code paths.';
foreach ($lineData[$i]['tests'] as $test) {
$popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
}
$popoverContent .= '</ul>';
$trClass = $lineCss . ' popin';
$popover = sprintf(
' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
);
}
$lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover);
$i++;
}
$linesTemplate->setVar(['lines' => $lines]);
return $linesTemplate->render();
}
private function renderBranchStructure(FileNode $node): string
{
$branchesTemplate = new Template($this->templatePath . 'branches.html.dist', '{{', '}}');
$coverageData = $node->functionCoverageData();
$testData = $node->testData();
$codeLines = $this->loadFile($node->pathAsString());
$branches = '';
ksort($coverageData);
foreach ($coverageData as $methodName => $methodData) {
if (!$methodData['branches']) {
continue;
}
$branchStructure = '';
foreach ($methodData['branches'] as $branch) {
$branchStructure .= $this->renderBranchLines($branch, $codeLines, $testData);
}
if ($branchStructure !== '') { // don't show empty branches
$branches .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, $this->htmlSpecialCharsFlags) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
$branches .= $branchStructure;
}
}
$branchesTemplate->setVar(['branches' => $branches]);
return $branchesTemplate->render();
}
private function renderBranchLines(array $branch, array $codeLines, array $testData): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
$singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
$lines = '';
$branchLines = range($branch['line_start'], $branch['line_end']);
sort($branchLines); // sometimes end_line < start_line
/** @var int $line */
foreach ($branchLines as $line) {
if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here
continue;
}
$popoverContent = '';
$popoverTitle = '';
$numTests = count($branch['hit']);
if ($numTests === 0) {
$trClass = 'danger';
} else {
$lineCss = 'covered-by-large-tests';
$popoverContent = '<ul>';
if ($numTests > 1) {
$popoverTitle = $numTests . ' tests cover this branch';
} else {
$popoverTitle = '1 test covers this branch';
}
foreach ($branch['hit'] as $test) {
if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') {
$lineCss = 'covered-by-medium-tests';
} elseif ($testData[$test]['size'] === 'small') {
$lineCss = 'covered-by-small-tests';
}
$popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
}
$trClass = $lineCss . ' popin';
}
$popover = '';
if (!empty($popoverTitle)) {
$popover = sprintf(
' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
);
}
$lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover);
}
if ($lines === '') {
return '';
}
$linesTemplate->setVar(['lines' => $lines]);
return $linesTemplate->render();
}
private function renderPathStructure(FileNode $node): string
{
$pathsTemplate = new Template($this->templatePath . 'paths.html.dist', '{{', '}}');
$coverageData = $node->functionCoverageData();
$testData = $node->testData();
$codeLines = $this->loadFile($node->pathAsString());
$paths = '';
ksort($coverageData);
foreach ($coverageData as $methodName => $methodData) {
if (!$methodData['paths']) {
continue;
}
$pathStructure = '';
if (count($methodData['paths']) > 100) {
$pathStructure .= '<p>' . count($methodData['paths']) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.</p>';
continue;
}
foreach ($methodData['paths'] as $path) {
$pathStructure .= $this->renderPathLines($path, $methodData['branches'], $codeLines, $testData);
}
if ($pathStructure !== '') {
$paths .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, $this->htmlSpecialCharsFlags) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
$paths .= $pathStructure;
}
}
$pathsTemplate->setVar(['paths' => $paths]);
return $pathsTemplate->render();
}
private function renderPathLines(array $path, array $branches, array $codeLines, array $testData): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
$singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
$lines = '';
$first = true;
foreach ($path['path'] as $branchId) {
if ($first) {
$first = false;
} else {
$lines .= ' <tr><td colspan="2"> </td></tr>' . "\n";
}
$branchLines = range($branches[$branchId]['line_start'], $branches[$branchId]['line_end']);
sort($branchLines); // sometimes end_line < start_line
/** @var int $line */
foreach ($branchLines as $line) {
if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here
continue;
}
$popoverContent = '';
$popoverTitle = '';
$numTests = count($path['hit']);
if ($numTests === 0) {
$trClass = 'danger';
} else {
$lineCss = 'covered-by-large-tests';
$popoverContent = '<ul>';
if ($numTests > 1) {
$popoverTitle = $numTests . ' tests cover this path';
} else {
$popoverTitle = '1 test covers this path';
}
foreach ($path['hit'] as $test) {
if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') {
$lineCss = 'covered-by-medium-tests';
} elseif ($testData[$test]['size'] === 'small') {
$lineCss = 'covered-by-small-tests';
}
$popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
}
$trClass = $lineCss . ' popin';
}
$popover = '';
if (!empty($popoverTitle)) {
$popover = sprintf(
' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
);
}
$lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover);
}
}
if ($lines === '') {
return '';
}
$linesTemplate->setVar(['lines' => $lines]);
return $linesTemplate->render();
}
private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string
{
$template->setVar(
[
'lineNumber' => $lineNumber,
'lineContent' => $lineContent,
'class' => $class,
'popover' => $popover,
]
);
return $template->render();
}
private function loadFile(string $file): array
{
if (isset(self::$formattedSourceCache[$file])) {
return self::$formattedSourceCache[$file];
}
$buffer = file_get_contents($file);
$tokens = token_get_all($buffer);
$result = [''];
$i = 0;
$stringFlag = false;
$fileEndsWithNewLine = substr($buffer, -1) === "\n";
unset($buffer);
foreach ($tokens as $j => $token) {
if (is_string($token)) {
if ($token === '"' && $tokens[$j - 1] !== '\\') {
$result[$i] .= sprintf(
'<span class="string">%s</span>',
htmlspecialchars($token, $this->htmlSpecialCharsFlags)
);
$stringFlag = !$stringFlag;
} else {
$result[$i] .= sprintf(
'<span class="keyword">%s</span>',
htmlspecialchars($token, $this->htmlSpecialCharsFlags)
);
}
continue;
}
[$token, $value] = $token;
$value = str_replace(
["\t", ' '],
[' ', ' '],
htmlspecialchars($value, $this->htmlSpecialCharsFlags)
);
if ($value === "\n") {
$result[++$i] = '';
} else {
$lines = explode("\n", $value);
foreach ($lines as $jj => $line) {
$line = trim($line);
if ($line !== '') {
if ($stringFlag) {
$colour = 'string';
} else {
$colour = 'default';
if ($this->isInlineHtml($token)) {
$colour = 'html';
} elseif ($this->isComment($token)) {
$colour = 'comment';
} elseif ($this->isKeyword($token)) {
$colour = 'keyword';
}
}
$result[$i] .= sprintf(
'<span class="%s">%s</span>',
$colour,
$line
);
}
if (isset($lines[$jj + 1])) {
$result[++$i] = '';
}
}
}
}
if ($fileEndsWithNewLine) {
unset($result[count($result) - 1]);
}
self::$formattedSourceCache[$file] = $result;
return $result;
}
private function abbreviateClassName(string $className): string
{
$tmp = explode('\\', $className);
if (count($tmp) > 1) {
$className = sprintf(
'<abbr title="%s">%s</abbr>',
$className,
array_pop($tmp)
);
}
return $className;
}
private function abbreviateMethodName(string $methodName): string
{
$parts = explode('->', $methodName);
if (count($parts) === 2) {
return $this->abbreviateClassName($parts[0]) . '->' . $parts[1];
}
return $methodName;
}
private function createPopoverContentForTest(string $test, array $testData): string
{
$testCSS = '';
if ($testData['fromTestcase']) {
switch ($testData['status']) {
case BaseTestRunner::STATUS_PASSED:
switch ($testData['size']) {
case 'small':
$testCSS = ' class="covered-by-small-tests"';
break;
case 'medium':
$testCSS = ' class="covered-by-medium-tests"';
break;
default:
$testCSS = ' class="covered-by-large-tests"';
break;
}
break;
case BaseTestRunner::STATUS_SKIPPED:
case BaseTestRunner::STATUS_INCOMPLETE:
case BaseTestRunner::STATUS_RISKY:
case BaseTestRunner::STATUS_WARNING:
$testCSS = ' class="warning"';
break;
case BaseTestRunner::STATUS_FAILURE:
case BaseTestRunner::STATUS_ERROR:
$testCSS = ' class="danger"';
break;
}
}
return sprintf(
'<li%s>%s</li>',
$testCSS,
htmlspecialchars($test, $this->htmlSpecialCharsFlags)
);
}
private function isComment(int $token): bool
{
return $token === T_COMMENT || $token === T_DOC_COMMENT;
}
private function isInlineHtml(int $token): bool
{
return $token === T_INLINE_HTML;
}
private function isKeyword(int $token): bool
{
return isset(self::keywordTokens()[$token]);
}
/**
* @psalm-return array<int,true>
*/
private static function keywordTokens(): array
{
if (self::$keywordTokens !== []) {
return self::$keywordTokens;
}
self::$keywordTokens = [
T_ABSTRACT => true,
T_ARRAY => true,
T_AS => true,
T_BREAK => true,
T_CALLABLE => true,
T_CASE => true,
T_CATCH => true,
T_CLASS => true,
T_CLONE => true,
T_CONST => true,
T_CONTINUE => true,
T_DECLARE => true,
T_DEFAULT => true,
T_DO => true,
T_ECHO => true,
T_ELSE => true,
T_ELSEIF => true,
T_EMPTY => true,
T_ENDDECLARE => true,
T_ENDFOR => true,
T_ENDFOREACH => true,
T_ENDIF => true,
T_ENDSWITCH => true,
T_ENDWHILE => true,
T_EVAL => true,
T_EXIT => true,
T_EXTENDS => true,
T_FINAL => true,
T_FINALLY => true,
T_FOR => true,
T_FOREACH => true,
T_FUNCTION => true,
T_GLOBAL => true,
T_GOTO => true,
T_HALT_COMPILER => true,
T_IF => true,
T_IMPLEMENTS => true,
T_INCLUDE => true,
T_INCLUDE_ONCE => true,
T_INSTANCEOF => true,
T_INSTEADOF => true,
T_INTERFACE => true,
T_ISSET => true,
T_LIST => true,
T_NAMESPACE => true,
T_NEW => true,
T_PRINT => true,
T_PRIVATE => true,
T_PROTECTED => true,
T_PUBLIC => true,
T_REQUIRE => true,
T_REQUIRE_ONCE => true,
T_RETURN => true,
T_STATIC => true,
T_SWITCH => true,
T_THROW => true,
T_TRAIT => true,
T_TRY => true,
T_UNSET => true,
T_USE => true,
T_VAR => true,
T_WHILE => true,
T_YIELD => true,
T_YIELD_FROM => true,
];
if (defined('T_FN')) {
self::$keywordTokens[constant('T_FN')] = true;
}
if (defined('T_MATCH')) {
self::$keywordTokens[constant('T_MATCH')] = true;
}
if (defined('T_ENUM')) {
self::$keywordTokens[constant('T_ENUM')] = true;
}
if (defined('T_READONLY')) {
self::$keywordTokens[constant('T_READONLY')] = true;
}
return self::$keywordTokens;
}
}