CodeCoverage.php 18 KB

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