CodeCoverage.php 17 KB

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