CodeCoverage.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. <?php declare(strict_types=1);
  2. /*
  3. * This file is part of phpunit/php-code-coverage.
  4. *
  5. * (c) Sebastian Bergmann <sebastian@phpunit.de>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace SebastianBergmann\CodeCoverage;
  11. use function array_diff;
  12. use function array_diff_key;
  13. use function array_flip;
  14. use function array_keys;
  15. use function array_merge;
  16. use function array_unique;
  17. use function array_values;
  18. use function count;
  19. use function explode;
  20. use function get_class;
  21. use function is_array;
  22. use function sort;
  23. use PHPUnit\Framework\TestCase;
  24. use PHPUnit\Runner\PhptTestCase;
  25. use PHPUnit\Util\Test;
  26. use ReflectionClass;
  27. use SebastianBergmann\CodeCoverage\Driver\Driver;
  28. use SebastianBergmann\CodeCoverage\Node\Builder;
  29. use SebastianBergmann\CodeCoverage\Node\Directory;
  30. use SebastianBergmann\CodeCoverage\StaticAnalysis\CachingFileAnalyser;
  31. use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
  32. use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingFileAnalyser;
  33. use SebastianBergmann\CodeUnitReverseLookup\Wizard;
  34. /**
  35. * Provides collection functionality for PHP code coverage information.
  36. */
  37. final class CodeCoverage
  38. {
  39. private const UNCOVERED_FILES = 'UNCOVERED_FILES';
  40. /**
  41. * @var Driver
  42. */
  43. private $driver;
  44. /**
  45. * @var Filter
  46. */
  47. private $filter;
  48. /**
  49. * @var Wizard
  50. */
  51. private $wizard;
  52. /**
  53. * @var bool
  54. */
  55. private $checkForUnintentionallyCoveredCode = false;
  56. /**
  57. * @var bool
  58. */
  59. private $includeUncoveredFiles = true;
  60. /**
  61. * @var bool
  62. */
  63. private $processUncoveredFiles = false;
  64. /**
  65. * @var bool
  66. */
  67. private $ignoreDeprecatedCode = false;
  68. /**
  69. * @var null|PhptTestCase|string|TestCase
  70. */
  71. private $currentId;
  72. /**
  73. * Code coverage data.
  74. *
  75. * @var ProcessedCodeCoverageData
  76. */
  77. private $data;
  78. /**
  79. * @var bool
  80. */
  81. private $useAnnotationsForIgnoringCode = true;
  82. /**
  83. * Test data.
  84. *
  85. * @var array
  86. */
  87. private $tests = [];
  88. /**
  89. * @psalm-var list<class-string>
  90. */
  91. private $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = [];
  92. /**
  93. * @var ?FileAnalyser
  94. */
  95. private $analyser;
  96. /**
  97. * @var ?string
  98. */
  99. private $cacheDirectory;
  100. /**
  101. * @var ?Directory
  102. */
  103. private $cachedReport;
  104. public function __construct(Driver $driver, Filter $filter)
  105. {
  106. $this->driver = $driver;
  107. $this->filter = $filter;
  108. $this->data = new ProcessedCodeCoverageData;
  109. $this->wizard = new Wizard;
  110. }
  111. /**
  112. * Returns the code coverage information as a graph of node objects.
  113. */
  114. public function getReport(): Directory
  115. {
  116. if ($this->cachedReport === null) {
  117. $this->cachedReport = (new Builder($this->analyser()))->build($this);
  118. }
  119. return $this->cachedReport;
  120. }
  121. /**
  122. * Clears collected code coverage data.
  123. */
  124. public function clear(): void
  125. {
  126. $this->currentId = null;
  127. $this->data = new ProcessedCodeCoverageData;
  128. $this->tests = [];
  129. $this->cachedReport = null;
  130. }
  131. /**
  132. * @internal
  133. */
  134. public function clearCache(): void
  135. {
  136. $this->cachedReport = null;
  137. }
  138. /**
  139. * Returns the filter object used.
  140. */
  141. public function filter(): Filter
  142. {
  143. return $this->filter;
  144. }
  145. /**
  146. * Returns the collected code coverage data.
  147. */
  148. public function getData(bool $raw = false): ProcessedCodeCoverageData
  149. {
  150. if (!$raw) {
  151. if ($this->processUncoveredFiles) {
  152. $this->processUncoveredFilesFromFilter();
  153. } elseif ($this->includeUncoveredFiles) {
  154. $this->addUncoveredFilesFromFilter();
  155. }
  156. }
  157. return $this->data;
  158. }
  159. /**
  160. * Sets the coverage data.
  161. */
  162. public function setData(ProcessedCodeCoverageData $data): void
  163. {
  164. $this->data = $data;
  165. }
  166. /**
  167. * Returns the test data.
  168. */
  169. public function getTests(): array
  170. {
  171. return $this->tests;
  172. }
  173. /**
  174. * Sets the test data.
  175. */
  176. public function setTests(array $tests): void
  177. {
  178. $this->tests = $tests;
  179. }
  180. /**
  181. * Start collection of code coverage information.
  182. *
  183. * @param PhptTestCase|string|TestCase $id
  184. */
  185. public function start($id, bool $clear = false): void
  186. {
  187. if ($clear) {
  188. $this->clear();
  189. }
  190. $this->currentId = $id;
  191. $this->driver->start();
  192. $this->cachedReport = null;
  193. }
  194. /**
  195. * Stop collection of code coverage information.
  196. *
  197. * @param array|false $linesToBeCovered
  198. */
  199. public function stop(bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = []): RawCodeCoverageData
  200. {
  201. if (!is_array($linesToBeCovered) && $linesToBeCovered !== false) {
  202. throw new InvalidArgumentException(
  203. '$linesToBeCovered must be an array or false'
  204. );
  205. }
  206. $data = $this->driver->stop();
  207. $this->append($data, null, $append, $linesToBeCovered, $linesToBeUsed);
  208. $this->currentId = null;
  209. $this->cachedReport = null;
  210. return $data;
  211. }
  212. /**
  213. * Appends code coverage data.
  214. *
  215. * @param PhptTestCase|string|TestCase $id
  216. * @param array|false $linesToBeCovered
  217. *
  218. * @throws ReflectionException
  219. * @throws TestIdMissingException
  220. * @throws UnintentionallyCoveredCodeException
  221. */
  222. public function append(RawCodeCoverageData $rawData, $id = null, bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = []): void
  223. {
  224. if ($id === null) {
  225. $id = $this->currentId;
  226. }
  227. if ($id === null) {
  228. throw new TestIdMissingException;
  229. }
  230. $this->cachedReport = null;
  231. $this->applyFilter($rawData);
  232. $this->applyExecutableLinesFilter($rawData);
  233. if ($this->useAnnotationsForIgnoringCode) {
  234. $this->applyIgnoredLinesFilter($rawData);
  235. }
  236. $this->data->initializeUnseenData($rawData);
  237. if (!$append) {
  238. return;
  239. }
  240. if ($id !== self::UNCOVERED_FILES) {
  241. $this->applyCoversAnnotationFilter(
  242. $rawData,
  243. $linesToBeCovered,
  244. $linesToBeUsed
  245. );
  246. if (empty($rawData->lineCoverage())) {
  247. return;
  248. }
  249. $size = 'unknown';
  250. $status = -1;
  251. $fromTestcase = false;
  252. if ($id instanceof TestCase) {
  253. $fromTestcase = true;
  254. $_size = $id->getSize();
  255. if ($_size === Test::SMALL) {
  256. $size = 'small';
  257. } elseif ($_size === Test::MEDIUM) {
  258. $size = 'medium';
  259. } elseif ($_size === Test::LARGE) {
  260. $size = 'large';
  261. }
  262. $status = $id->getStatus();
  263. $id = get_class($id) . '::' . $id->getName();
  264. } elseif ($id instanceof PhptTestCase) {
  265. $fromTestcase = true;
  266. $size = 'large';
  267. $id = $id->getName();
  268. }
  269. $this->tests[$id] = ['size' => $size, 'status' => $status, 'fromTestcase' => $fromTestcase];
  270. $this->data->markCodeAsExecutedByTestCase($id, $rawData);
  271. }
  272. }
  273. /**
  274. * Merges the data from another instance.
  275. */
  276. public function merge(self $that): void
  277. {
  278. $this->filter->includeFiles(
  279. $that->filter()->files()
  280. );
  281. $this->data->merge($that->data);
  282. $this->tests = array_merge($this->tests, $that->getTests());
  283. $this->cachedReport = null;
  284. }
  285. public function enableCheckForUnintentionallyCoveredCode(): void
  286. {
  287. $this->checkForUnintentionallyCoveredCode = true;
  288. }
  289. public function disableCheckForUnintentionallyCoveredCode(): void
  290. {
  291. $this->checkForUnintentionallyCoveredCode = false;
  292. }
  293. public function includeUncoveredFiles(): void
  294. {
  295. $this->includeUncoveredFiles = true;
  296. }
  297. public function excludeUncoveredFiles(): void
  298. {
  299. $this->includeUncoveredFiles = false;
  300. }
  301. public function processUncoveredFiles(): void
  302. {
  303. $this->processUncoveredFiles = true;
  304. }
  305. public function doNotProcessUncoveredFiles(): void
  306. {
  307. $this->processUncoveredFiles = false;
  308. }
  309. public function enableAnnotationsForIgnoringCode(): void
  310. {
  311. $this->useAnnotationsForIgnoringCode = true;
  312. }
  313. public function disableAnnotationsForIgnoringCode(): void
  314. {
  315. $this->useAnnotationsForIgnoringCode = false;
  316. }
  317. public function ignoreDeprecatedCode(): void
  318. {
  319. $this->ignoreDeprecatedCode = true;
  320. }
  321. public function doNotIgnoreDeprecatedCode(): void
  322. {
  323. $this->ignoreDeprecatedCode = false;
  324. }
  325. /**
  326. * @psalm-assert-if-true !null $this->cacheDirectory
  327. */
  328. public function cachesStaticAnalysis(): bool
  329. {
  330. return $this->cacheDirectory !== null;
  331. }
  332. public function cacheStaticAnalysis(string $directory): void
  333. {
  334. $this->cacheDirectory = $directory;
  335. }
  336. public function doNotCacheStaticAnalysis(): void
  337. {
  338. $this->cacheDirectory = null;
  339. }
  340. /**
  341. * @throws StaticAnalysisCacheNotConfiguredException
  342. */
  343. public function cacheDirectory(): string
  344. {
  345. if (!$this->cachesStaticAnalysis()) {
  346. throw new StaticAnalysisCacheNotConfiguredException(
  347. 'The static analysis cache is not configured'
  348. );
  349. }
  350. return $this->cacheDirectory;
  351. }
  352. /**
  353. * @psalm-param class-string $className
  354. */
  355. public function excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck(string $className): void
  356. {
  357. $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck[] = $className;
  358. }
  359. public function enableBranchAndPathCoverage(): void
  360. {
  361. $this->driver->enableBranchAndPathCoverage();
  362. }
  363. public function disableBranchAndPathCoverage(): void
  364. {
  365. $this->driver->disableBranchAndPathCoverage();
  366. }
  367. public function collectsBranchAndPathCoverage(): bool
  368. {
  369. return $this->driver->collectsBranchAndPathCoverage();
  370. }
  371. public function detectsDeadCode(): bool
  372. {
  373. return $this->driver->detectsDeadCode();
  374. }
  375. /**
  376. * Applies the @covers annotation filtering.
  377. *
  378. * @param array|false $linesToBeCovered
  379. *
  380. * @throws ReflectionException
  381. * @throws UnintentionallyCoveredCodeException
  382. */
  383. private function applyCoversAnnotationFilter(RawCodeCoverageData $rawData, $linesToBeCovered, array $linesToBeUsed): void
  384. {
  385. if ($linesToBeCovered === false) {
  386. $rawData->clear();
  387. return;
  388. }
  389. if (empty($linesToBeCovered)) {
  390. return;
  391. }
  392. if ($this->checkForUnintentionallyCoveredCode &&
  393. (!$this->currentId instanceof TestCase ||
  394. (!$this->currentId->isMedium() && !$this->currentId->isLarge()))) {
  395. $this->performUnintentionallyCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed);
  396. }
  397. $rawLineData = $rawData->lineCoverage();
  398. $filesWithNoCoverage = array_diff_key($rawLineData, $linesToBeCovered);
  399. foreach (array_keys($filesWithNoCoverage) as $fileWithNoCoverage) {
  400. $rawData->removeCoverageDataForFile($fileWithNoCoverage);
  401. }
  402. if (is_array($linesToBeCovered)) {
  403. foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) {
  404. $rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
  405. $rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
  406. }
  407. }
  408. }
  409. private function applyFilter(RawCodeCoverageData $data): void
  410. {
  411. if ($this->filter->isEmpty()) {
  412. return;
  413. }
  414. foreach (array_keys($data->lineCoverage()) as $filename) {
  415. if ($this->filter->isExcluded($filename)) {
  416. $data->removeCoverageDataForFile($filename);
  417. }
  418. }
  419. }
  420. private function applyExecutableLinesFilter(RawCodeCoverageData $data): void
  421. {
  422. foreach (array_keys($data->lineCoverage()) as $filename) {
  423. if (!$this->filter->isFile($filename)) {
  424. continue;
  425. }
  426. $linesToBranchMap = $this->analyser()->executableLinesIn($filename);
  427. $data->keepLineCoverageDataOnlyForLines(
  428. $filename,
  429. array_keys($linesToBranchMap)
  430. );
  431. $data->markExecutableLineByBranch(
  432. $filename,
  433. $linesToBranchMap
  434. );
  435. }
  436. }
  437. private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void
  438. {
  439. foreach (array_keys($data->lineCoverage()) as $filename) {
  440. if (!$this->filter->isFile($filename)) {
  441. continue;
  442. }
  443. $data->removeCoverageDataForLines(
  444. $filename,
  445. $this->analyser()->ignoredLinesFor($filename)
  446. );
  447. }
  448. }
  449. /**
  450. * @throws UnintentionallyCoveredCodeException
  451. */
  452. private function addUncoveredFilesFromFilter(): void
  453. {
  454. $uncoveredFiles = array_diff(
  455. $this->filter->files(),
  456. $this->data->coveredFiles()
  457. );
  458. foreach ($uncoveredFiles as $uncoveredFile) {
  459. if ($this->filter->isFile($uncoveredFile)) {
  460. $this->append(
  461. RawCodeCoverageData::fromUncoveredFile(
  462. $uncoveredFile,
  463. $this->analyser()
  464. ),
  465. self::UNCOVERED_FILES
  466. );
  467. }
  468. }
  469. }
  470. /**
  471. * @throws UnintentionallyCoveredCodeException
  472. */
  473. private function processUncoveredFilesFromFilter(): void
  474. {
  475. $uncoveredFiles = array_diff(
  476. $this->filter->files(),
  477. $this->data->coveredFiles()
  478. );
  479. $this->driver->start();
  480. foreach ($uncoveredFiles as $uncoveredFile) {
  481. if ($this->filter->isFile($uncoveredFile)) {
  482. include_once $uncoveredFile;
  483. }
  484. }
  485. $this->append($this->driver->stop(), self::UNCOVERED_FILES);
  486. }
  487. /**
  488. * @throws ReflectionException
  489. * @throws UnintentionallyCoveredCodeException
  490. */
  491. private function performUnintentionallyCoveredCodeCheck(RawCodeCoverageData $data, array $linesToBeCovered, array $linesToBeUsed): void
  492. {
  493. $allowedLines = $this->getAllowedLines(
  494. $linesToBeCovered,
  495. $linesToBeUsed
  496. );
  497. $unintentionallyCoveredUnits = [];
  498. foreach ($data->lineCoverage() as $file => $_data) {
  499. foreach ($_data as $line => $flag) {
  500. if ($flag === 1 && !isset($allowedLines[$file][$line])) {
  501. $unintentionallyCoveredUnits[] = $this->wizard->lookup($file, $line);
  502. }
  503. }
  504. }
  505. $unintentionallyCoveredUnits = $this->processUnintentionallyCoveredUnits($unintentionallyCoveredUnits);
  506. if (!empty($unintentionallyCoveredUnits)) {
  507. throw new UnintentionallyCoveredCodeException(
  508. $unintentionallyCoveredUnits
  509. );
  510. }
  511. }
  512. private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): array
  513. {
  514. $allowedLines = [];
  515. foreach (array_keys($linesToBeCovered) as $file) {
  516. if (!isset($allowedLines[$file])) {
  517. $allowedLines[$file] = [];
  518. }
  519. $allowedLines[$file] = array_merge(
  520. $allowedLines[$file],
  521. $linesToBeCovered[$file]
  522. );
  523. }
  524. foreach (array_keys($linesToBeUsed) as $file) {
  525. if (!isset($allowedLines[$file])) {
  526. $allowedLines[$file] = [];
  527. }
  528. $allowedLines[$file] = array_merge(
  529. $allowedLines[$file],
  530. $linesToBeUsed[$file]
  531. );
  532. }
  533. foreach (array_keys($allowedLines) as $file) {
  534. $allowedLines[$file] = array_flip(
  535. array_unique($allowedLines[$file])
  536. );
  537. }
  538. return $allowedLines;
  539. }
  540. /**
  541. * @throws ReflectionException
  542. */
  543. private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array
  544. {
  545. $unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits);
  546. sort($unintentionallyCoveredUnits);
  547. foreach (array_keys($unintentionallyCoveredUnits) as $k => $v) {
  548. $unit = explode('::', $unintentionallyCoveredUnits[$k]);
  549. if (count($unit) !== 2) {
  550. continue;
  551. }
  552. try {
  553. $class = new ReflectionClass($unit[0]);
  554. foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) {
  555. if ($class->isSubclassOf($parentClass)) {
  556. unset($unintentionallyCoveredUnits[$k]);
  557. break;
  558. }
  559. }
  560. } catch (\ReflectionException $e) {
  561. throw new ReflectionException(
  562. $e->getMessage(),
  563. $e->getCode(),
  564. $e
  565. );
  566. }
  567. }
  568. return array_values($unintentionallyCoveredUnits);
  569. }
  570. private function analyser(): FileAnalyser
  571. {
  572. if ($this->analyser !== null) {
  573. return $this->analyser;
  574. }
  575. $this->analyser = new ParsingFileAnalyser(
  576. $this->useAnnotationsForIgnoringCode,
  577. $this->ignoreDeprecatedCode
  578. );
  579. if ($this->cachesStaticAnalysis()) {
  580. $this->analyser = new CachingFileAnalyser(
  581. $this->cacheDirectory,
  582. $this->analyser,
  583. $this->useAnnotationsForIgnoringCode,
  584. $this->ignoreDeprecatedCode
  585. );
  586. }
  587. return $this->analyser;
  588. }
  589. }