diff --git a/.gitattributes b/.gitattributes index e09791c8a..e389d472f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,14 @@ -/.github export-ignore -/.php_cs.dist export-ignore -/.psalm export-ignore -/build.xml export-ignore -/phive.xml export-ignore -/phpunit.xml export-ignore -/tests export-ignore -/tools export-ignore -/tools/* binary +/.gitattributes export-ignore +/.gitignore export-ignore +/.github export-ignore +/.phive export-ignore +/.php-cs-fixer.dist.php export-ignore +/build export-ignore +/build.xml export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/tests export-ignore +/tools export-ignore +/tools/* binary *.php diff=php diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..ee242a803 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,28 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic + addresses, without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer at sebastian@phpunit.de. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 33392505b..fedcef3bc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1 +1,106 @@ -Please refer to [https://github.com/sebastianbergmann/phpunit/blob/master/CONTRIBUTING.md](https://github.com/sebastianbergmann/phpunit/blob/master/.github/CONTRIBUTING.md) for details on how to contribute to this project. +# Contributing to `phpunit/php-code-coverage` + +## Welcome! + +We look forward to your contributions! Here are some examples how you can contribute: + +* [Report a bug](https://github.com/sebastianbergmann/php-code-coverage/issues/new) +* [Send a pull request to fix a bug](https://github.com/sebastianbergmann/php-code-coverage/pulls) + + +## We have a Code of Conduct + +Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. + + +## Any contributions you make will be under the BSD-3-Clause License + +When you submit code changes, your submissions are understood to be under the same [BSD-3-Clause License](https://github.com/sebastianbergmann/php-code-coverage/blob/main/LICENSE) that covers the project. By contributing to this project, you agree that your contributions will be licensed under its BSD-3-Clause License. + + +### Do Not Violate Copyright + +Only submit a pull request with your own original code. Do NOT submit a pull request containing code which you have largely copied from +another project, unless you wrote the respective code yourself. + +Open Source does not mean that copyright does not apply. Copyright infringements will not be tolerated and can lead to you being banned from this project and repository. + + +### Do Not Submit AI-Generated Pull Requests + +The same goes for (largely) AI-generated pull requests. These are not welcome as they will be based on copyrighted code from others +without accreditation and without taking the license of the original code into account, let alone getting permission +for the use of the code or for re-licensing. + +Aside from that, the experience is that AI-generated pull requests will be incorrect 100% of the time and cost reviewers too much time. +Submitting a (largely) AI-generated pull request will lead to you being banned from this project and repository. + + +## Write bug reports with detail, background, and sample code + +[This is an example](https://github.com/sebastianbergmann/phpunit/issues/4376) of a bug report I wrote, and I think it's not too bad. + +In your bug report, please provide the following: + +* A quick summary and/or background +* Steps to reproduce + * Be specific! + * Give sample code if you can. +* What you expected would happen +* What actually happens +* Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +Please do not report a bug for a version of this library that is no longer supported. Please do not report a bug if you are using a version of PHP that is not supported by the version of this library you are using. + +The library that is developed in this repository was either extracted from [PHPUnit](https://github.com/sebastianbergmann/phpunit) or developed specifically as a dependency for PHPUnit. Support for this library follows the [support for the version of PHPUnit that uses a specific version of this library](https://phpunit.de/supported-versions.html). + +Please post code and output as text ([using proper markup](https://guides.github.com/features/mastering-markdown/)). Do not post screenshots of code or output. + + +## Workflow for Pull Requests + +1. Fork the repository. +2. Create your branch from `main` if you plan to implement new functionality or change existing code significantly; create your branch from the oldest branch that is affected by the bug if you plan to fix a bug. +3. Implement your change and add tests for it. +4. Ensure the test suite passes. +5. Ensure the code complies with our coding guidelines (see below). +6. Send that pull request! + +Please make sure you have [set up your username and email address](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup) for use with Git. Strings such as `silly nick name ` look really stupid in the commit history of a project. + +We encourage you to [sign your Git commits with your GPG key](https://docs.github.com/en/github/authenticating-to-github/signing-commits). + +Pull requests for bug fixes must be made for the oldest branch that is supported (see above). Pull requests for new features must be based on the `main` branch. + +We are trying to keep backwards compatibility breaks to an absolute minimum. Please take this into account when proposing changes. + +Due to time constraints, we are not always able to respond as quickly as we would like. Please do not take delays personal and feel free to remind us if you feel that we forgot to respond. + + +## Development + +This project uses [PHPUnit](https://phpunit.de/) for testing: + +```shell +./vendor/bin/phpunit +``` + +This project uses [PHPStan](https://phpstan.org/) for static analysis: + +```shell +./tools/phpstan +``` + +This project uses [PHP-CS-Fixer](https://cs.symfony.com/) to enforce coding guidelines: + +```shell +./tools/php-cs-fixer fix +``` + +The commands shown above require an autoloader script at `vendor/autoload.php`. This can be generated like so: + +```shell +./tools/composer dump-autoload +``` + +Please understand that we will not accept a pull request when its changes violate this project's coding guidelines or break the test suite. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c2fba0f90..d40ffea35 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,4 @@ github: sebastianbergmann +liberapay: sebastianbergmann +thanks_dev: u/gh/sebastianbergmann +tidelift: "packagist/phpunit/php-code-coverage" diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index dc8e3b02f..5ca75c07d 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -2,7 +2,8 @@ | --------------------------| --------------- | php-code-coverage version | x.y.z | PHP version | x.y.z -| Driver | Xdebug / PHPDBG +| Driver | PCOV / Xdebug +| PCOV version (if used) | x.y.z | Xdebug version (if used) | x.y.z | Installation Method | Composer / PHPUnit PHAR | Usage Method | PHPUnit / other @@ -11,6 +12,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ChangeLog-12.3.md b/ChangeLog-12.3.md new file mode 100644 index 000000000..f076381c5 --- /dev/null +++ b/ChangeLog-12.3.md @@ -0,0 +1,31 @@ +# ChangeLog + +All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. + +## [12.3.2] - 2025-07-29 + +### Changed + +* Add coverage and complexity columns to class and method complexity tables +* Add CRAP to graph tooltip + +### Fixed + +* [#1081](https://github.com/sebastianbergmann/php-code-coverage/issues/1081): Class complexity scatter chart tooltips show incorrect class + +## [12.3.1] - 2025-06-18 + +### Changed + +* Changed CSS for HTML report to not use common ligatures as this sometimes lead to hard-to-read code +* Updated Bootstrap to version 5.3.6 for HTML report + +## [12.3.0] - 2025-05-23 + +### Changed + +* [#1080](https://github.com/sebastianbergmann/php-code-coverage/pull/1080): Support for reporting code coverage information in OpenClover XML format; unlike the existing Clover XML reporter, which remains unchanged, the XML documents generated by this new reporter validate against the OpenClover project's XML schema definition, with one exception: we do not generate the `` element. This feature is experimental and the generated XML might change in order to improve compliance with the OpenClover project's XML schema definition further. Such changes will be made in bugfix and/or minor releases even if they break backward compatibility. + +[12.3.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.1...12.3.2 +[12.3.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.0...12.3.1 +[12.3.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.2.1...12.3.0 diff --git a/ChangeLog.md b/ChangeLog.md deleted file mode 100644 index 762037d1a..000000000 --- a/ChangeLog.md +++ /dev/null @@ -1,176 +0,0 @@ -# ChangeLog - -All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [9.0.0] - 2020-MM-DD - -### Added - -* [#761](https://github.com/sebastianbergmann/php-code-coverage/pull/761): Support for Branch Coverage and Path Coverage - -### Changed - -* [#746](https://github.com/sebastianbergmann/php-code-coverage/pull/746): Remove some ancient workarounds for very old Xdebug versions -* [#747](https://github.com/sebastianbergmann/php-code-coverage/pull/747): Use native filtering in PCOV and Xdebug drivers -* [#748](https://github.com/sebastianbergmann/php-code-coverage/pull/748): Store raw code coverage in value objects instead of arrays -* [#749](https://github.com/sebastianbergmann/php-code-coverage/pull/749): Store processed code coverage in value objects instead of arrays -* [#752](https://github.com/sebastianbergmann/php-code-coverage/pull/752): Rework how code coverage settings are propagated to the driver -* [#754](https://github.com/sebastianbergmann/php-code-coverage/pull/754): Implement collection of raw branch and path coverage -* [#755](https://github.com/sebastianbergmann/php-code-coverage/pull/755): Implement processing of raw branch and path coverage -* [#756](https://github.com/sebastianbergmann/php-code-coverage/pull/756): Improve handling of uncovered files - -## [8.0.2] - 2020-05-23 - -### Fixed - -* [#750](https://github.com/sebastianbergmann/php-code-coverage/pull/750): Inconsistent handling of namespaces -* [#751](https://github.com/sebastianbergmann/php-code-coverage/pull/751): Dead code is not highlighted correctly -* [#753](https://github.com/sebastianbergmann/php-code-coverage/issues/753): Do not use `$_SERVER['REQUEST_TIME']` because the test(ed) code might unset it - -## [8.0.1] - 2020-02-19 - -### Fixed - -* [#731](https://github.com/sebastianbergmann/php-code-coverage/pull/731): Confusing footer in the HTML report - -## [8.0.0] - 2020-02-07 - -### Fixed - -* [#721](https://github.com/sebastianbergmann/php-code-coverage/pull/721): Workaround for PHP bug [#79191](https://bugs.php.net/bug.php?id=79191) - -### Removed - -* This component is no longer supported on PHP 7.2 - -## [7.0.10] - 2019-11-20 - -### Fixed - -* [#710](https://github.com/sebastianbergmann/php-code-coverage/pull/710): Code Coverage does not work in PhpStorm - -## [7.0.9] - 2019-11-20 - -### Changed - -* [#709](https://github.com/sebastianbergmann/php-code-coverage/pull/709): Prioritize PCOV over Xdebug - -## [7.0.8] - 2019-09-17 - -### Changed - -* Update HTML report Bootstrap 4.3.1, jQuery 3.4.1, and popper.js 1.15.0 - -## [7.0.7] - 2019-07-25 - -### Changed - -* Bumped required version of php-token-stream - -## [7.0.6] - 2019-07-08 - -### Changed - -* Bumped required version of php-token-stream - -## [7.0.5] - 2019-06-06 - -### Fixed - -* [#681](https://github.com/sebastianbergmann/php-code-coverage/pull/681): `use function` statements are not ignored - -## [7.0.4] - 2019-05-29 - -### Fixed - -* [#682](https://github.com/sebastianbergmann/php-code-coverage/pull/682): Code that is not executed is reported as being executed when using PCOV - -## [7.0.3] - 2019-02-26 - -### Fixed - -* [#671](https://github.com/sebastianbergmann/php-code-coverage/issues/671): `TypeError` when directory name is a number - -## [7.0.2] - 2019-02-15 - -### Changed - -* Updated HTML report to Bootstrap 4.3.0 - -### Fixed - -* [#667](https://github.com/sebastianbergmann/php-code-coverage/pull/667): `TypeError` in PHP reporter - -## [7.0.1] - 2019-02-01 - -### Fixed - -* [#664](https://github.com/sebastianbergmann/php-code-coverage/issues/664): `TypeError` when whitelisted file does not exist - -## [7.0.0] - 2019-02-01 - -### Added - -* [#663](https://github.com/sebastianbergmann/php-code-coverage/pull/663): Support for PCOV - -### Fixed - -* [#654](https://github.com/sebastianbergmann/php-code-coverage/issues/654): HTML report fails to load assets -* [#655](https://github.com/sebastianbergmann/php-code-coverage/issues/655): Popin pops in outside of screen - -### Removed - -* This component is no longer supported on PHP 7.1 - -## [6.1.4] - 2018-10-31 - -### Fixed - -* [#650](https://github.com/sebastianbergmann/php-code-coverage/issues/650): Wasted screen space in HTML code coverage report - -## [6.1.3] - 2018-10-23 - -### Changed - -* Use `^3.1` of `sebastian/environment` again due to [regression](https://github.com/sebastianbergmann/environment/issues/31) - -## [6.1.2] - 2018-10-23 - -### Fixed - -* [#645](https://github.com/sebastianbergmann/php-code-coverage/pull/645): Crash that can occur when php-token-stream parses invalid files - -## [6.1.1] - 2018-10-18 - -### Changed - -* This component now allows `^4` of `sebastian/environment` - -## [6.1.0] - 2018-10-16 - -### Changed - -* Class names are now abbreviated (unqualified name shown, fully qualified name shown on hover) in the file view of the HTML report -* Update HTML report to Bootstrap 4 - -[9.0.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/8.0...9.0.0 -[8.0.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/8.0.1...8.0.2 -[8.0.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/8.0.0...8.0.1 -[8.0.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.10...8.0.0 -[7.0.10]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.9...7.0.10 -[7.0.9]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.8...7.0.9 -[7.0.8]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.7...7.0.8 -[7.0.7]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.6...7.0.7 -[7.0.6]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.5...7.0.6 -[7.0.5]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.4...7.0.5 -[7.0.4]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.3...7.0.4 -[7.0.3]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.2...7.0.3 -[7.0.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.1...7.0.2 -[7.0.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/7.0.0...7.0.1 -[7.0.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/6.1.4...7.0.0 -[6.1.4]: https://github.com/sebastianbergmann/php-code-coverage/compare/6.1.3...6.1.4 -[6.1.3]: https://github.com/sebastianbergmann/php-code-coverage/compare/6.1.2...6.1.3 -[6.1.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/6.1.1...6.1.2 -[6.1.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/6.1.0...6.1.1 -[6.1.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/6.0...6.1.0 - diff --git a/LICENSE b/LICENSE index 34bc89d95..017eb48b1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,33 +1,29 @@ -php-code-coverage +BSD 3-Clause License -Copyright (c) 2009-2020, Sebastian Bergmann . +Copyright (c) 2009-2025, Sebastian Bergmann All rights reserved. Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: +modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the - distribution. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. - * Neither the name of Sebastian Bergmann nor the names of his - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index fcd3b901d..f5d452724 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # phpunit/php-code-coverage -[![Latest Stable Version](https://poser.pugx.org/phpunit/php-code-coverage/v/stable.png)](https://packagist.org/packages/phpunit/php-code-coverage) +[![Latest Stable Version](https://poser.pugx.org/phpunit/php-code-coverage/v)](https://packagist.org/packages/phpunit/php-code-coverage) [![CI Status](https://github.com/sebastianbergmann/php-code-coverage/workflows/CI/badge.svg)](https://github.com/sebastianbergmann/php-code-coverage/actions) -[![Type Coverage](https://shepherd.dev/github/sebastianbergmann/php-code-coverage/coverage.svg)](https://shepherd.dev/github/sebastianbergmann/php-code-coverage) +[![codecov](https://codecov.io/gh/sebastianbergmann/php-code-coverage/branch/main/graph/badge.svg)](https://codecov.io/gh/sebastianbergmann/php-code-coverage) Provides collection, processing, and rendering functionality for PHP code coverage information. @@ -23,12 +23,25 @@ composer require --dev phpunit/php-code-coverage ## Usage ```php -filter()->addDirectoryToWhitelist('/path/to/src'); +$filter->includeFiles( + [ + '/path/to/file.php', + '/path/to/another_file.php', + ] +); + +$coverage = new CodeCoverage( + (new Selector)->forLineCoverage($filter), + $filter +); $coverage->start(''); @@ -36,10 +49,6 @@ $coverage->start(''); $coverage->stop(); -$writer = new \SebastianBergmann\CodeCoverage\Report\Clover; -$writer->process($coverage, '/tmp/clover.xml'); -$writer = new \SebastianBergmann\CodeCoverage\Report\Html\Facade; -$writer->process($coverage, '/tmp/code-coverage-report'); +(new HtmlReport)->process($coverage, '/tmp/code-coverage-report'); ``` - diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..d88ff0019 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +If you believe you have found a security vulnerability in the library that is developed in this repository, please report it to us through coordinated disclosure. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, please email `sebastian@phpunit.de`. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + +* The type of issue +* Full paths of source file(s) related to the manifestation of the issue +* The location of the affected source code (tag/branch/commit or direct URL) +* Any special configuration required to reproduce the issue +* Step-by-step instructions to reproduce the issue +* Proof-of-concept or exploit code (if possible) +* Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Web Context + +The library that is developed in this repository was either extracted from [PHPUnit](https://github.com/sebastianbergmann/phpunit) or developed specifically as a dependency for PHPUnit. + +The library is developed with a focus on development environments and the command-line. No specific testing or hardening with regard to using the library in an HTTP or web context or with untrusted input data is performed. The library might also contain functionality that intentionally exposes internal application data for debugging purposes. + +If the library is used in a web application, the application developer is responsible for filtering inputs or escaping outputs as necessary and for verifying that the used functionality is safe for use within the intended context. + +Vulnerabilities specific to the use outside a development context will be fixed as applicable, provided that the fix does not have an averse effect on the primary use case for development purposes. + diff --git a/build.xml b/build.xml index 6526f4f65..daf51c7b1 100644 --- a/build.xml +++ b/build.xml @@ -16,15 +16,43 @@ - - - + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + diff --git a/build/scripts/extract-release-notes.php b/build/scripts/extract-release-notes.php new file mode 100755 index 000000000..390143980 --- /dev/null +++ b/build/scripts/extract-release-notes.php @@ -0,0 +1,47 @@ +#!/usr/bin/env php +' . PHP_EOL; + + exit(1); +} + +$version = $argv[1]; +$versionSeries = explode('.', $version)[0] . '.' . explode('.', $version)[1]; + +$file = __DIR__ . '/../../ChangeLog-' . $versionSeries . '.md'; + +if (!is_file($file) || !is_readable($file)) { + print $file . ' cannot be read' . PHP_EOL; + + exit(1); +} + +$buffer = ''; +$append = false; + +foreach (file($file) as $line) { + if (str_starts_with($line, '## [' . $version . ']')) { + $append = true; + + continue; + } + + if ($append && (str_starts_with($line, '## ') || str_starts_with($line, '['))) { + break; + } + + if ($append) { + $buffer .= $line; + } +} + +$buffer = trim($buffer); + +if ($buffer === '') { + print 'Unable to extract release notes' . PHP_EOL; + + exit(1); +} + +print $buffer . PHP_EOL; diff --git a/composer.json b/composer.json index 276b9a174..bfd990ca1 100644 --- a/composer.json +++ b/composer.json @@ -17,34 +17,37 @@ } ], "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy" }, "config": { "platform": { - "php": "7.3.0" + "php": "8.3.0" }, "optimize-autoloader": true, "sort-packages": true }, "prefer-stable": true, "require": { - "php": "^7.3", + "php": ">=8.3", "ext-dom": "*", + "ext-libxml": "*", "ext-xmlwriter": "*", - "phpunit/php-file-iterator": "^3.0", - "phpunit/php-token-stream": "^4.0", - "phpunit/php-text-template": "^2.0", - "sebastian/code-unit-reverse-lookup": "^2.0", - "sebastian/environment": "^5.0", - "sebastian/version": "^3.0", - "theseer/tokenizer": "^1.1.3" + "nikic/php-parser": "^5.4.0", + "phpunit/php-file-iterator": "^6.0", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^12.1" }, "suggest": { - "ext-pcov": "*", - "ext-xdebug": "*" + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "autoload": { "classmap": [ @@ -52,14 +55,13 @@ ] }, "autoload-dev": { - "files": [ - "tests/TestCase.php", - "tests/_files/BankAccountTest.php" + "classmap": [ + "tests/" ] }, "extra": { "branch-alias": { - "dev-master": "8.9-dev" + "dev-main": "12.3.x-dev" } } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..7d7f95975 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,57 @@ +parameters: + level: 5 + paths: + - src + - tests/tests + - tests/bootstrap.php + + checkTooWideReturnTypesInProtectedAndPublicMethods: true + reportAlwaysTrueInLastCondition: true + reportPossiblyNonexistentConstantArrayOffset: true + reportPossiblyNonexistentGeneralArrayOffset: true + treatPhpDocTypesAsCertain: false + + strictRules: + allRules: false + booleansInConditions: true + closureUsesThis: true + disallowedBacktick: true + disallowedEmpty: true + disallowedImplicitArrayCreation: true + disallowedLooseComparison: true + disallowedShortTernary: true + illegalConstructorMethodCall: true + matchingInheritedMethodNames: true + noVariableVariables: true + numericOperandsInArithmeticOperators: true + overwriteVariablesWithLoop: true + requireParentConstructorCall: true + strictArrayFilter: true + strictFunctionCalls: true + switchConditionsMatchingType: true + uselessCast: true + + ergebnis: + allRules: false + final: + enabled: true + classesNotRequiredToBeAbstractOrFinal: + - SebastianBergmann\CodeCoverage\Report\Xml\File + + type_coverage: + declare: 100 + return: 100 + param: 100 + property: 100 + constant: 100 + + ignoreErrors: + # Ignore errors caused by defensive programming + - '#Call to function assert\(\) with true will always evaluate to true.#' + - '#Call to method .* will always evaluate to true.#' + - '#Call to method .* will always evaluate to false.#' + - '#Instanceof between .* and .* will always evaluate to true.#' + - '#SebastianBergmann\\CodeCoverage\\Node\\Iterator::current\(\) should be covariant with return type#' + +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon diff --git a/phpunit.xml b/phpunit.xml index b53a88510..c7f22893f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,22 +1,26 @@ + displayDetailsOnPhpunitDeprecations="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true" + colors="true"> - tests/tests + tests/tests - - - src - - + + + src + + diff --git a/src/CodeCoverage.php b/src/CodeCoverage.php index 0f764d006..bedc11927 100644 --- a/src/CodeCoverage.php +++ b/src/CodeCoverage.php @@ -9,143 +9,70 @@ */ namespace SebastianBergmann\CodeCoverage; -use PHPUnit\Framework\TestCase; -use PHPUnit\Runner\PhptTestCase; -use PHPUnit\Util\Test; +use function array_diff; +use function array_diff_key; +use function array_flip; +use function array_keys; +use function array_merge; +use function array_unique; +use function count; +use function explode; +use function is_file; +use function sort; +use ReflectionClass; +use SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; use SebastianBergmann\CodeCoverage\Driver\Driver; -use SebastianBergmann\CodeCoverage\Driver\PcovDriver; -use SebastianBergmann\CodeCoverage\Driver\PhpdbgDriver; -use SebastianBergmann\CodeCoverage\Driver\XdebugDriver; use SebastianBergmann\CodeCoverage\Node\Builder; use SebastianBergmann\CodeCoverage\Node\Directory; -use SebastianBergmann\CodeUnitReverseLookup\Wizard; -use SebastianBergmann\Environment\Runtime; +use SebastianBergmann\CodeCoverage\StaticAnalysis\CachingSourceAnalyser; +use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser; +use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser; +use SebastianBergmann\CodeCoverage\Test\Target\MapBuilder; +use SebastianBergmann\CodeCoverage\Test\Target\Mapper; +use SebastianBergmann\CodeCoverage\Test\Target\TargetCollection; +use SebastianBergmann\CodeCoverage\Test\Target\TargetCollectionValidator; +use SebastianBergmann\CodeCoverage\Test\Target\ValidationResult; +use SebastianBergmann\CodeCoverage\Test\TestSize\TestSize; +use SebastianBergmann\CodeCoverage\Test\TestStatus\TestStatus; /** * Provides collection functionality for PHP code coverage information. + * + * @phpstan-type TestType array{size: string, status: string} + * @phpstan-type TargetedLines array> */ final class CodeCoverage { - private const UNCOVERED_FILES_FROM_WHITELIST = 'UNCOVERED_FILES_FROM_WHITELIST'; - - /** - * @var Driver - */ - private $driver; - - /** - * @var Filter - */ - private $filter; - - /** - * @var Wizard - */ - private $wizard; - - /** - * @var bool - */ - private $cacheTokens = false; - - /** - * @var bool - */ - private $checkForUnintentionallyCoveredCode = false; - - /** - * @var bool - */ - private $forceCoversAnnotation = false; - - /** - * @var bool - */ - private $checkForUnexecutedCoveredCode = false; - - /** - * @var bool - */ - private $checkForMissingCoversAnnotation = false; - - /** - * @var bool - */ - private $addUncoveredFilesFromWhitelist = true; - - /** - * @var bool - */ - private $processUncoveredFilesFromWhitelist = false; + private const string UNCOVERED_FILES = 'UNCOVERED_FILES'; + private readonly Driver $driver; + private readonly Filter $filter; + private ?Mapper $targetMapper = null; + private bool $checkForUnintentionallyCoveredCode = false; + private bool $includeUncoveredFiles = true; + private bool $ignoreDeprecatedCode = false; + private ?string $currentId = null; + private ?TestSize $currentSize = null; + private ProcessedCodeCoverageData $data; + private bool $useAnnotationsForIgnoringCode = true; /** - * @var bool + * @var array */ - private $ignoreDeprecatedCode = false; + private array $tests = []; /** - * @var PhptTestCase|string|TestCase + * @var list */ - private $currentId; + private array $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = []; + private ?FileAnalyser $analyser = null; + private ?string $cacheDirectory = null; + private ?Directory $cachedReport = null; - /** - * Code coverage data. - * - * @var ProcessedCodeCoverageData - */ - private $data; - - /** - * @var array - */ - private $ignoredLines = []; - - /** - * @var bool - */ - private $disableIgnoredLines = false; - - /** - * Test data. - * - * @var array - */ - private $tests = []; - - /** - * @var string[] - */ - private $unintentionallyCoveredSubclassesWhitelist = []; - - /** - * Determine if the data has been initialized or not - * - * @var bool - */ - private $isInitialized = false; - - /** - * @var Directory - */ - private $report; - - /** - * @throws RuntimeException - */ - public function __construct(Driver $driver = null, Filter $filter = null) + public function __construct(Driver $driver, Filter $filter) { - if ($filter === null) { - $filter = new Filter; - } - - if ($driver === null) { - $driver = $this->selectDriver($filter); - } - $this->driver = $driver; $this->filter = $filter; - - $this->wizard = new Wizard; $this->data = new ProcessedCodeCoverageData; } @@ -154,11 +81,11 @@ public function __construct(Driver $driver = null, Filter $filter = null) */ public function getReport(): Directory { - if ($this->report === null) { - $this->report = (new Builder)->build($this); + if ($this->cachedReport === null) { + $this->cachedReport = (new Builder($this->analyser()))->build($this); } - return $this->report; + return $this->cachedReport; } /** @@ -166,11 +93,19 @@ public function getReport(): Directory */ public function clear(): void { - $this->isInitialized = false; - $this->currentId = null; - $this->data = new ProcessedCodeCoverageData(); - $this->tests = []; - $this->report = null; + $this->currentId = null; + $this->currentSize = null; + $this->data = new ProcessedCodeCoverageData; + $this->tests = []; + $this->cachedReport = null; + } + + /** + * @internal + */ + public function clearCache(): void + { + $this->cachedReport = null; } /** @@ -186,8 +121,10 @@ public function filter(): Filter */ public function getData(bool $raw = false): ProcessedCodeCoverageData { - if (!$raw && $this->addUncoveredFilesFromWhitelist) { - $this->addUncoveredFilesFromWhitelist(); + if (!$raw) { + if ($this->includeUncoveredFiles) { + $this->addUncoveredFilesFromFilter(); + } } return $this->data; @@ -198,12 +135,11 @@ public function getData(bool $raw = false): ProcessedCodeCoverageData */ public function setData(ProcessedCodeCoverageData $data): void { - $this->data = $data; - $this->report = null; + $this->data = $data; } /** - * Returns the test data. + * @return array */ public function getTests(): array { @@ -211,203 +147,217 @@ public function getTests(): array } /** - * Sets the test data. + * @param array $tests */ public function setTests(array $tests): void { $this->tests = $tests; } - /** - * Start collection of code coverage information. - * - * @param PhptTestCase|string|TestCase $id - * - * @throws RuntimeException - */ - public function start($id, bool $clear = false): void + public function start(string $id, ?TestSize $size = null, bool $clear = false): void { if ($clear) { $this->clear(); } - if ($this->isInitialized === false) { - $this->initializeData(); - } - - $this->currentId = $id; + $this->currentId = $id; + $this->currentSize = $size; $this->driver->start(); + + $this->cachedReport = null; } - /** - * Stop collection of code coverage information. - * - * @param array|false $linesToBeCovered - * - * @throws MissingCoversAnnotationException - * @throws CoveredCodeNotExecutedException - * @throws RuntimeException - * @throws \ReflectionException - */ - public function stop(bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = [], bool $ignoreForceCoversAnnotation = false): RawCodeCoverageData + public function stop(bool $append = true, ?TestStatus $status = null, null|false|TargetCollection $covers = null, ?TargetCollection $uses = null): RawCodeCoverageData { - if (!\is_array($linesToBeCovered) && $linesToBeCovered !== false) { - throw new InvalidArgumentException( - '$linesToBeCovered must be an array or false' - ); - } - $data = $this->driver->stop(); - $this->append($data, null, $append, $linesToBeCovered, $linesToBeUsed, $ignoreForceCoversAnnotation); - $this->currentId = null; + $this->append($data, null, $append, $status, $covers, $uses); + + $this->currentId = null; + $this->currentSize = null; + $this->cachedReport = null; return $data; } /** - * Appends code coverage data. - * - * @param PhptTestCase|string|TestCase $id - * @param array|false $linesToBeCovered - * - * @throws \SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException - * @throws \SebastianBergmann\CodeCoverage\MissingCoversAnnotationException - * @throws \SebastianBergmann\CodeCoverage\CoveredCodeNotExecutedException - * @throws \ReflectionException - * @throws RuntimeException + * @throws ReflectionException + * @throws TestIdMissingException + * @throws UnintentionallyCoveredCodeException */ - public function append(RawCodeCoverageData $rawData, $id = null, bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = [], bool $ignoreForceCoversAnnotation = false): void + public function append(RawCodeCoverageData $rawData, ?string $id = null, bool $append = true, ?TestStatus $status = null, null|false|TargetCollection $covers = null, ?TargetCollection $uses = null): void { if ($id === null) { $id = $this->currentId; } if ($id === null) { - throw new RuntimeException; + throw new TestIdMissingException; } - $this->applyWhitelistFilter($rawData); - $this->applyIgnoredLinesFilter($rawData); + if ($status === null) { + $status = TestStatus::unknown(); + } - $this->data->initializeFilesThatAreSeenTheFirstTime($rawData); + if ($covers === null) { + $covers = TargetCollection::fromArray([]); + } - if (!$append) { - return; + if ($uses === null) { + $uses = TargetCollection::fromArray([]); } - if ($id !== self::UNCOVERED_FILES_FROM_WHITELIST) { - $this->applyCoversAnnotationFilter( - $rawData, - $linesToBeCovered, - $linesToBeUsed, - $ignoreForceCoversAnnotation - ); + $size = $this->currentSize; - if (empty($rawData->lineCoverage())) { - return; - } + if ($size === null) { + $size = TestSize::unknown(); + } - $size = 'unknown'; - $status = -1; + $this->cachedReport = null; - if ($id instanceof TestCase) { - $_size = $id->getSize(); + $this->applyFilter($rawData); - if ($_size === Test::SMALL) { - $size = 'small'; - } elseif ($_size === Test::MEDIUM) { - $size = 'medium'; - } elseif ($_size === Test::LARGE) { - $size = 'large'; - } + $this->applyExecutableLinesFilter($rawData); - $status = $id->getStatus(); - $id = \get_class($id) . '::' . $id->getName(); - } elseif ($id instanceof PhptTestCase) { - $size = 'large'; - $id = $id->getName(); - } + if ($this->useAnnotationsForIgnoringCode) { + $this->applyIgnoredLinesFilter($rawData); + } + + $this->data->initializeUnseenData($rawData); + + if (!$append) { + return; + } - $this->tests[$id] = ['size' => $size, 'status' => $status]; + if ($id === self::UNCOVERED_FILES) { + return; + } + + $linesToBeCovered = false; + $linesToBeUsed = []; + + if ($covers !== false) { + $linesToBeCovered = $this->targetMapper()->mapTargets($covers); + } - $this->data->markCodeAsExecutedByTestCase($id, $rawData); + if ($linesToBeCovered !== false) { + $linesToBeUsed = $this->targetMapper()->mapTargets($uses); } - $this->report = null; + $this->applyCoversAndUsesFilter( + $rawData, + $linesToBeCovered, + $linesToBeUsed, + $size, + ); + + if ($rawData->lineCoverage() === []) { + return; + } + + $this->tests[$id] = [ + 'size' => $size->asString(), + 'status' => $status->asString(), + ]; + + $this->data->markCodeAsExecutedByTestCase($id, $rawData); } /** * Merges the data from another instance. - * - * @param CodeCoverage $that */ public function merge(self $that): void { - $this->filter->setWhitelistedFiles( - \array_merge($this->filter->getWhitelistedFiles(), $that->filter()->getWhitelistedFiles()) + $this->filter->includeFiles( + $that->filter()->files(), ); $this->data->merge($that->data); - $this->tests = \array_merge($this->tests, $that->getTests()); - $this->report = null; + $this->tests = array_merge($this->tests, $that->getTests()); + + $this->cachedReport = null; } - public function setCacheTokens(bool $flag): void + public function enableCheckForUnintentionallyCoveredCode(): void { - $this->cacheTokens = $flag; + $this->checkForUnintentionallyCoveredCode = true; } - public function getCacheTokens(): bool + public function disableCheckForUnintentionallyCoveredCode(): void { - return $this->cacheTokens; + $this->checkForUnintentionallyCoveredCode = false; } - public function setCheckForUnintentionallyCoveredCode(bool $flag): void + public function includeUncoveredFiles(): void { - $this->checkForUnintentionallyCoveredCode = $flag; + $this->includeUncoveredFiles = true; } - public function setForceCoversAnnotation(bool $flag): void + public function excludeUncoveredFiles(): void { - $this->forceCoversAnnotation = $flag; + $this->includeUncoveredFiles = false; } - public function setCheckForMissingCoversAnnotation(bool $flag): void + public function enableAnnotationsForIgnoringCode(): void { - $this->checkForMissingCoversAnnotation = $flag; + $this->useAnnotationsForIgnoringCode = true; } - public function setCheckForUnexecutedCoveredCode(bool $flag): void + public function disableAnnotationsForIgnoringCode(): void { - $this->checkForUnexecutedCoveredCode = $flag; + $this->useAnnotationsForIgnoringCode = false; } - public function setAddUncoveredFilesFromWhitelist(bool $flag): void + public function ignoreDeprecatedCode(): void { - $this->addUncoveredFilesFromWhitelist = $flag; + $this->ignoreDeprecatedCode = true; } - public function setProcessUncoveredFilesFromWhitelist(bool $flag): void + public function doNotIgnoreDeprecatedCode(): void { - $this->processUncoveredFilesFromWhitelist = $flag; + $this->ignoreDeprecatedCode = false; } - public function setDisableIgnoredLines(bool $flag): void + /** + * @phpstan-assert-if-true !null $this->cacheDirectory + */ + public function cachesStaticAnalysis(): bool { - $this->disableIgnoredLines = $flag; + return $this->cacheDirectory !== null; } - public function setIgnoreDeprecatedCode(bool $flag): void + public function cacheStaticAnalysis(string $directory): void { - $this->ignoreDeprecatedCode = $flag; + $this->cacheDirectory = $directory; } - public function setUnintentionallyCoveredSubclassesWhitelist(array $whitelist): void + public function doNotCacheStaticAnalysis(): void { - $this->unintentionallyCoveredSubclassesWhitelist = $whitelist; + $this->cacheDirectory = null; + } + + /** + * @throws StaticAnalysisCacheNotConfiguredException + */ + public function cacheDirectory(): string + { + if (!$this->cachesStaticAnalysis()) { + throw new StaticAnalysisCacheNotConfiguredException( + 'The static analysis cache is not configured', + ); + } + + return $this->cacheDirectory; + } + + /** + * @param class-string $className + */ + public function excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck(string $className): void + { + $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck[] = $className; } public function enableBranchAndPathCoverage(): void @@ -425,213 +375,130 @@ public function collectsBranchAndPathCoverage(): bool return $this->driver->collectsBranchAndPathCoverage(); } - public function detectsDeadCode(): bool + public function validate(TargetCollection $targets): ValidationResult { - return $this->driver->detectsDeadCode(); + return (new TargetCollectionValidator)->validate($this->targetMapper(), $targets); } /** - * Applies the @covers annotation filtering. + * @param false|TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed * - * @param array|false $linesToBeCovered - * - * @throws \SebastianBergmann\CodeCoverage\CoveredCodeNotExecutedException - * @throws \ReflectionException - * @throws MissingCoversAnnotationException + * @throws ReflectionException * @throws UnintentionallyCoveredCodeException */ - private function applyCoversAnnotationFilter(RawCodeCoverageData $rawData, $linesToBeCovered, array $linesToBeUsed, bool $ignoreForceCoversAnnotation): void + private function applyCoversAndUsesFilter(RawCodeCoverageData $rawData, array|false $linesToBeCovered, array $linesToBeUsed, TestSize $size): void { - if ($linesToBeCovered === false || - ($this->forceCoversAnnotation && empty($linesToBeCovered) && !$ignoreForceCoversAnnotation)) { - if ($this->checkForMissingCoversAnnotation) { - throw new MissingCoversAnnotationException; - } - + if ($linesToBeCovered === false) { $rawData->clear(); return; } - if (empty($linesToBeCovered)) { + if ($linesToBeCovered === []) { return; } - if ($this->checkForUnintentionallyCoveredCode && - (!$this->currentId instanceof TestCase || - (!$this->currentId->isMedium() && !$this->currentId->isLarge()))) { + if ($this->checkForUnintentionallyCoveredCode && !$size->isMedium() && !$size->isLarge()) { $this->performUnintentionallyCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed); } - if ($this->checkForUnexecutedCoveredCode) { - $this->performUnexecutedCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed); - } - $rawLineData = $rawData->lineCoverage(); - $filesWithNoCoverage = \array_diff_key($rawLineData, $linesToBeCovered); + $filesWithNoCoverage = array_diff_key($rawLineData, $linesToBeCovered); - foreach (\array_keys($filesWithNoCoverage) as $fileWithNoCoverage) { + foreach (array_keys($filesWithNoCoverage) as $fileWithNoCoverage) { $rawData->removeCoverageDataForFile($fileWithNoCoverage); } - if (\is_array($linesToBeCovered)) { - foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) { - $rawData->keepCoverageDataOnlyForLines($fileToBeCovered, $includedLines); - } + foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) { + $rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines); + $rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines); } } - private function applyWhitelistFilter(RawCodeCoverageData $data): void + private function applyFilter(RawCodeCoverageData $data): void { - foreach (\array_keys($data->lineCoverage()) as $filename) { - if ($this->filter->isFiltered($filename)) { + if ($this->filter->isEmpty()) { + return; + } + + foreach (array_keys($data->lineCoverage()) as $filename) { + if ($this->filter->isExcluded($filename)) { $data->removeCoverageDataForFile($filename); } } } - private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void + private function applyExecutableLinesFilter(RawCodeCoverageData $data): void { - foreach (\array_keys($data->lineCoverage()) as $filename) { - $data->removeCoverageDataForLines($filename, $this->getLinesToBeIgnored($filename)); - } - } + foreach (array_keys($data->lineCoverage()) as $filename) { + if (!$this->filter->isFile($filename)) { + continue; + } - /** - * @throws CoveredCodeNotExecutedException - * @throws MissingCoversAnnotationException - * @throws RuntimeException - * @throws UnintentionallyCoveredCodeException - * @throws \ReflectionException - */ - private function addUncoveredFilesFromWhitelist(): void - { - $uncoveredFiles = \array_diff( - $this->filter->getWhitelist(), - \array_keys($this->data->lineCoverage()) - ); + $linesToBranchMap = $this->analyser()->analyse($filename)->executableLines(); - foreach ($uncoveredFiles as $uncoveredFile) { - if (\file_exists($uncoveredFile)) { - if ($this->cacheTokens) { - $tokens = \PHP_Token_Stream_CachingFactory::get($uncoveredFile); - } else { - $tokens = new \PHP_Token_Stream($uncoveredFile); - } + $data->keepLineCoverageDataOnlyForLines( + $filename, + array_keys($linesToBranchMap), + ); - $this->append(RawCodeCoverageData::fromUncoveredFile($uncoveredFile, $tokens), self::UNCOVERED_FILES_FROM_WHITELIST); - } + $data->markExecutableLineByBranch( + $filename, + $linesToBranchMap, + ); } } - private function getLinesToBeIgnored(string $fileName): array + private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void { - if (isset($this->ignoredLines[$fileName])) { - return $this->ignoredLines[$fileName]; - } + foreach (array_keys($data->lineCoverage()) as $filename) { + if (!$this->filter->isFile($filename)) { + continue; + } - try { - return $this->getLinesToBeIgnoredInner($fileName); - } catch (\OutOfBoundsException $e) { - // This can happen with PHP_Token_Stream if the file is syntactically invalid, - // and probably affects a file that wasn't executed. - return []; + $data->removeCoverageDataForLines( + $filename, + $this->analyser()->analyse($filename)->ignoredLines(), + ); } } - private function getLinesToBeIgnoredInner(string $fileName): array + /** + * @throws UnintentionallyCoveredCodeException + */ + private function addUncoveredFilesFromFilter(): void { - $this->ignoredLines[$fileName] = []; - - $lines = \file($fileName); - - if ($this->cacheTokens) { - $tokens = \PHP_Token_Stream_CachingFactory::get($fileName); - } else { - $tokens = new \PHP_Token_Stream($fileName); - } - - if ($this->disableIgnoredLines) { - $this->ignoredLines[$fileName] = \array_unique($this->ignoredLines[$fileName]); - \sort($this->ignoredLines[$fileName]); - - return $this->ignoredLines[$fileName]; - } - - $ignore = false; - $stop = false; - - foreach ($tokens->tokens() as $token) { - switch (\get_class($token)) { - case \PHP_Token_COMMENT::class: - case \PHP_Token_DOC_COMMENT::class: - $_token = \trim((string) $token); - $_line = \trim($lines[$token->getLine() - 1]); - - if ($_token === '// @codeCoverageIgnore' || - $_token === '//@codeCoverageIgnore') { - $ignore = true; - $stop = true; - } elseif ($_token === '// @codeCoverageIgnoreStart' || - $_token === '//@codeCoverageIgnoreStart') { - $ignore = true; - } elseif ($_token === '// @codeCoverageIgnoreEnd' || - $_token === '//@codeCoverageIgnoreEnd') { - $stop = true; - } - - break; - - // Intentional fallthrough - - case \PHP_Token_INTERFACE::class: - case \PHP_Token_TRAIT::class: - case \PHP_Token_CLASS::class: - case \PHP_Token_FUNCTION::class: - /* @var \PHP_Token_Interface $token */ - - $docblock = (string) $token->getDocblock(); - - if (\strpos($docblock, '@codeCoverageIgnore') || ($this->ignoreDeprecatedCode && \strpos($docblock, '@deprecated'))) { - $endLine = $token->getEndLine(); - - for ($i = $token->getLine(); $i <= $endLine; $i++) { - $this->ignoredLines[$fileName][] = $i; - } - } - - break; - } - - if ($ignore) { - $this->ignoredLines[$fileName][] = $token->getLine(); + $uncoveredFiles = array_diff( + $this->filter->files(), + $this->data->coveredFiles(), + ); - if ($stop) { - $ignore = false; - $stop = false; - } + foreach ($uncoveredFiles as $uncoveredFile) { + if (is_file($uncoveredFile)) { + $this->append( + RawCodeCoverageData::fromUncoveredFile( + $uncoveredFile, + $this->analyser(), + ), + self::UNCOVERED_FILES, + ); } } - - $this->ignoredLines[$fileName] = \array_unique( - $this->ignoredLines[$fileName] - ); - - $this->ignoredLines[$fileName] = \array_unique($this->ignoredLines[$fileName]); - \sort($this->ignoredLines[$fileName]); - - return $this->ignoredLines[$fileName]; } /** - * @throws \ReflectionException + * @param TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed + * + * @throws ReflectionException * @throws UnintentionallyCoveredCodeException */ private function performUnintentionallyCoveredCodeCheck(RawCodeCoverageData $data, array $linesToBeCovered, array $linesToBeUsed): void { $allowedLines = $this->getAllowedLines( $linesToBeCovered, - $linesToBeUsed + $linesToBeUsed, ); $unintentionallyCoveredUnits = []; @@ -639,80 +506,55 @@ private function performUnintentionallyCoveredCodeCheck(RawCodeCoverageData $dat foreach ($data->lineCoverage() as $file => $_data) { foreach ($_data as $line => $flag) { if ($flag === 1 && !isset($allowedLines[$file][$line])) { - $unintentionallyCoveredUnits[] = $this->wizard->lookup($file, $line); + $unintentionallyCoveredUnits[] = $this->targetMapper->lookup($file, $line); } } } $unintentionallyCoveredUnits = $this->processUnintentionallyCoveredUnits($unintentionallyCoveredUnits); - if (!empty($unintentionallyCoveredUnits)) { + if ($unintentionallyCoveredUnits !== []) { throw new UnintentionallyCoveredCodeException( - $unintentionallyCoveredUnits + $unintentionallyCoveredUnits, ); } } /** - * @throws CoveredCodeNotExecutedException + * @param TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed + * + * @return TargetedLines */ - private function performUnexecutedCoveredCodeCheck(RawCodeCoverageData $rawData, array $linesToBeCovered, array $linesToBeUsed): void - { - $executedCodeUnits = $this->coverageToCodeUnits($rawData); - $message = ''; - - foreach ($this->linesToCodeUnits($linesToBeCovered) as $codeUnit) { - if (!\in_array($codeUnit, $executedCodeUnits)) { - $message .= \sprintf( - '- %s is expected to be executed (@covers) but was not executed' . "\n", - $codeUnit - ); - } - } - - foreach ($this->linesToCodeUnits($linesToBeUsed) as $codeUnit) { - if (!\in_array($codeUnit, $executedCodeUnits)) { - $message .= \sprintf( - '- %s is expected to be executed (@uses) but was not executed' . "\n", - $codeUnit - ); - } - } - - if (!empty($message)) { - throw new CoveredCodeNotExecutedException($message); - } - } - private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): array { $allowedLines = []; - foreach (\array_keys($linesToBeCovered) as $file) { + foreach (array_keys($linesToBeCovered) as $file) { if (!isset($allowedLines[$file])) { $allowedLines[$file] = []; } - $allowedLines[$file] = \array_merge( + $allowedLines[$file] = array_merge( $allowedLines[$file], - $linesToBeCovered[$file] + $linesToBeCovered[$file], ); } - foreach (\array_keys($linesToBeUsed) as $file) { + foreach (array_keys($linesToBeUsed) as $file) { if (!isset($allowedLines[$file])) { $allowedLines[$file] = []; } - $allowedLines[$file] = \array_merge( + $allowedLines[$file] = array_merge( $allowedLines[$file], - $linesToBeUsed[$file] + $linesToBeUsed[$file], ); } - foreach (\array_keys($allowedLines) as $file) { - $allowedLines[$file] = \array_flip( - \array_unique($allowedLines[$file]) + foreach (array_keys($allowedLines) as $file) { + $allowedLines[$file] = array_flip( + array_unique($allowedLines[$file]), ); } @@ -720,112 +562,86 @@ private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): } /** - * @throws RuntimeException + * @param list $unintentionallyCoveredUnits + * + * @throws ReflectionException + * + * @return list */ - private function selectDriver(Filter $filter): Driver - { - $runtime = new Runtime; - - if ($runtime->hasPHPDBGCodeCoverage()) { - return new PhpdbgDriver; - } - - if ($runtime->hasPCOV()) { - return new PcovDriver($filter); - } - - if ($runtime->hasXdebug()) { - return new XdebugDriver($filter); - } - - throw new RuntimeException('No code coverage driver available'); - } - private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array { - $unintentionallyCoveredUnits = \array_unique($unintentionallyCoveredUnits); - \sort($unintentionallyCoveredUnits); + $unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits); + $processed = []; - foreach (\array_keys($unintentionallyCoveredUnits) as $k => $v) { - $unit = \explode('::', $unintentionallyCoveredUnits[$k]); + foreach ($unintentionallyCoveredUnits as $unintentionallyCoveredUnit) { + $tmp = explode('::', $unintentionallyCoveredUnit); + + if (count($tmp) !== 2) { + $processed[] = $unintentionallyCoveredUnit; - if (\count($unit) !== 2) { continue; } - $class = new \ReflectionClass($unit[0]); - - foreach ($this->unintentionallyCoveredSubclassesWhitelist as $whitelisted) { - if ($class->isSubclassOf($whitelisted)) { - unset($unintentionallyCoveredUnits[$k]); + try { + $class = new ReflectionClass($tmp[0]); - break; + foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) { + if ($class->isSubclassOf($parentClass)) { + continue 2; + } } - } - } - - return \array_values($unintentionallyCoveredUnits); - } - - /** - * @throws CoveredCodeNotExecutedException - * @throws MissingCoversAnnotationException - * @throws RuntimeException - * @throws UnintentionallyCoveredCodeException - * @throws \ReflectionException - */ - private function initializeData(): void - { - $this->isInitialized = true; - - if ($this->processUncoveredFilesFromWhitelist) { - // by collecting dead code data here on an initial pass, future runs with test data do not need to - if ($this->driver->canDetectDeadCode()) { - $this->driver->enableDeadCodeDetection(); + } catch (\ReflectionException $e) { + throw new ReflectionException( + $e->getMessage(), + $e->getCode(), + $e, + ); } - $this->driver->start(); + $processed[] = $tmp[0]; + } - foreach ($this->filter->getWhitelist() as $file) { - if ($this->filter->isFile($file)) { - include_once $file; - } - } + $processed = array_unique($processed); - // having now collected dead code for the entire whitelist, we can safely skip this data on subsequent runs - if ($this->driver->canDetectDeadCode()) { - $this->driver->disableDeadCodeDetection(); - } + sort($processed); - $this->append($this->driver->stop(), self::UNCOVERED_FILES_FROM_WHITELIST); - } + return $processed; } - private function coverageToCodeUnits(RawCodeCoverageData $rawData): array + private function targetMapper(): Mapper { - $codeUnits = []; - - foreach ($rawData->lineCoverage() as $filename => $lines) { - foreach ($lines as $line => $flag) { - if ($flag === 1) { - $codeUnits[] = $this->wizard->lookup($filename, $line); - } - } + if ($this->targetMapper !== null) { + return $this->targetMapper; } - return \array_unique($codeUnits); + $this->targetMapper = new Mapper( + (new MapBuilder)->build($this->filter, $this->analyser()), + ); + + return $this->targetMapper; } - private function linesToCodeUnits(array $data): array + private function analyser(): FileAnalyser { - $codeUnits = []; + if ($this->analyser !== null) { + return $this->analyser; + } - foreach ($data as $filename => $lines) { - foreach ($lines as $line) { - $codeUnits[] = $this->wizard->lookup($filename, $line); - } + $sourceAnalyser = new ParsingSourceAnalyser; + + if ($this->cachesStaticAnalysis()) { + $sourceAnalyser = new CachingSourceAnalyser( + $this->cacheDirectory, + $sourceAnalyser, + ); } - return \array_unique($codeUnits); + $this->analyser = new FileAnalyser( + $sourceAnalyser, + $this->useAnnotationsForIgnoringCode, + $this->ignoreDeprecatedCode, + ); + + return $this->analyser; } } diff --git a/src/Data/ProcessedCodeCoverageData.php b/src/Data/ProcessedCodeCoverageData.php new file mode 100644 index 000000000..57ccbb166 --- /dev/null +++ b/src/Data/ProcessedCodeCoverageData.php @@ -0,0 +1,283 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Data; + +use function array_key_exists; +use function array_keys; +use function array_merge; +use function array_unique; +use function count; +use function is_array; +use function ksort; +use SebastianBergmann\CodeCoverage\Driver\Driver; +use SebastianBergmann\CodeCoverage\Driver\XdebugDriver; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type XdebugFunctionCoverageType from XdebugDriver + * + * @phpstan-type TestIdType string + * @phpstan-type FunctionCoverageDataType array{ + * branches: array, + * out: array, + * out_hit: array, + * }>, + * paths: array, + * hit: list, + * }>, + * hit: list + * } + * @phpstan-type FunctionCoverageType array> + */ +final class ProcessedCodeCoverageData +{ + /** + * Line coverage data. + * An array of filenames, each having an array of linenumbers, each executable line having an array of testcase ids. + * + * @var array>> + */ + private array $lineCoverage = []; + + /** + * Function coverage data. + * Maintains base format of raw data (@see https://xdebug.org/docs/code_coverage), but each 'hit' entry is an array + * of testcase ids. + * + * @var FunctionCoverageType + */ + private array $functionCoverage = []; + + public function initializeUnseenData(RawCodeCoverageData $rawData): void + { + foreach ($rawData->lineCoverage() as $file => $lines) { + if (!isset($this->lineCoverage[$file])) { + $this->lineCoverage[$file] = []; + + foreach ($lines as $k => $v) { + $this->lineCoverage[$file][$k] = $v === Driver::LINE_NOT_EXECUTABLE ? null : []; + } + } + } + + foreach ($rawData->functionCoverage() as $file => $functions) { + foreach ($functions as $functionName => $functionData) { + if (isset($this->functionCoverage[$file][$functionName])) { + $this->initPreviouslySeenFunction($file, $functionName, $functionData); + } else { + $this->initPreviouslyUnseenFunction($file, $functionName, $functionData); + } + } + } + } + + public function markCodeAsExecutedByTestCase(string $testCaseId, RawCodeCoverageData $executedCode): void + { + foreach ($executedCode->lineCoverage() as $file => $lines) { + foreach ($lines as $k => $v) { + if ($v === Driver::LINE_EXECUTED) { + $this->lineCoverage[$file][$k][] = $testCaseId; + } + } + } + + foreach ($executedCode->functionCoverage() as $file => $functions) { + foreach ($functions as $functionName => $functionData) { + foreach ($functionData['branches'] as $branchId => $branchData) { + if ($branchData['hit'] === Driver::BRANCH_HIT) { + $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'][] = $testCaseId; + } + } + + foreach ($functionData['paths'] as $pathId => $pathData) { + if ($pathData['hit'] === Driver::BRANCH_HIT) { + $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'][] = $testCaseId; + } + } + } + } + } + + public function setLineCoverage(array $lineCoverage): void + { + $this->lineCoverage = $lineCoverage; + } + + public function lineCoverage(): array + { + ksort($this->lineCoverage); + + return $this->lineCoverage; + } + + public function setFunctionCoverage(array $functionCoverage): void + { + $this->functionCoverage = $functionCoverage; + } + + public function functionCoverage(): array + { + ksort($this->functionCoverage); + + return $this->functionCoverage; + } + + public function coveredFiles(): array + { + ksort($this->lineCoverage); + + return array_keys($this->lineCoverage); + } + + public function renameFile(string $oldFile, string $newFile): void + { + $this->lineCoverage[$newFile] = $this->lineCoverage[$oldFile]; + + if (isset($this->functionCoverage[$oldFile])) { + $this->functionCoverage[$newFile] = $this->functionCoverage[$oldFile]; + } + + unset($this->lineCoverage[$oldFile], $this->functionCoverage[$oldFile]); + } + + public function merge(self $newData): void + { + foreach ($newData->lineCoverage as $file => $lines) { + if (!isset($this->lineCoverage[$file])) { + $this->lineCoverage[$file] = $lines; + + continue; + } + + // we should compare the lines if any of two contains data + $compareLineNumbers = array_unique( + array_merge( + array_keys($this->lineCoverage[$file]), + array_keys($newData->lineCoverage[$file]), + ), + ); + + foreach ($compareLineNumbers as $line) { + $thatPriority = $this->priorityForLine($newData->lineCoverage[$file], $line); + $thisPriority = $this->priorityForLine($this->lineCoverage[$file], $line); + + if ($thatPriority > $thisPriority) { + $this->lineCoverage[$file][$line] = $newData->lineCoverage[$file][$line]; + } elseif ($thatPriority === $thisPriority && is_array($this->lineCoverage[$file][$line])) { + $this->lineCoverage[$file][$line] = array_unique( + array_merge($this->lineCoverage[$file][$line], $newData->lineCoverage[$file][$line]), + ); + } + } + } + + foreach ($newData->functionCoverage as $file => $functions) { + if (!isset($this->functionCoverage[$file])) { + $this->functionCoverage[$file] = $functions; + + continue; + } + + foreach ($functions as $functionName => $functionData) { + if (isset($this->functionCoverage[$file][$functionName])) { + $this->initPreviouslySeenFunction($file, $functionName, $functionData); + } else { + $this->initPreviouslyUnseenFunction($file, $functionName, $functionData); + } + + foreach ($functionData['branches'] as $branchId => $branchData) { + $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'] = array_unique(array_merge($this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'], $branchData['hit'])); + } + + foreach ($functionData['paths'] as $pathId => $pathData) { + $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'] = array_unique(array_merge($this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'], $pathData['hit'])); + } + } + } + } + + /** + * Determine the priority for a line. + * + * 1 = the line is not set + * 2 = the line has not been tested + * 3 = the line is dead code + * 4 = the line has been tested + * + * During a merge, a higher number is better. + * + * @return 1|2|3|4 + */ + private function priorityForLine(array $data, int $line): int + { + if (!array_key_exists($line, $data)) { + return 1; + } + + if (is_array($data[$line]) && count($data[$line]) === 0) { + return 2; + } + + if ($data[$line] === null) { + return 3; + } + + return 4; + } + + /** + * For a function we have never seen before, copy all data over and simply init the 'hit' array. + * + * @param FunctionCoverageDataType|XdebugFunctionCoverageType $functionData + */ + private function initPreviouslyUnseenFunction(string $file, string $functionName, array $functionData): void + { + $this->functionCoverage[$file][$functionName] = $functionData; + + foreach (array_keys($functionData['branches']) as $branchId) { + $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'] = []; + } + + foreach (array_keys($functionData['paths']) as $pathId) { + $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'] = []; + } + } + + /** + * For a function we have seen before, only copy over and init the 'hit' array for any unseen branches and paths. + * Techniques such as mocking and where the contents of a file are different vary during tests (e.g. compiling + * containers) mean that the functions inside a file cannot be relied upon to be static. + * + * @param FunctionCoverageDataType|XdebugFunctionCoverageType $functionData + */ + private function initPreviouslySeenFunction(string $file, string $functionName, array $functionData): void + { + foreach ($functionData['branches'] as $branchId => $branchData) { + if (!isset($this->functionCoverage[$file][$functionName]['branches'][$branchId])) { + $this->functionCoverage[$file][$functionName]['branches'][$branchId] = $branchData; + $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'] = []; + } + } + + foreach ($functionData['paths'] as $pathId => $pathData) { + if (!isset($this->functionCoverage[$file][$functionName]['paths'][$pathId])) { + $this->functionCoverage[$file][$functionName]['paths'][$pathId] = $pathData; + $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'] = []; + } + } + } +} diff --git a/src/Data/RawCodeCoverageData.php b/src/Data/RawCodeCoverageData.php new file mode 100644 index 000000000..f6e847ae3 --- /dev/null +++ b/src/Data/RawCodeCoverageData.php @@ -0,0 +1,285 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Data; + +use function array_diff; +use function array_diff_key; +use function array_flip; +use function array_intersect; +use function array_intersect_key; +use function array_map; +use function count; +use function explode; +use function file_get_contents; +use function in_array; +use function is_file; +use function preg_replace; +use function range; +use function str_ends_with; +use function str_starts_with; +use function trim; +use SebastianBergmann\CodeCoverage\Driver\Driver; +use SebastianBergmann\CodeCoverage\Driver\XdebugDriver; +use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type XdebugFunctionsCoverageType from XdebugDriver + * @phpstan-import-type XdebugCodeCoverageWithoutPathCoverageType from XdebugDriver + * @phpstan-import-type XdebugCodeCoverageWithPathCoverageType from XdebugDriver + */ +final class RawCodeCoverageData +{ + /** + * @var array> + */ + private static array $emptyLineCache = []; + + /** + * @var XdebugCodeCoverageWithoutPathCoverageType + */ + private array $lineCoverage; + + /** + * @var array + */ + private array $functionCoverage; + + /** + * @param XdebugCodeCoverageWithoutPathCoverageType $rawCoverage + */ + public static function fromXdebugWithoutPathCoverage(array $rawCoverage): self + { + return new self($rawCoverage, []); + } + + /** + * @param XdebugCodeCoverageWithPathCoverageType $rawCoverage + */ + public static function fromXdebugWithPathCoverage(array $rawCoverage): self + { + $lineCoverage = []; + $functionCoverage = []; + + foreach ($rawCoverage as $file => $fileCoverageData) { + // Xdebug annotates the function name of traits, strip that off + foreach ($fileCoverageData['functions'] as $existingKey => $data) { + if (str_ends_with($existingKey, '}') && !str_starts_with($existingKey, '{')) { // don't want to catch {main} + $newKey = preg_replace('/\{.*}$/', '', $existingKey); + $fileCoverageData['functions'][$newKey] = $data; + unset($fileCoverageData['functions'][$existingKey]); + } + } + + $lineCoverage[$file] = $fileCoverageData['lines']; + $functionCoverage[$file] = $fileCoverageData['functions']; + } + + return new self($lineCoverage, $functionCoverage); + } + + public static function fromUncoveredFile(string $filename, FileAnalyser $analyser): self + { + $lineCoverage = array_map( + static fn (): int => Driver::LINE_NOT_EXECUTED, + $analyser->analyse($filename)->executableLines(), + ); + + return new self([$filename => $lineCoverage], []); + } + + /** + * @param XdebugCodeCoverageWithoutPathCoverageType $lineCoverage + * @param array $functionCoverage + */ + private function __construct(array $lineCoverage, array $functionCoverage) + { + $this->lineCoverage = $lineCoverage; + $this->functionCoverage = $functionCoverage; + + $this->skipEmptyLines(); + } + + public function clear(): void + { + $this->lineCoverage = $this->functionCoverage = []; + } + + /** + * @return XdebugCodeCoverageWithoutPathCoverageType + */ + public function lineCoverage(): array + { + return $this->lineCoverage; + } + + /** + * @return array + */ + public function functionCoverage(): array + { + return $this->functionCoverage; + } + + public function removeCoverageDataForFile(string $filename): void + { + unset($this->lineCoverage[$filename], $this->functionCoverage[$filename]); + } + + /** + * @param int[] $lines + */ + public function keepLineCoverageDataOnlyForLines(string $filename, array $lines): void + { + if (!isset($this->lineCoverage[$filename])) { + return; + } + + $this->lineCoverage[$filename] = array_intersect_key( + $this->lineCoverage[$filename], + array_flip($lines), + ); + } + + /** + * @param int[] $linesToBranchMap + */ + public function markExecutableLineByBranch(string $filename, array $linesToBranchMap): void + { + if (!isset($this->lineCoverage[$filename])) { + return; + } + + $linesByBranch = []; + + foreach ($linesToBranchMap as $line => $branch) { + $linesByBranch[$branch][] = $line; + } + + foreach ($this->lineCoverage[$filename] as $line => $lineStatus) { + if (!isset($linesToBranchMap[$line])) { + continue; + } + + $branch = $linesToBranchMap[$line]; + + if (!isset($linesByBranch[$branch])) { + continue; + } + + foreach ($linesByBranch[$branch] as $lineInBranch) { + $this->lineCoverage[$filename][$lineInBranch] = $lineStatus; + } + + if (Driver::LINE_EXECUTED === $lineStatus) { + unset($linesByBranch[$branch]); + } + } + } + + /** + * @param int[] $lines + */ + public function keepFunctionCoverageDataOnlyForLines(string $filename, array $lines): void + { + if (!isset($this->functionCoverage[$filename])) { + return; + } + + foreach ($this->functionCoverage[$filename] as $functionName => $functionData) { + foreach ($functionData['branches'] as $branchId => $branch) { + if (count(array_diff(range($branch['line_start'], $branch['line_end']), $lines)) > 0) { + unset($this->functionCoverage[$filename][$functionName]['branches'][$branchId]); + + foreach ($functionData['paths'] as $pathId => $path) { + if (in_array($branchId, $path['path'], true)) { + unset($this->functionCoverage[$filename][$functionName]['paths'][$pathId]); + } + } + } + } + } + } + + /** + * @param int[] $lines + */ + public function removeCoverageDataForLines(string $filename, array $lines): void + { + if ($lines === []) { + return; + } + + if (!isset($this->lineCoverage[$filename])) { + return; + } + + $this->lineCoverage[$filename] = array_diff_key( + $this->lineCoverage[$filename], + array_flip($lines), + ); + + if (isset($this->functionCoverage[$filename])) { + foreach ($this->functionCoverage[$filename] as $functionName => $functionData) { + foreach ($functionData['branches'] as $branchId => $branch) { + if (count(array_intersect($lines, range($branch['line_start'], $branch['line_end']))) > 0) { + unset($this->functionCoverage[$filename][$functionName]['branches'][$branchId]); + + foreach ($functionData['paths'] as $pathId => $path) { + if (in_array($branchId, $path['path'], true)) { + unset($this->functionCoverage[$filename][$functionName]['paths'][$pathId]); + } + } + } + } + } + } + } + + /** + * At the end of a file, the PHP interpreter always sees an implicit return. Where this occurs in a file that has + * e.g. a class definition, that line cannot be invoked from a test and results in confusing coverage. This engine + * implementation detail therefore needs to be masked which is done here by simply ensuring that all empty lines + * are skipped over for coverage purposes. + * + * @see https://github.com/sebastianbergmann/php-code-coverage/issues/799 + */ + private function skipEmptyLines(): void + { + foreach ($this->lineCoverage as $filename => $coverage) { + foreach ($this->getEmptyLinesForFile($filename) as $emptyLine) { + unset($this->lineCoverage[$filename][$emptyLine]); + } + } + } + + /** + * @return array + */ + private function getEmptyLinesForFile(string $filename): array + { + if (!isset(self::$emptyLineCache[$filename])) { + self::$emptyLineCache[$filename] = []; + + if (is_file($filename)) { + $sourceLines = explode("\n", file_get_contents($filename)); + + foreach ($sourceLines as $line => $source) { + if (trim($source) === '') { + self::$emptyLineCache[$filename][] = ($line + 1); + } + } + } + } + + return self::$emptyLineCache[$filename]; + } +} diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php index 71351e380..b839cca53 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -9,9 +9,9 @@ */ namespace SebastianBergmann\CodeCoverage\Driver; +use function sprintf; use SebastianBergmann\CodeCoverage\BranchAndPathCoverageNotSupportedException; -use SebastianBergmann\CodeCoverage\DeadCodeDetectionNotSupportedException; -use SebastianBergmann\CodeCoverage\RawCodeCoverageData; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage @@ -19,49 +19,30 @@ abstract class Driver { /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - public const LINE_NOT_EXECUTABLE = -2; + public const int LINE_NOT_EXECUTABLE = -2; /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - public const LINE_NOT_EXECUTED = -1; + public const int LINE_NOT_EXECUTED = -1; /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - public const LINE_EXECUTED = 1; + public const int LINE_EXECUTED = 1; /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - public const BRANCH_NOT_HIT = 0; + public const int BRANCH_NOT_HIT = 0; /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - public const BRANCH_HIT = 1; - - /** - * @var bool - */ - private $collectBranchAndPathCoverage = false; - - /** - * @var bool - */ - private $detectDeadCode = false; + public const int BRANCH_HIT = 1; + private bool $collectBranchAndPathCoverage = false; public function canCollectBranchAndPathCoverage(): bool { @@ -80,10 +61,10 @@ public function enableBranchAndPathCoverage(): void { if (!$this->canCollectBranchAndPathCoverage()) { throw new BranchAndPathCoverageNotSupportedException( - \sprintf( - 'The %s driver does not support branch and path coverage', - $this->name() - ) + sprintf( + '%s does not support branch and path coverage', + $this->nameAndVersion(), + ), ); } @@ -95,39 +76,7 @@ public function disableBranchAndPathCoverage(): void $this->collectBranchAndPathCoverage = false; } - public function canDetectDeadCode(): bool - { - return false; - } - - public function detectsDeadCode(): bool - { - return $this->detectDeadCode; - } - - /** - * @throws DeadCodeDetectionNotSupportedException - */ - public function enableDeadCodeDetection(): void - { - if (!$this->canDetectDeadCode()) { - throw new DeadCodeDetectionNotSupportedException( - \sprintf( - 'The %s driver does not support dead code detection', - $this->name() - ) - ); - } - - $this->detectDeadCode = true; - } - - public function disableDeadCodeDetection(): void - { - $this->detectDeadCode = false; - } - - abstract public function name(): string; + abstract public function nameAndVersion(): string; abstract public function start(): void; diff --git a/src/Driver/PcovDriver.php b/src/Driver/PcovDriver.php index 2319e8039..d7975efe3 100644 --- a/src/Driver/PcovDriver.php +++ b/src/Driver/PcovDriver.php @@ -9,42 +9,77 @@ */ namespace SebastianBergmann\CodeCoverage\Driver; +use const pcov\inclusive; +use function array_intersect; +use function extension_loaded; +use function pcov\clear; +use function pcov\collect; +use function pcov\start; +use function pcov\stop; +use function pcov\waiting; +use function phpversion; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\RawCodeCoverageData; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ final class PcovDriver extends Driver { + private readonly Filter $filter; + /** - * @var Filter + * @throws PcovNotAvailableException */ - private $filter; - public function __construct(Filter $filter) { + $this->ensurePcovIsAvailable(); + $this->filter = $filter; } + /** + * @codeCoverageIgnore + */ public function start(): void { - \pcov\start(); + start(); } public function stop(): RawCodeCoverageData { - \pcov\stop(); + stop(); + + // @codeCoverageIgnoreStart + $filesToCollectCoverageFor = waiting(); + $collected = []; - $collect = \pcov\collect(\pcov\inclusive, $this->filter->getWhitelist()); + if ($filesToCollectCoverageFor !== []) { + if (!$this->filter->isEmpty()) { + $filesToCollectCoverageFor = array_intersect($filesToCollectCoverageFor, $this->filter->files()); + } - \pcov\clear(); + $collected = collect(inclusive, $filesToCollectCoverageFor); - return RawCodeCoverageData::fromXdebugWithoutPathCoverage($collect); + clear(); + } + + return RawCodeCoverageData::fromXdebugWithoutPathCoverage($collected); + // @codeCoverageIgnoreEnd + } + + public function nameAndVersion(): string + { + return 'PCOV ' . phpversion('pcov'); } - public function name(): string + /** + * @throws PcovNotAvailableException + */ + private function ensurePcovIsAvailable(): void { - return 'PCOV'; + if (!extension_loaded('pcov')) { + throw new PcovNotAvailableException; + } } } diff --git a/src/Driver/PhpdbgDriver.php b/src/Driver/PhpdbgDriver.php deleted file mode 100644 index bf49f0d44..000000000 --- a/src/Driver/PhpdbgDriver.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -namespace SebastianBergmann\CodeCoverage\Driver; - -use SebastianBergmann\CodeCoverage\RawCodeCoverageData; -use SebastianBergmann\CodeCoverage\RuntimeException; - -/** - * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage - */ -final class PhpdbgDriver extends Driver -{ - /** - * @throws RuntimeException - */ - public function __construct() - { - if (\PHP_SAPI !== 'phpdbg') { - throw new RuntimeException( - 'This driver requires the PHPDBG SAPI' - ); - } - - if (!\function_exists('phpdbg_start_oplog')) { - throw new RuntimeException( - 'This build of PHPDBG does not support code coverage' - ); - } - } - - public function start(): void - { - \phpdbg_start_oplog(); - } - - public function stop(): RawCodeCoverageData - { - static $fetchedLines = []; - - $dbgData = \phpdbg_end_oplog(); - - if ($fetchedLines === []) { - $sourceLines = \phpdbg_get_executable(); - } else { - $newFiles = \array_diff(\get_included_files(), \array_keys($fetchedLines)); - - $sourceLines = []; - - if ($newFiles) { - $sourceLines = \phpdbg_get_executable(['files' => $newFiles]); - } - } - - foreach ($sourceLines as $file => $lines) { - foreach ($lines as $lineNo => $numExecuted) { - $sourceLines[$file][$lineNo] = self::LINE_NOT_EXECUTED; - } - } - - $fetchedLines = \array_merge($fetchedLines, $sourceLines); - - return RawCodeCoverageData::fromXdebugWithoutPathCoverage($this->detectExecutedLines($fetchedLines, $dbgData)); - } - - public function name(): string - { - return 'PHPDBG'; - } - - private function detectExecutedLines(array $sourceLines, array $dbgData): array - { - foreach ($dbgData as $file => $coveredLines) { - foreach ($coveredLines as $lineNo => $numExecuted) { - // phpdbg also reports $lineNo=0 when e.g. exceptions get thrown. - // make sure we only mark lines executed which are actually executable. - if (isset($sourceLines[$file][$lineNo])) { - $sourceLines[$file][$lineNo] = self::LINE_EXECUTED; - } - } - } - - return $sourceLines; - } -} diff --git a/src/Driver/Selector.php b/src/Driver/Selector.php new file mode 100644 index 000000000..56ffbf80e --- /dev/null +++ b/src/Driver/Selector.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Driver; + +use SebastianBergmann\CodeCoverage\Filter; +use SebastianBergmann\CodeCoverage\NoCodeCoverageDriverAvailableException; +use SebastianBergmann\CodeCoverage\NoCodeCoverageDriverWithPathCoverageSupportAvailableException; +use SebastianBergmann\Environment\Runtime; + +final class Selector +{ + /** + * @throws NoCodeCoverageDriverAvailableException + * @throws PcovNotAvailableException + * @throws XdebugNotAvailableException + * @throws XdebugNotEnabledException + * @throws XdebugVersionNotSupportedException + */ + public function forLineCoverage(Filter $filter): Driver + { + $runtime = new Runtime; + + if ($runtime->hasPCOV()) { + return new PcovDriver($filter); + } + + if ($runtime->hasXdebug()) { + return new XdebugDriver($filter); + } + + throw new NoCodeCoverageDriverAvailableException; + } + + /** + * @throws NoCodeCoverageDriverWithPathCoverageSupportAvailableException + * @throws XdebugNotAvailableException + * @throws XdebugNotEnabledException + * @throws XdebugVersionNotSupportedException + */ + public function forLineAndPathCoverage(Filter $filter): Driver + { + if ((new Runtime)->hasXdebug()) { + $driver = new XdebugDriver($filter); + + $driver->enableBranchAndPathCoverage(); + + return $driver; + } + + throw new NoCodeCoverageDriverWithPathCoverageSupportAvailableException; + } +} diff --git a/src/Driver/XdebugDriver.php b/src/Driver/XdebugDriver.php index 728dbb409..039df00d0 100644 --- a/src/Driver/XdebugDriver.php +++ b/src/Driver/XdebugDriver.php @@ -9,31 +9,72 @@ */ namespace SebastianBergmann\CodeCoverage\Driver; +use const XDEBUG_CC_BRANCH_CHECK; +use const XDEBUG_CC_DEAD_CODE; +use const XDEBUG_CC_UNUSED; +use const XDEBUG_FILTER_CODE_COVERAGE; +use const XDEBUG_PATH_INCLUDE; +use function extension_loaded; +use function in_array; +use function phpversion; +use function version_compare; +use function xdebug_get_code_coverage; +use function xdebug_info; +use function xdebug_set_filter; +use function xdebug_start_code_coverage; +use function xdebug_stop_code_coverage; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\RawCodeCoverageData; -use SebastianBergmann\CodeCoverage\RuntimeException; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @see https://xdebug.org/docs/code_coverage#xdebug_get_code_coverage + * + * @phpstan-type XdebugLinesCoverageType array + * @phpstan-type XdebugBranchCoverageType array{ + * op_start: int, + * op_end: int, + * line_start: int, + * line_end: int, + * hit: int, + * out: array, + * out_hit: array, + * } + * @phpstan-type XdebugPathCoverageType array{ + * path: array, + * hit: int, + * } + * @phpstan-type XdebugFunctionCoverageType array{ + * branches: array, + * paths: array, + * } + * @phpstan-type XdebugFunctionsCoverageType array + * @phpstan-type XdebugPathAndBranchesCoverageType array{ + * lines: XdebugLinesCoverageType, + * functions: XdebugFunctionsCoverageType, + * } + * @phpstan-type XdebugCodeCoverageWithoutPathCoverageType array + * @phpstan-type XdebugCodeCoverageWithPathCoverageType array */ final class XdebugDriver extends Driver { /** - * @throws RuntimeException + * @throws XdebugNotAvailableException + * @throws XdebugNotEnabledException + * @throws XdebugVersionNotSupportedException */ public function __construct(Filter $filter) { - if (!\extension_loaded('xdebug')) { - throw new RuntimeException('This driver requires Xdebug'); - } - - if (!\ini_get('xdebug.coverage_enable')) { - throw new RuntimeException('xdebug.coverage_enable=On has to be set in php.ini'); + $this->ensureXdebugIsAvailable(); + + if (!$filter->isEmpty()) { + xdebug_set_filter( + XDEBUG_FILTER_CODE_COVERAGE, + XDEBUG_PATH_INCLUDE, + $filter->files(), + ); } - - \xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, \XDEBUG_PATH_WHITELIST, $filter->getWhitelist()); - - $this->enableDeadCodeDetection(); } public function canCollectBranchAndPathCoverage(): bool @@ -41,41 +82,54 @@ public function canCollectBranchAndPathCoverage(): bool return true; } - public function canDetectDeadCode(): bool - { - return true; - } - public function start(): void { - $flags = \XDEBUG_CC_UNUSED; - - if ($this->detectsDeadCode() || $this->collectsBranchAndPathCoverage()) { - $flags |= \XDEBUG_CC_DEAD_CODE; - } + $flags = XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE; if ($this->collectsBranchAndPathCoverage()) { - $flags |= \XDEBUG_CC_BRANCH_CHECK; + $flags |= XDEBUG_CC_BRANCH_CHECK; } - \xdebug_start_code_coverage($flags); + xdebug_start_code_coverage($flags); } public function stop(): RawCodeCoverageData { - $data = \xdebug_get_code_coverage(); + $data = xdebug_get_code_coverage(); - \xdebug_stop_code_coverage(); + xdebug_stop_code_coverage(); if ($this->collectsBranchAndPathCoverage()) { + /* @var XdebugCodeCoverageWithPathCoverageType $data */ return RawCodeCoverageData::fromXdebugWithPathCoverage($data); } + /* @var XdebugCodeCoverageWithoutPathCoverageType $data */ return RawCodeCoverageData::fromXdebugWithoutPathCoverage($data); } - public function name(): string + public function nameAndVersion(): string + { + return 'Xdebug ' . phpversion('xdebug'); + } + + /** + * @throws XdebugNotAvailableException + * @throws XdebugNotEnabledException + * @throws XdebugVersionNotSupportedException + */ + private function ensureXdebugIsAvailable(): void { - return 'Xdebug'; + if (!extension_loaded('xdebug')) { + throw new XdebugNotAvailableException; + } + + if (!version_compare(phpversion('xdebug'), '3.1', '>=')) { + throw new XdebugVersionNotSupportedException(phpversion('xdebug')); + } + + if (!in_array('coverage', xdebug_info('mode'), true)) { + throw new XdebugNotEnabledException; + } } } diff --git a/src/Exception/BranchAndPathCoverageNotSupportedException.php b/src/Exception/BranchAndPathCoverageNotSupportedException.php index eaf13e321..ab2089197 100644 --- a/src/Exception/BranchAndPathCoverageNotSupportedException.php +++ b/src/Exception/BranchAndPathCoverageNotSupportedException.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -final class BranchAndPathCoverageNotSupportedException extends RuntimeException +use RuntimeException; + +final class BranchAndPathCoverageNotSupportedException extends RuntimeException implements Exception { } diff --git a/src/Exception/DirectoryCouldNotBeCreatedException.php b/src/Exception/DirectoryCouldNotBeCreatedException.php index 298b11d95..fdd9bfdf1 100644 --- a/src/Exception/DirectoryCouldNotBeCreatedException.php +++ b/src/Exception/DirectoryCouldNotBeCreatedException.php @@ -7,8 +7,11 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -namespace SebastianBergmann\CodeCoverage; +namespace SebastianBergmann\CodeCoverage\Util; -final class DirectoryCouldNotBeCreatedException extends RuntimeException +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class DirectoryCouldNotBeCreatedException extends RuntimeException implements Exception { } diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php index 66308cf48..28dc48b8a 100644 --- a/src/Exception/Exception.php +++ b/src/Exception/Exception.php @@ -9,9 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -/** - * Exception interface for php-code-coverage component. - */ -interface Exception +use Throwable; + +interface Exception extends Throwable { } diff --git a/src/Exception/RuntimeException.php b/src/Exception/FileCouldNotBeWrittenException.php similarity index 73% rename from src/Exception/RuntimeException.php rename to src/Exception/FileCouldNotBeWrittenException.php index d0f93942a..db9cdac34 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/FileCouldNotBeWrittenException.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -class RuntimeException extends \RuntimeException implements Exception +use RuntimeException; + +final class FileCouldNotBeWrittenException extends RuntimeException implements Exception { } diff --git a/src/Exception/InvalidCodeCoverageTargetException.php b/src/Exception/InvalidCodeCoverageTargetException.php new file mode 100644 index 000000000..09139585a --- /dev/null +++ b/src/Exception/InvalidCodeCoverageTargetException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Test\Target; + +use function sprintf; +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class InvalidCodeCoverageTargetException extends RuntimeException implements Exception +{ + public function __construct(Target $target) + { + parent::__construct( + sprintf( + '%s is not a valid target for code coverage', + $target->description(), + ), + ); + } +} diff --git a/src/Exception/NoCodeCoverageDriverAvailableException.php b/src/Exception/NoCodeCoverageDriverAvailableException.php new file mode 100644 index 000000000..b1494e267 --- /dev/null +++ b/src/Exception/NoCodeCoverageDriverAvailableException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use RuntimeException; + +final class NoCodeCoverageDriverAvailableException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('No code coverage driver available'); + } +} diff --git a/src/Exception/NoCodeCoverageDriverWithPathCoverageSupportAvailableException.php b/src/Exception/NoCodeCoverageDriverWithPathCoverageSupportAvailableException.php new file mode 100644 index 000000000..0065b740d --- /dev/null +++ b/src/Exception/NoCodeCoverageDriverWithPathCoverageSupportAvailableException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use RuntimeException; + +final class NoCodeCoverageDriverWithPathCoverageSupportAvailableException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('No code coverage driver with path coverage support available'); + } +} diff --git a/src/Exception/DeadCodeDetectionNotSupportedException.php b/src/Exception/ParserException.php similarity index 76% rename from src/Exception/DeadCodeDetectionNotSupportedException.php rename to src/Exception/ParserException.php index d898a938f..a907e34e8 100644 --- a/src/Exception/DeadCodeDetectionNotSupportedException.php +++ b/src/Exception/ParserException.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -final class DeadCodeDetectionNotSupportedException extends RuntimeException +use RuntimeException; + +final class ParserException extends RuntimeException implements Exception { } diff --git a/src/Exception/PathExistsButIsNotDirectoryException.php b/src/Exception/PathExistsButIsNotDirectoryException.php new file mode 100644 index 000000000..fd6f80a70 --- /dev/null +++ b/src/Exception/PathExistsButIsNotDirectoryException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use function sprintf; +use RuntimeException; + +final class PathExistsButIsNotDirectoryException extends RuntimeException implements Exception +{ + public function __construct(string $path) + { + parent::__construct(sprintf('"%s" exists but is not a directory', $path)); + } +} diff --git a/src/Exception/PcovNotAvailableException.php b/src/Exception/PcovNotAvailableException.php new file mode 100644 index 000000000..2f0a66e5a --- /dev/null +++ b/src/Exception/PcovNotAvailableException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Driver; + +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class PcovNotAvailableException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('The PCOV extension is not available'); + } +} diff --git a/src/Exception/MissingCoversAnnotationException.php b/src/Exception/ReflectionException.php similarity index 75% rename from src/Exception/MissingCoversAnnotationException.php rename to src/Exception/ReflectionException.php index a83b5f536..78db430be 100644 --- a/src/Exception/MissingCoversAnnotationException.php +++ b/src/Exception/ReflectionException.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -final class MissingCoversAnnotationException extends RuntimeException +use RuntimeException; + +final class ReflectionException extends RuntimeException implements Exception { } diff --git a/src/Exception/ReportAlreadyFinalizedException.php b/src/Exception/ReportAlreadyFinalizedException.php new file mode 100644 index 000000000..0481f1610 --- /dev/null +++ b/src/Exception/ReportAlreadyFinalizedException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use RuntimeException; + +final class ReportAlreadyFinalizedException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('The code coverage report has already been finalized'); + } +} diff --git a/src/Exception/StaticAnalysisCacheNotConfiguredException.php b/src/Exception/StaticAnalysisCacheNotConfiguredException.php new file mode 100644 index 000000000..fd58fd6b6 --- /dev/null +++ b/src/Exception/StaticAnalysisCacheNotConfiguredException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use RuntimeException; + +final class StaticAnalysisCacheNotConfiguredException extends RuntimeException implements Exception +{ +} diff --git a/src/Exception/TestIdMissingException.php b/src/Exception/TestIdMissingException.php new file mode 100644 index 000000000..4cc3e0c2b --- /dev/null +++ b/src/Exception/TestIdMissingException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use RuntimeException; + +final class TestIdMissingException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('Test ID is missing'); + } +} diff --git a/src/Exception/UnintentionallyCoveredCodeException.php b/src/Exception/UnintentionallyCoveredCodeException.php index c872135b1..bb7d88c97 100644 --- a/src/Exception/UnintentionallyCoveredCodeException.php +++ b/src/Exception/UnintentionallyCoveredCodeException.php @@ -9,13 +9,18 @@ */ namespace SebastianBergmann\CodeCoverage; -final class UnintentionallyCoveredCodeException extends RuntimeException +use RuntimeException; + +final class UnintentionallyCoveredCodeException extends RuntimeException implements Exception { /** - * @var array + * @var list */ - private $unintentionallyCoveredUnits; + private readonly array $unintentionallyCoveredUnits; + /** + * @param list $unintentionallyCoveredUnits + */ public function __construct(array $unintentionallyCoveredUnits) { $this->unintentionallyCoveredUnits = $unintentionallyCoveredUnits; @@ -23,6 +28,9 @@ public function __construct(array $unintentionallyCoveredUnits) parent::__construct($this->toString()); } + /** + * @return list + */ public function getUnintentionallyCoveredUnits(): array { return $this->unintentionallyCoveredUnits; diff --git a/src/Exception/WriteOperationFailedException.php b/src/Exception/WriteOperationFailedException.php new file mode 100644 index 000000000..c6b5e516a --- /dev/null +++ b/src/Exception/WriteOperationFailedException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use function sprintf; +use RuntimeException; + +final class WriteOperationFailedException extends RuntimeException implements Exception +{ + public function __construct(string $path) + { + parent::__construct(sprintf('Cannot write to "%s"', $path)); + } +} diff --git a/src/Exception/XdebugNotAvailableException.php b/src/Exception/XdebugNotAvailableException.php new file mode 100644 index 000000000..1622c5a63 --- /dev/null +++ b/src/Exception/XdebugNotAvailableException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Driver; + +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class XdebugNotAvailableException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('The Xdebug extension is not available'); + } +} diff --git a/src/Exception/XdebugNotEnabledException.php b/src/Exception/XdebugNotEnabledException.php new file mode 100644 index 000000000..a8df4645b --- /dev/null +++ b/src/Exception/XdebugNotEnabledException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Driver; + +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class XdebugNotEnabledException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('XDEBUG_MODE=coverage (environment variable) or xdebug.mode=coverage (PHP configuration setting) has to be set'); + } +} diff --git a/src/Exception/XdebugVersionNotSupportedException.php b/src/Exception/XdebugVersionNotSupportedException.php new file mode 100644 index 000000000..c785af145 --- /dev/null +++ b/src/Exception/XdebugVersionNotSupportedException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Driver; + +use function sprintf; +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class XdebugVersionNotSupportedException extends RuntimeException implements Exception +{ + /** + * @param non-empty-string $version + */ + public function __construct(string $version) + { + parent::__construct( + sprintf( + 'Version %s of the Xdebug extension is not supported', + $version, + ), + ); + } +} diff --git a/src/Exception/CoveredCodeNotExecutedException.php b/src/Exception/XmlException.php similarity index 77% rename from src/Exception/CoveredCodeNotExecutedException.php rename to src/Exception/XmlException.php index 1b2fcf8c7..31e4623df 100644 --- a/src/Exception/CoveredCodeNotExecutedException.php +++ b/src/Exception/XmlException.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -final class CoveredCodeNotExecutedException extends RuntimeException +use RuntimeException; + +final class XmlException extends RuntimeException implements Exception { } diff --git a/src/Filter.php b/src/Filter.php index ee191c04b..f9086542b 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -9,90 +9,45 @@ */ namespace SebastianBergmann\CodeCoverage; -use SebastianBergmann\FileIterator\Facade as FileIteratorFacade; +use function array_keys; +use function is_file; +use function realpath; +use function str_contains; +use function str_starts_with; -/** - * Filter for whitelisting of code coverage information. - */ final class Filter { /** - * Source files that are whitelisted. - * - * @psalm-var array + * @var array */ - private $whitelistedFiles = []; + private array $files = []; /** - * Remembers the result of the `is_file()` calls. - * - * @psalm-var array + * @var array */ - private $isFileCache = []; + private array $isFileCache = []; /** - * Adds a directory to the whitelist (recursively). + * @param list $filenames */ - public function addDirectoryToWhitelist(string $directory, string $suffix = '.php', string $prefix = ''): void + public function includeFiles(array $filenames): void { - foreach ((new FileIteratorFacade)->getFilesAsArray($directory, $suffix, $prefix) as $file) { - $this->addFileToWhitelist($file); + foreach ($filenames as $filename) { + $this->includeFile($filename); } } - /** - * Adds a file to the whitelist. - */ - public function addFileToWhitelist(string $filename): void + public function includeFile(string $filename): void { - $filename = \realpath($filename); + $filename = realpath($filename); if (!$filename) { return; } - $this->whitelistedFiles[$filename] = true; - } - - /** - * Adds files to the whitelist. - * - * @psalm-param list $files - */ - public function addFilesToWhitelist(array $files): void - { - foreach ($files as $file) { - $this->addFileToWhitelist($file); - } - } - - /** - * Removes a directory from the whitelist (recursively). - */ - public function removeDirectoryFromWhitelist(string $directory, string $suffix = '.php', string $prefix = ''): void - { - foreach ((new FileIteratorFacade)->getFilesAsArray($directory, $suffix, $prefix) as $file) { - $this->removeFileFromWhitelist($file); - } - } - - /** - * Removes a file from the whitelist. - */ - public function removeFileFromWhitelist(string $filename): void - { - $filename = \realpath($filename); - - if (!$filename || !isset($this->whitelistedFiles[$filename])) { - return; - } - - unset($this->whitelistedFiles[$filename]); + $this->files[$filename] = true; } - /** - * Checks whether a filename is a real filename. - */ public function isFile(string $filename): bool { if (isset($this->isFileCache[$filename])) { @@ -100,17 +55,17 @@ public function isFile(string $filename): bool } if ($filename === '-' || - \strpos($filename, 'vfs://') === 0 || - \strpos($filename, 'xdebug://debug-eval') !== false || - \strpos($filename, 'eval()\'d code') !== false || - \strpos($filename, 'runtime-created function') !== false || - \strpos($filename, 'runkit created function') !== false || - \strpos($filename, 'assert code') !== false || - \strpos($filename, 'regexp code') !== false || - \strpos($filename, 'Standard input code') !== false) { + str_starts_with($filename, 'vfs://') || + str_contains($filename, 'xdebug://debug-eval') || + str_contains($filename, 'eval()\'d code') || + str_contains($filename, 'runtime-created function') || + str_contains($filename, 'runkit created function') || + str_contains($filename, 'assert code') || + str_contains($filename, 'regexp code') || + str_contains($filename, 'Standard input code')) { $isFile = false; } else { - $isFile = \file_exists($filename); + $isFile = is_file($filename); } $this->isFileCache[$filename] = $isFile; @@ -118,53 +73,21 @@ public function isFile(string $filename): bool return $isFile; } - /** - * Checks whether or not a file is filtered. - */ - public function isFiltered(string $filename): bool + public function isExcluded(string $filename): bool { - if (!$this->isFile($filename)) { - return true; - } - - return !isset($this->whitelistedFiles[$filename]); + return !isset($this->files[$filename]) || !$this->isFile($filename); } /** - * Returns the list of whitelisted files. - * - * @psalm-return list + * @return list */ - public function getWhitelist(): array + public function files(): array { - return \array_keys($this->whitelistedFiles); + return array_keys($this->files); } - /** - * Returns whether this filter has a whitelist. - */ - public function hasWhitelist(): bool - { - return !empty($this->whitelistedFiles); - } - - /** - * Returns the whitelisted files. - * - * @psalm-return array - */ - public function getWhitelistedFiles(): array - { - return $this->whitelistedFiles; - } - - /** - * Sets the whitelisted files. - * - * @psalm-param array $whitelistedFiles - */ - public function setWhitelistedFiles(array $whitelistedFiles): void + public function isEmpty(): bool { - $this->whitelistedFiles = $whitelistedFiles; + return $this->files === []; } } diff --git a/src/Node/AbstractNode.php b/src/Node/AbstractNode.php index 0ff4149dc..7e82a3daf 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -9,46 +9,45 @@ */ namespace SebastianBergmann\CodeCoverage\Node; -use SebastianBergmann\CodeCoverage\Percentage; +use const DIRECTORY_SEPARATOR; +use function array_merge; +use function str_ends_with; +use function str_replace; +use function substr; +use Countable; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; +use SebastianBergmann\CodeCoverage\Util\Percentage; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type ProcessedFunctionType from File + * @phpstan-import-type ProcessedClassType from File + * @phpstan-import-type ProcessedTraitType from File */ -abstract class AbstractNode implements \Countable +abstract class AbstractNode implements Countable { - /** - * @var string - */ - private $name; - - /** - * @var string - */ - private $path; + private readonly string $name; + private string $pathAsString; /** - * @var array + * @var non-empty-list */ - private $pathArray; + private array $pathAsArray; + private readonly ?AbstractNode $parent; + private string $id; - /** - * @var AbstractNode - */ - private $parent; - - /** - * @var string - */ - private $id; - - public function __construct(string $name, self $parent = null) + public function __construct(string $name, ?self $parent = null) { - if (\substr($name, -1) === \DIRECTORY_SEPARATOR) { - $name = \substr($name, 0, -1); + if (str_ends_with($name, DIRECTORY_SEPARATOR)) { + $name = substr($name, 0, -1); } $this->name = $name; $this->parent = $parent; + + $this->processId(); + $this->processPath(); } public function name(): string @@ -58,51 +57,20 @@ public function name(): string public function id(): string { - if ($this->id === null) { - $parent = $this->parent(); - - if ($parent === null) { - $this->id = 'index'; - } else { - $parentId = $parent->id(); - - if ($parentId === 'index') { - $this->id = \str_replace(':', '_', $this->name); - } else { - $this->id = $parentId . '/' . $this->name; - } - } - } - return $this->id; } public function pathAsString(): string { - if ($this->path === null) { - if ($this->parent === null || $this->parent->pathAsString() === null || $this->parent->pathAsString() === false) { - $this->path = $this->name; - } else { - $this->path = $this->parent->pathAsString() . \DIRECTORY_SEPARATOR . $this->name; - } - } - - return $this->path; + return $this->pathAsString; } + /** + * @return non-empty-list + */ public function pathAsArray(): array { - if ($this->pathArray === null) { - if ($this->parent === null) { - $this->pathArray = []; - } else { - $this->pathArray = $this->parent->pathAsArray(); - } - - $this->pathArray[] = $this; - } - - return $this->pathArray; + return $this->pathAsArray; } public function parent(): ?self @@ -170,7 +138,7 @@ public function percentageOfExecutedBranches(): Percentage { return Percentage::fromFractionAndTotal( $this->numberOfExecutedBranches(), - $this->numberOfExecutableBranches() + $this->numberOfExecutableBranches(), ); } @@ -178,7 +146,7 @@ public function percentageOfExecutedPaths(): Percentage { return Percentage::fromFractionAndTotal( $this->numberOfExecutedPaths(), - $this->numberOfExecutablePaths() + $this->numberOfExecutablePaths(), ); } @@ -192,9 +160,12 @@ public function numberOfTestedClassesAndTraits(): int return $this->numberOfTestedClasses() + $this->numberOfTestedTraits(); } + /** + * @return array + */ public function classesAndTraits(): array { - return \array_merge($this->classes(), $this->traits()); + return array_merge($this->classes(), $this->traits()); } public function numberOfFunctionsAndMethods(): int @@ -207,13 +178,40 @@ public function numberOfTestedFunctionsAndMethods(): int return $this->numberOfTestedFunctions() + $this->numberOfTestedMethods(); } + /** + * @return non-negative-int + */ + public function cyclomaticComplexity(): int + { + $ccn = 0; + + foreach ($this->classesAndTraits() as $classLike) { + $ccn += $classLike['ccn']; + } + + foreach ($this->functions() as $function) { + $ccn += $function['ccn']; + } + + return $ccn; + } + + /** + * @return array + */ abstract public function classes(): array; + /** + * @return array + */ abstract public function traits(): array; + /** + * @return array + */ abstract public function functions(): array; - abstract public function linesOfCode(): array; + abstract public function linesOfCode(): LinesOfCode; abstract public function numberOfExecutableLines(): int; @@ -242,4 +240,36 @@ abstract public function numberOfTestedMethods(): int; abstract public function numberOfFunctions(): int; abstract public function numberOfTestedFunctions(): int; + + private function processId(): void + { + if ($this->parent === null) { + $this->id = 'index'; + + return; + } + + $parentId = $this->parent->id(); + + if ($parentId === 'index') { + $this->id = str_replace(':', '_', $this->name); + } else { + $this->id = $parentId . '/' . $this->name; + } + } + + private function processPath(): void + { + if ($this->parent === null) { + $this->pathAsArray = [$this]; + $this->pathAsString = $this->name; + + return; + } + + $this->pathAsArray = $this->parent->pathAsArray(); + $this->pathAsString = $this->parent->pathAsString() . DIRECTORY_SEPARATOR . $this->name; + + $this->pathAsArray[] = $this; + } } diff --git a/src/Node/Builder.php b/src/Node/Builder.php index 8b398d39b..19fc3a24d 100644 --- a/src/Node/Builder.php +++ b/src/Node/Builder.php @@ -9,47 +9,87 @@ */ namespace SebastianBergmann\CodeCoverage\Node; +use const DIRECTORY_SEPARATOR; +use function array_shift; +use function basename; +use function count; +use function dirname; +use function explode; +use function implode; +use function is_file; +use function str_ends_with; +use function str_replace; +use function str_starts_with; +use function substr; use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\ProcessedCodeCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData; +use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type TestType from CodeCoverage */ -final class Builder +final readonly class Builder { + private FileAnalyser $analyser; + + public function __construct(FileAnalyser $analyser) + { + $this->analyser = $analyser; + } + public function build(CodeCoverage $coverage): Directory { $data = clone $coverage->getData(); // clone because path munging is destructive to the original data $commonPath = $this->reducePaths($data); $root = new Directory( $commonPath, - null + null, ); $this->addItems( $root, $this->buildDirectoryStructure($data), $coverage->getTests(), - $coverage->getCacheTokens() ); return $root; } - private function addItems(Directory $root, array $items, array $tests, bool $cacheTokens): void + /** + * @param array $tests + */ + private function addItems(Directory $root, array $items, array $tests): void { foreach ($items as $key => $value) { $key = (string) $key; - if (\substr($key, -2) === '/f') { - $key = \substr($key, 0, -2); + if (str_ends_with($key, '/f')) { + $key = substr($key, 0, -2); + $filename = $root->pathAsString() . DIRECTORY_SEPARATOR . $key; + + if (is_file($filename)) { + $analysisResult = $this->analyser->analyse($filename); - if (\file_exists($root->pathAsString() . \DIRECTORY_SEPARATOR . $key)) { - $root->addFile($key, $value['lineCoverage'], $value['functionCoverage'], $tests, $cacheTokens); + $root->addFile( + new File( + $key, + $root, + $value['lineCoverage'], + $value['functionCoverage'], + $tests, + $analysisResult->classes(), + $analysisResult->traits(), + $analysisResult->functions(), + $analysisResult->linesOfCode(), + ), + ); } } else { $child = $root->addDirectory($key); - $this->addItems($child, $value, $tests, $cacheTokens); + + $this->addItems($child, $value, $tests); } } } @@ -93,15 +133,17 @@ private function addItems(Directory $root, array $items, array $tests, bool $cac * ) * ) * + * + * @return array, functionCoverage: array>}>> */ private function buildDirectoryStructure(ProcessedCodeCoverageData $data): array { $result = []; foreach ($data->coveredFiles() as $originalPath) { - $path = \explode(\DIRECTORY_SEPARATOR, $originalPath); + $path = explode(DIRECTORY_SEPARATOR, $originalPath); $pointer = &$result; - $max = \count($path); + $max = count($path); for ($i = 0; $i < $max; $i++) { $type = ''; @@ -161,37 +203,38 @@ private function buildDirectoryStructure(ProcessedCodeCoverageData $data): array */ private function reducePaths(ProcessedCodeCoverageData $coverage): string { - if (empty($coverage->coveredFiles())) { + if ($coverage->coveredFiles() === []) { return '.'; } $commonPath = ''; $paths = $coverage->coveredFiles(); - if (\count($paths) === 1) { - $commonPath = \dirname($paths[0]) . \DIRECTORY_SEPARATOR; - $coverage->renameFile($paths[0], \basename($paths[0])); + if (count($paths) === 1) { + $commonPath = dirname($paths[0]) . DIRECTORY_SEPARATOR; + $coverage->renameFile($paths[0], basename($paths[0])); return $commonPath; } - $max = \count($paths); + $max = count($paths); for ($i = 0; $i < $max; $i++) { // strip phar:// prefixes - if (\strpos($paths[$i], 'phar://') === 0) { - $paths[$i] = \substr($paths[$i], 7); - $paths[$i] = \str_replace('/', \DIRECTORY_SEPARATOR, $paths[$i]); + if (str_starts_with($paths[$i], 'phar://')) { + $paths[$i] = substr($paths[$i], 7); + $paths[$i] = str_replace('/', DIRECTORY_SEPARATOR, $paths[$i]); } - $paths[$i] = \explode(\DIRECTORY_SEPARATOR, $paths[$i]); - if (empty($paths[$i][0])) { - $paths[$i][0] = \DIRECTORY_SEPARATOR; + $paths[$i] = explode(DIRECTORY_SEPARATOR, $paths[$i]); + + if ($paths[$i][0] === '') { + $paths[$i][0] = DIRECTORY_SEPARATOR; } } $done = false; - $max = \count($paths); + $max = count($paths); while (!$done) { for ($i = 0; $i < $max - 1; $i++) { @@ -207,23 +250,23 @@ private function reducePaths(ProcessedCodeCoverageData $coverage): string if (!$done) { $commonPath .= $paths[0][0]; - if ($paths[0][0] !== \DIRECTORY_SEPARATOR) { - $commonPath .= \DIRECTORY_SEPARATOR; + if ($paths[0][0] !== DIRECTORY_SEPARATOR) { + $commonPath .= DIRECTORY_SEPARATOR; } for ($i = 0; $i < $max; $i++) { - \array_shift($paths[$i]); + array_shift($paths[$i]); } } } $original = $coverage->coveredFiles(); - $max = \count($original); + $max = count($original); for ($i = 0; $i < $max; $i++) { - $coverage->renameFile($original[$i], \implode(\DIRECTORY_SEPARATOR, $paths[$i])); + $coverage->renameFile($original[$i], implode(DIRECTORY_SEPARATOR, $paths[$i])); } - return \substr($commonPath, 0, -1); + return substr($commonPath, 0, -1); } } diff --git a/src/Node/CrapIndex.php b/src/Node/CrapIndex.php new file mode 100644 index 000000000..a07a55048 --- /dev/null +++ b/src/Node/CrapIndex.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Node; + +use function sprintf; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final readonly class CrapIndex +{ + private int $cyclomaticComplexity; + private float $codeCoverage; + + public function __construct(int $cyclomaticComplexity, float $codeCoverage) + { + $this->cyclomaticComplexity = $cyclomaticComplexity; + $this->codeCoverage = $codeCoverage; + } + + public function asString(): string + { + if ($this->codeCoverage === 0.0) { + return (string) ($this->cyclomaticComplexity ** 2 + $this->cyclomaticComplexity); + } + + if ($this->codeCoverage >= 95) { + return (string) $this->cyclomaticComplexity; + } + + return sprintf( + '%01.2F', + $this->cyclomaticComplexity ** 2 * (1 - $this->codeCoverage / 100) ** 3 + $this->cyclomaticComplexity, + ); + } +} diff --git a/src/Node/Directory.php b/src/Node/Directory.php index 7dd84b5eb..2802f93ab 100644 --- a/src/Node/Directory.php +++ b/src/Node/Directory.php @@ -9,120 +9,69 @@ */ namespace SebastianBergmann\CodeCoverage\Node; +use function array_merge; +use function assert; +use function count; +use IteratorAggregate; +use RecursiveIteratorIterator; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + /** + * @template-implements IteratorAggregate + * + * @phpstan-import-type ProcessedFunctionType from File + * @phpstan-import-type ProcessedClassType from File + * @phpstan-import-type ProcessedTraitType from File + * * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ -final class Directory extends AbstractNode implements \IteratorAggregate +final class Directory extends AbstractNode implements IteratorAggregate { /** - * @var AbstractNode[] - */ - private $children = []; - - /** - * @var Directory[] - */ - private $directories = []; - - /** - * @var File[] - */ - private $files = []; - - /** - * @var array - */ - private $classes; - - /** - * @var array - */ - private $traits; - - /** - * @var array + * @var list */ - private $functions; + private array $children = []; /** - * @var array + * @var list */ - private $linesOfCode; + private array $directories = []; /** - * @var int + * @var list */ - private $numFiles = -1; + private array $files = []; /** - * @var int + * @var ?array */ - private $numExecutableLines = -1; + private ?array $classes = null; /** - * @var int + * @var ?array */ - private $numExecutedLines = -1; + private ?array $traits = null; /** - * @var int + * @var ?array */ - private $numExecutableBranches = -1; - - /** - * @var int - */ - private $numExecutedBranches = -1; - - /** - * @var int - */ - private $numExecutablePaths = -1; - - /** - * @var int - */ - private $numExecutedPaths = -1; - - /** - * @var int - */ - private $numClasses = -1; - - /** - * @var int - */ - private $numTestedClasses = -1; - - /** - * @var int - */ - private $numTraits = -1; - - /** - * @var int - */ - private $numTestedTraits = -1; - - /** - * @var int - */ - private $numMethods = -1; - - /** - * @var int - */ - private $numTestedMethods = -1; - - /** - * @var int - */ - private $numFunctions = -1; - - /** - * @var int - */ - private $numTestedFunctions = -1; + private ?array $functions = null; + private ?LinesOfCode $linesOfCode = null; + private int $numFiles = -1; + private int $numExecutableLines = -1; + private int $numExecutedLines = -1; + private int $numExecutableBranches = -1; + private int $numExecutedBranches = -1; + private int $numExecutablePaths = -1; + private int $numExecutedPaths = -1; + private int $numClasses = -1; + private int $numTestedClasses = -1; + private int $numTraits = -1; + private int $numTestedTraits = -1; + private int $numMethods = -1; + private int $numTestedMethods = -1; + private int $numFunctions = -1; + private int $numTestedFunctions = -1; public function count(): int { @@ -130,18 +79,21 @@ public function count(): int $this->numFiles = 0; foreach ($this->children as $child) { - $this->numFiles += \count($child); + $this->numFiles += count($child); } } return $this->numFiles; } - public function getIterator(): \RecursiveIteratorIterator + /** + * @return RecursiveIteratorIterator> + */ + public function getIterator(): RecursiveIteratorIterator { - return new \RecursiveIteratorIterator( + return new RecursiveIteratorIterator( new Iterator($this), - \RecursiveIteratorIterator::SELF_FIRST + RecursiveIteratorIterator::SELF_FIRST, ); } @@ -149,49 +101,59 @@ public function addDirectory(string $name): self { $directory = new self($name, $this); + assert($directory instanceof self); + $this->children[] = $directory; - $this->directories[] = &$this->children[\count($this->children) - 1]; + $this->directories[] = &$this->children[count($this->children) - 1]; return $directory; } - public function addFile(string $name, array $lineCoverageData, array $functionCoverageData, array $testData, bool $cacheTokens): File + public function addFile(File $file): void { - $file = new File($name, $this, $lineCoverageData, $functionCoverageData, $testData, $cacheTokens); - $this->children[] = $file; - $this->files[] = &$this->children[\count($this->children) - 1]; + $this->files[] = &$this->children[count($this->children) - 1]; $this->numExecutableLines = -1; $this->numExecutedLines = -1; - - return $file; } + /** + * @return list + */ public function directories(): array { return $this->directories; } + /** + * @return list + */ public function files(): array { return $this->files; } + /** + * @return list + */ public function children(): array { return $this->children; } + /** + * @return array + */ public function classes(): array { if ($this->classes === null) { $this->classes = []; foreach ($this->children as $child) { - $this->classes = \array_merge( + $this->classes = array_merge( $this->classes, - $child->classes() + $child->classes(), ); } } @@ -199,15 +161,18 @@ public function classes(): array return $this->classes; } + /** + * @return array + */ public function traits(): array { if ($this->traits === null) { $this->traits = []; foreach ($this->children as $child) { - $this->traits = \array_merge( + $this->traits = array_merge( $this->traits, - $child->traits() + $child->traits(), ); } } @@ -215,15 +180,18 @@ public function traits(): array return $this->traits; } + /** + * @return array + */ public function functions(): array { if ($this->functions === null) { $this->functions = []; foreach ($this->children as $child) { - $this->functions = \array_merge( + $this->functions = array_merge( $this->functions, - $child->functions() + $child->functions(), ); } } @@ -231,18 +199,22 @@ public function functions(): array return $this->functions; } - public function linesOfCode(): array + public function linesOfCode(): LinesOfCode { if ($this->linesOfCode === null) { - $this->linesOfCode = ['loc' => 0, 'cloc' => 0, 'ncloc' => 0]; + $linesOfCode = 0; + $commentLinesOfCode = 0; + $nonCommentLinesOfCode = 0; foreach ($this->children as $child) { - $linesOfCode = $child->linesOfCode(); + $childLinesOfCode = $child->linesOfCode(); - $this->linesOfCode['loc'] += $linesOfCode['loc']; - $this->linesOfCode['cloc'] += $linesOfCode['cloc']; - $this->linesOfCode['ncloc'] += $linesOfCode['ncloc']; + $linesOfCode += $childLinesOfCode->linesOfCode(); + $commentLinesOfCode += $childLinesOfCode->commentLinesOfCode(); + $nonCommentLinesOfCode += $childLinesOfCode->nonCommentLinesOfCode(); } + + $this->linesOfCode = new LinesOfCode($linesOfCode, $commentLinesOfCode, $nonCommentLinesOfCode); } return $this->linesOfCode; diff --git a/src/Node/File.php b/src/Node/File.php index af4572ce5..54ee70b4a 100644 --- a/src/Node/File.php +++ b/src/Node/File.php @@ -9,131 +9,154 @@ */ namespace SebastianBergmann\CodeCoverage\Node; +use function array_filter; +use function count; +use function range; +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\StaticAnalysis\AnalysisResult; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Class_; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Function_; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Method; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Trait_; + /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type TestType from CodeCoverage + * @phpstan-import-type LinesType from AnalysisResult + * + * @phpstan-type ProcessedFunctionType array{ + * functionName: string, + * namespace: string, + * signature: string, + * startLine: int, + * endLine: int, + * executableLines: int, + * executedLines: int, + * executableBranches: int, + * executedBranches: int, + * executablePaths: int, + * executedPaths: int, + * ccn: int, + * coverage: int|float, + * crap: int|string, + * link: string + * } + * @phpstan-type ProcessedMethodType array{ + * methodName: string, + * visibility: string, + * signature: string, + * startLine: int, + * endLine: int, + * executableLines: int, + * executedLines: int, + * executableBranches: int, + * executedBranches: int, + * executablePaths: int, + * executedPaths: int, + * ccn: int, + * coverage: float|int, + * crap: int|string, + * link: string + * } + * @phpstan-type ProcessedClassType array{ + * className: string, + * namespace: string, + * methods: array, + * startLine: int, + * executableLines: int, + * executedLines: int, + * executableBranches: int, + * executedBranches: int, + * executablePaths: int, + * executedPaths: int, + * ccn: int, + * coverage: int|float, + * crap: int|string, + * link: string + * } + * @phpstan-type ProcessedTraitType array{ + * traitName: string, + * namespace: string, + * methods: array, + * startLine: int, + * executableLines: int, + * executedLines: int, + * executableBranches: int, + * executedBranches: int, + * executablePaths: int, + * executedPaths: int, + * ccn: int, + * coverage: float|int, + * crap: int|string, + * link: string + * } */ final class File extends AbstractNode { /** - * @var array - */ - private $lineCoverageData; - - /** - * @var array - */ - private $functionCoverageData; - - /** - * @var array - */ - private $testData; - - /** - * @var int - */ - private $numExecutableLines = 0; - - /** - * @var int - */ - private $numExecutedLines = 0; - - /** - * @var int - */ - private $numExecutableBranches = 0; - - /** - * @var int - */ - private $numExecutedBranches = 0; - - /** - * @var int - */ - private $numExecutablePaths = 0; - - /** - * @var int - */ - private $numExecutedPaths = 0; - - /** - * @var array + * @var array> */ - private $classes = []; + private array $lineCoverageData; + private array $functionCoverageData; /** - * @var array + * @var array */ - private $traits = []; + private readonly array $testData; + private int $numExecutableLines = 0; + private int $numExecutedLines = 0; + private int $numExecutableBranches = 0; + private int $numExecutedBranches = 0; + private int $numExecutablePaths = 0; + private int $numExecutedPaths = 0; /** - * @var array + * @var array */ - private $functions = []; + private array $classes = []; /** - * @var array + * @var array */ - private $linesOfCode = []; + private array $traits = []; /** - * @var int + * @var array */ - private $numClasses; + private array $functions = []; + private readonly LinesOfCode $linesOfCode; + private ?int $numClasses = null; + private int $numTestedClasses = 0; + private ?int $numTraits = null; + private int $numTestedTraits = 0; + private ?int $numMethods = null; + private ?int $numTestedMethods = null; + private ?int $numTestedFunctions = null; /** - * @var int + * @var array */ - private $numTestedClasses = 0; + private array $codeUnitsByLine = []; /** - * @var int + * @param array> $lineCoverageData + * @param array $testData + * @param array $classes + * @param array $traits + * @param array $functions */ - private $numTraits; - - /** - * @var int - */ - private $numTestedTraits = 0; - - /** - * @var int - */ - private $numMethods; - - /** - * @var int - */ - private $numTestedMethods; - - /** - * @var int - */ - private $numTestedFunctions; - - /** - * @var bool - */ - private $cacheTokens; - - /** - * @var array - */ - private $codeUnitsByLine = []; - - public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData, bool $cacheTokens) + public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData, array $classes, array $traits, array $functions, LinesOfCode $linesOfCode) { parent::__construct($name, $parent); $this->lineCoverageData = $lineCoverageData; $this->functionCoverageData = $functionCoverageData; $this->testData = $testData; - $this->cacheTokens = $cacheTokens; + $this->linesOfCode = $linesOfCode; - $this->calculateStatistics(); + $this->calculateStatistics($classes, $traits, $functions); } public function count(): int @@ -141,6 +164,9 @@ public function count(): int return 1; } + /** + * @return array> + */ public function lineCoverageData(): array { return $this->lineCoverageData; @@ -151,27 +177,39 @@ public function functionCoverageData(): array return $this->functionCoverageData; } + /** + * @return array + */ public function testData(): array { return $this->testData; } + /** + * @return array + */ public function classes(): array { return $this->classes; } + /** + * @return array + */ public function traits(): array { return $this->traits; } + /** + * @return array + */ public function functions(): array { return $this->functions; } - public function linesOfCode(): array + public function linesOfCode(): LinesOfCode { return $this->linesOfCode; } @@ -308,7 +346,7 @@ public function numberOfTestedMethods(): int public function numberOfFunctions(): int { - return \count($this->functions); + return count($this->functions); } public function numberOfTestedFunctions(): int @@ -327,32 +365,22 @@ public function numberOfTestedFunctions(): int return $this->numTestedFunctions; } - private function calculateStatistics(): void + /** + * @param array $classes + * @param array $traits + * @param array $functions + */ + private function calculateStatistics(array $classes, array $traits, array $functions): void { - if ($this->cacheTokens) { - $tokens = \PHP_Token_Stream_CachingFactory::get($this->pathAsString()); - } else { - $tokens = new \PHP_Token_Stream($this->pathAsString()); - } - - $this->linesOfCode = $tokens->getLinesOfCode(); - - foreach (\range(1, $this->linesOfCode['loc']) as $lineNumber) { + foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) { $this->codeUnitsByLine[$lineNumber] = []; } - try { - $this->processClasses($tokens); - $this->processTraits($tokens); - $this->processFunctions($tokens); - } catch (\OutOfBoundsException $e) { - // This can happen with PHP_Token_Stream if the file is syntactically invalid, - // and probably affects a file that wasn't executed. - } - - unset($tokens); + $this->processClasses($classes); + $this->processTraits($traits); + $this->processFunctions($functions); - foreach (\range(1, $this->linesOfCode['loc']) as $lineNumber) { + foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) { if (isset($this->lineCoverageData[$lineNumber])) { foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) { $codeUnit['executableLines']++; @@ -362,7 +390,7 @@ private function calculateStatistics(): void $this->numExecutableLines++; - if (\count($this->lineCoverageData[$lineNumber]) > 0) { + if (count($this->lineCoverageData[$lineNumber]) > 0) { foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) { $codeUnit['executedLines']++; } @@ -376,112 +404,87 @@ private function calculateStatistics(): void foreach ($this->traits as &$trait) { foreach ($trait['methods'] as &$method) { - $methodLineCoverage = $method['executableLines'] ? ($method['executedLines'] / $method['executableLines']) * 100 : 100; - $methodBranchCoverage = $method['executableBranches'] ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0; - $methodPathCoverage = $method['executablePaths'] ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0; + $methodLineCoverage = $method['executableLines'] > 0 ? ($method['executedLines'] / $method['executableLines']) * 100 : 100; + $methodBranchCoverage = $method['executableBranches'] > 0 ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0; + $methodPathCoverage = $method['executablePaths'] > 0 ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0; - $method['coverage'] = $methodBranchCoverage ?: $methodLineCoverage; - - $method['crap'] = $this->crap( - $method['ccn'], - $methodPathCoverage ?: $methodLineCoverage - ); + $method['coverage'] = $methodBranchCoverage > 0 ? $methodBranchCoverage : $methodLineCoverage; + $method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage > 0 ? $methodPathCoverage : $methodLineCoverage))->asString(); $trait['ccn'] += $method['ccn']; } unset($method); - $traitLineCoverage = $trait['executableLines'] ? ($trait['executedLines'] / $trait['executableLines']) * 100 : 100; - $traitBranchCoverage = $trait['executableBranches'] ? ($trait['executedBranches'] / $trait['executableBranches']) * 100 : 0; - $traitPathCoverage = $trait['executablePaths'] ? ($trait['executedPaths'] / $trait['executablePaths']) * 100 : 0; + $traitLineCoverage = $trait['executableLines'] > 0 ? ($trait['executedLines'] / $trait['executableLines']) * 100 : 100; + $traitBranchCoverage = $trait['executableBranches'] > 0 ? ($trait['executedBranches'] / $trait['executableBranches']) * 100 : 0; + $traitPathCoverage = $trait['executablePaths'] > 0 ? ($trait['executedPaths'] / $trait['executablePaths']) * 100 : 0; - $trait['coverage'] = $traitBranchCoverage ?: $traitLineCoverage; + $trait['coverage'] = $traitBranchCoverage > 0 ? $traitBranchCoverage : $traitLineCoverage; + $trait['crap'] = (new CrapIndex($trait['ccn'], $traitPathCoverage > 0 ? $traitPathCoverage : $traitLineCoverage))->asString(); if ($trait['executableLines'] > 0 && $trait['coverage'] === 100) { $this->numTestedClasses++; } - - $trait['crap'] = $this->crap( - $trait['ccn'], - $traitPathCoverage ?: $traitLineCoverage - ); } unset($trait); foreach ($this->classes as &$class) { foreach ($class['methods'] as &$method) { - $methodLineCoverage = $method['executableLines'] ? ($method['executedLines'] / $method['executableLines']) * 100 : 100; - $methodBranchCoverage = $method['executableBranches'] ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0; - $methodPathCoverage = $method['executablePaths'] ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0; + $methodLineCoverage = $method['executableLines'] > 0 ? ($method['executedLines'] / $method['executableLines']) * 100 : 100; + $methodBranchCoverage = $method['executableBranches'] > 0 ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0; + $methodPathCoverage = $method['executablePaths'] > 0 ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0; - $method['coverage'] = $methodBranchCoverage ?: $methodLineCoverage; - - $method['crap'] = $this->crap( - $method['ccn'], - $methodPathCoverage ?: $methodLineCoverage - ); + $method['coverage'] = $methodBranchCoverage > 0 ? $methodBranchCoverage : $methodLineCoverage; + $method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage > 0 ? $methodPathCoverage : $methodLineCoverage))->asString(); $class['ccn'] += $method['ccn']; } unset($method); - $classLineCoverage = $class['executableLines'] ? ($class['executedLines'] / $class['executableLines']) * 100 : 100; - $classBranchCoverage = $class['executableBranches'] ? ($class['executedBranches'] / $class['executableBranches']) * 100 : 0; - $classPathCoverage = $class['executablePaths'] ? ($class['executedPaths'] / $class['executablePaths']) * 100 : 0; + $classLineCoverage = $class['executableLines'] > 0 ? ($class['executedLines'] / $class['executableLines']) * 100 : 100; + $classBranchCoverage = $class['executableBranches'] > 0 ? ($class['executedBranches'] / $class['executableBranches']) * 100 : 0; + $classPathCoverage = $class['executablePaths'] > 0 ? ($class['executedPaths'] / $class['executablePaths']) * 100 : 0; - $class['coverage'] = $classBranchCoverage ?: $classLineCoverage; + $class['coverage'] = $classBranchCoverage > 0 ? $classBranchCoverage : $classLineCoverage; + $class['crap'] = (new CrapIndex($class['ccn'], $classPathCoverage > 0 ? $classPathCoverage : $classLineCoverage))->asString(); if ($class['executableLines'] > 0 && $class['coverage'] === 100) { $this->numTestedClasses++; } - - $class['crap'] = $this->crap( - $class['ccn'], - $classPathCoverage ?: $classLineCoverage - ); } unset($class); foreach ($this->functions as &$function) { - $functionLineCoverage = $function['executableLines'] ? ($function['executedLines'] / $function['executableLines']) * 100 : 100; - $functionBranchCoverage = $function['executableBranches'] ? ($function['executedBranches'] / $function['executableBranches']) * 100 : 0; - $functionPathCoverage = $function['executablePaths'] ? ($function['executedPaths'] / $function['executablePaths']) * 100 : 0; + $functionLineCoverage = $function['executableLines'] > 0 ? ($function['executedLines'] / $function['executableLines']) * 100 : 100; + $functionBranchCoverage = $function['executableBranches'] > 0 ? ($function['executedBranches'] / $function['executableBranches']) * 100 : 0; + $functionPathCoverage = $function['executablePaths'] > 0 ? ($function['executedPaths'] / $function['executablePaths']) * 100 : 0; - $function['coverage'] = $functionBranchCoverage ?: $functionLineCoverage; + $function['coverage'] = $functionBranchCoverage > 0 ? $functionBranchCoverage : $functionLineCoverage; + $function['crap'] = (new CrapIndex($function['ccn'], $functionPathCoverage > 0 ? $functionPathCoverage : $functionLineCoverage))->asString(); if ($function['coverage'] === 100) { $this->numTestedFunctions++; } - - $function['crap'] = $this->crap( - $function['ccn'], - $functionPathCoverage ?: $functionLineCoverage - ); } } - private function processClasses(\PHP_Token_Stream $tokens): void + /** + * @param array $classes + */ + private function processClasses(array $classes): void { - $classes = $tokens->getClasses(); - $link = $this->id() . '.html#'; + $link = $this->id() . '.html#'; foreach ($classes as $className => $class) { - if (\strpos($className, 'anonymous') === 0) { - continue; - } - - if (!empty($class['package']['namespace'])) { - $className = $class['package']['namespace'] . '\\' . $className; - } - $this->classes[$className] = [ 'className' => $className, + 'namespace' => $class->namespace(), 'methods' => [], - 'startLine' => $class['startLine'], + 'startLine' => $class->startLine(), 'executableLines' => 0, 'executedLines' => 0, 'executableBranches' => 0, @@ -491,29 +494,24 @@ private function processClasses(\PHP_Token_Stream $tokens): void 'ccn' => 0, 'coverage' => 0, 'crap' => 0, - 'package' => $class['package'], - 'link' => $link . $class['startLine'], + 'link' => $link . $class->startLine(), ]; - foreach ($class['methods'] as $methodName => $method) { - if (\strpos($methodName, 'anonymous') === 0) { - continue; - } - - $methodData = $this->newMethod($className, $methodName, $method, $link); + foreach ($class->methods() as $methodName => $method) { + $methodData = $this->newMethod($className, $method, $link); $this->classes[$className]['methods'][$methodName] = $methodData; $this->classes[$className]['executableBranches'] += $methodData['executableBranches']; - $this->classes[$className]['executedBranches'] += $methodData['executedBranches']; - $this->classes[$className]['executablePaths'] += $methodData['executablePaths']; - $this->classes[$className]['executedPaths'] += $methodData['executedPaths']; + $this->classes[$className]['executedBranches'] += $methodData['executedBranches']; + $this->classes[$className]['executablePaths'] += $methodData['executablePaths']; + $this->classes[$className]['executedPaths'] += $methodData['executedPaths']; $this->numExecutableBranches += $methodData['executableBranches']; - $this->numExecutedBranches += $methodData['executedBranches']; - $this->numExecutablePaths += $methodData['executablePaths']; - $this->numExecutedPaths += $methodData['executedPaths']; + $this->numExecutedBranches += $methodData['executedBranches']; + $this->numExecutablePaths += $methodData['executablePaths']; + $this->numExecutedPaths += $methodData['executedPaths']; - foreach (\range($method['startLine'], $method['endLine']) as $lineNumber) { + foreach (range($method->startLine(), $method->endLine()) as $lineNumber) { $this->codeUnitsByLine[$lineNumber] = [ &$this->classes[$className], &$this->classes[$className]['methods'][$methodName], @@ -523,20 +521,19 @@ private function processClasses(\PHP_Token_Stream $tokens): void } } - private function processTraits(\PHP_Token_Stream $tokens): void + /** + * @param array $traits + */ + private function processTraits(array $traits): void { - $traits = $tokens->getTraits(); - $link = $this->id() . '.html#'; + $link = $this->id() . '.html#'; foreach ($traits as $traitName => $trait) { - if (!empty($trait['package']['namespace'])) { - $traitName = $trait['package']['namespace'] . '\\' . $traitName; - } - $this->traits[$traitName] = [ 'traitName' => $traitName, + 'namespace' => $trait->namespace(), 'methods' => [], - 'startLine' => $trait['startLine'], + 'startLine' => $trait->startLine(), 'executableLines' => 0, 'executedLines' => 0, 'executableBranches' => 0, @@ -546,24 +543,24 @@ private function processTraits(\PHP_Token_Stream $tokens): void 'ccn' => 0, 'coverage' => 0, 'crap' => 0, - 'package' => $trait['package'], - 'link' => $link . $trait['startLine'], + 'link' => $link . $trait->startLine(), ]; - foreach ($trait['methods'] as $methodName => $method) { - if (\strpos($methodName, 'anonymous') === 0) { - continue; - } - - $methodData = $this->newMethod($traitName, $methodName, $method, $link); + foreach ($trait->methods() as $methodName => $method) { + $methodData = $this->newMethod($traitName, $method, $link); $this->traits[$traitName]['methods'][$methodName] = $methodData; $this->traits[$traitName]['executableBranches'] += $methodData['executableBranches']; - $this->traits[$traitName]['executedBranches'] += $methodData['executedBranches']; - $this->traits[$traitName]['executablePaths'] += $methodData['executablePaths']; - $this->traits[$traitName]['executedPaths'] += $methodData['executedPaths']; + $this->traits[$traitName]['executedBranches'] += $methodData['executedBranches']; + $this->traits[$traitName]['executablePaths'] += $methodData['executablePaths']; + $this->traits[$traitName]['executedPaths'] += $methodData['executedPaths']; + + $this->numExecutableBranches += $methodData['executableBranches']; + $this->numExecutedBranches += $methodData['executedBranches']; + $this->numExecutablePaths += $methodData['executablePaths']; + $this->numExecutedPaths += $methodData['executedPaths']; - foreach (\range($method['startLine'], $method['endLine']) as $lineNumber) { + foreach (range($method->startLine(), $method->endLine()) as $lineNumber) { $this->codeUnitsByLine[$lineNumber] = [ &$this->traits[$traitName], &$this->traits[$traitName]['methods'][$methodName], @@ -573,88 +570,130 @@ private function processTraits(\PHP_Token_Stream $tokens): void } } - private function processFunctions(\PHP_Token_Stream $tokens): void + /** + * @param array $functions + */ + private function processFunctions(array $functions): void { - $functions = $tokens->getFunctions(); - $link = $this->id() . '.html#'; + $link = $this->id() . '.html#'; foreach ($functions as $functionName => $function) { - if (\strpos($functionName, 'anonymous') === 0) { - continue; - } - $this->functions[$functionName] = [ 'functionName' => $functionName, - 'signature' => $function['signature'], - 'startLine' => $function['startLine'], + 'namespace' => $function->namespace(), + 'signature' => $function->signature(), + 'startLine' => $function->startLine(), + 'endLine' => $function->endLine(), 'executableLines' => 0, 'executedLines' => 0, 'executableBranches' => 0, 'executedBranches' => 0, 'executablePaths' => 0, 'executedPaths' => 0, - 'ccn' => $function['ccn'], + 'ccn' => $function->cyclomaticComplexity(), 'coverage' => 0, 'crap' => 0, - 'link' => $link . $function['startLine'], + 'link' => $link . $function->startLine(), ]; - foreach (\range($function['startLine'], $function['endLine']) as $lineNumber) { + foreach (range($function->startLine(), $function->endLine()) as $lineNumber) { $this->codeUnitsByLine[$lineNumber] = [&$this->functions[$functionName]]; } - } - } - private function crap(int $ccn, float $coverage): string - { - if ($coverage === 0.0) { - return (string) ($ccn ** 2 + $ccn); - } + if (isset($this->functionCoverageData[$functionName]['branches'])) { + $this->functions[$functionName]['executableBranches'] = count( + $this->functionCoverageData[$functionName]['branches'], + ); - if ($coverage >= 95) { - return (string) $ccn; - } + $this->functions[$functionName]['executedBranches'] = count( + array_filter( + $this->functionCoverageData[$functionName]['branches'], + static function (array $branch) + { + return (bool) $branch['hit']; + }, + ), + ); + } - return \sprintf( - '%01.2F', - $ccn ** 2 * (1 - $coverage / 100) ** 3 + $ccn - ); + if (isset($this->functionCoverageData[$functionName]['paths'])) { + $this->functions[$functionName]['executablePaths'] = count( + $this->functionCoverageData[$functionName]['paths'], + ); + + $this->functions[$functionName]['executedPaths'] = count( + array_filter( + $this->functionCoverageData[$functionName]['paths'], + static function (array $path) + { + return (bool) $path['hit']; + }, + ), + ); + } + + $this->numExecutableBranches += $this->functions[$functionName]['executableBranches']; + $this->numExecutedBranches += $this->functions[$functionName]['executedBranches']; + $this->numExecutablePaths += $this->functions[$functionName]['executablePaths']; + $this->numExecutedPaths += $this->functions[$functionName]['executedPaths']; + } } - private function newMethod(string $className, string $methodName, array $method, string $link): array + /** + * @return ProcessedMethodType + */ + private function newMethod(string $className, Method $method, string $link): array { $methodData = [ - 'methodName' => $methodName, - 'visibility' => $method['visibility'], - 'signature' => $method['signature'], - 'startLine' => $method['startLine'], - 'endLine' => $method['endLine'], + 'methodName' => $method->name(), + 'visibility' => $method->visibility()->value, + 'signature' => $method->signature(), + 'startLine' => $method->startLine(), + 'endLine' => $method->endLine(), 'executableLines' => 0, 'executedLines' => 0, 'executableBranches' => 0, 'executedBranches' => 0, 'executablePaths' => 0, 'executedPaths' => 0, - 'ccn' => $method['ccn'], + 'ccn' => $method->cyclomaticComplexity(), 'coverage' => 0, 'crap' => 0, - 'link' => $link . $method['startLine'], + 'link' => $link . $method->startLine(), ]; - $key = $className . '->' . $methodName; + $key = $className . '->' . $method->name(); if (isset($this->functionCoverageData[$key]['branches'])) { - $methodData['executableBranches'] = \count($this->functionCoverageData[$key]['branches']); - $methodData['executedBranches'] = \count(\array_filter($this->functionCoverageData[$key]['branches'], static function ($branch) { - return (bool) $branch['hit']; - })); + $methodData['executableBranches'] = count( + $this->functionCoverageData[$key]['branches'], + ); + + $methodData['executedBranches'] = count( + array_filter( + $this->functionCoverageData[$key]['branches'], + static function (array $branch) + { + return (bool) $branch['hit']; + }, + ), + ); } if (isset($this->functionCoverageData[$key]['paths'])) { - $methodData['executablePaths'] = \count($this->functionCoverageData[$key]['paths']); - $methodData['executedPaths'] = \count(\array_filter($this->functionCoverageData[$key]['paths'], static function ($path) { - return (bool) $path['hit']; - })); + $methodData['executablePaths'] = count( + $this->functionCoverageData[$key]['paths'], + ); + + $methodData['executedPaths'] = count( + array_filter( + $this->functionCoverageData[$key]['paths'], + static function (array $path) + { + return (bool) $path['hit']; + }, + ), + ); } return $methodData; diff --git a/src/Node/Iterator.php b/src/Node/Iterator.php index c68f57efc..ab3c8eb98 100644 --- a/src/Node/Iterator.php +++ b/src/Node/Iterator.php @@ -9,79 +9,61 @@ */ namespace SebastianBergmann\CodeCoverage\Node; +use function assert; +use function count; +use RecursiveIterator; + /** + * @template-implements RecursiveIterator + * * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ -final class Iterator implements \RecursiveIterator +final class Iterator implements RecursiveIterator { - /** - * @var int - */ - private $position; + private int $position; /** - * @var AbstractNode[] + * @var list */ - private $nodes; + private readonly array $nodes; public function __construct(Directory $node) { $this->nodes = $node->children(); } - /** - * Rewinds the Iterator to the first element. - */ public function rewind(): void { $this->position = 0; } - /** - * Checks if there is a current element after calls to rewind() or next(). - */ public function valid(): bool { - return $this->position < \count($this->nodes); + return $this->position < count($this->nodes); } - /** - * Returns the key of the current element. - */ public function key(): int { return $this->position; } - /** - * Returns the current element. - */ public function current(): ?AbstractNode { return $this->valid() ? $this->nodes[$this->position] : null; } - /** - * Moves forward to next element. - */ public function next(): void { $this->position++; } - /** - * Returns the sub iterator for the current element. - * - * @return Iterator - */ public function getChildren(): self { + assert($this->nodes[$this->position] instanceof Directory); + return new self($this->nodes[$this->position]); } - /** - * Checks whether the current element has children. - */ public function hasChildren(): bool { return $this->nodes[$this->position] instanceof Directory; diff --git a/src/ProcessedCodeCoverageData.php b/src/ProcessedCodeCoverageData.php deleted file mode 100644 index 67ac47d17..000000000 --- a/src/ProcessedCodeCoverageData.php +++ /dev/null @@ -1,209 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -namespace SebastianBergmann\CodeCoverage; - -use SebastianBergmann\CodeCoverage\Driver\Driver; - -/** - * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage - */ -final class ProcessedCodeCoverageData -{ - /** - * Line coverage data. - * An array of filenames, each having an array of linenumbers, each executable line having an array of testcase ids. - * - * @var array - */ - private $lineCoverage = []; - - /** - * Function coverage data. - * Maintains base format of raw data (@see https://xdebug.org/docs/code_coverage), but each 'hit' entry is an array - * of testcase ids - * - * @var array - */ - private $functionCoverage = []; - - public function initializeFilesThatAreSeenTheFirstTime(RawCodeCoverageData $rawData): void - { - foreach ($rawData->lineCoverage() as $file => $lines) { - if (!isset($this->lineCoverage[$file])) { - $this->lineCoverage[$file] = []; - - foreach ($lines as $k => $v) { - $this->lineCoverage[$file][$k] = $v === Driver::LINE_NOT_EXECUTABLE ? null : []; - } - } - } - - foreach ($rawData->functionCoverage() as $file => $functions) { - if (!isset($this->functionCoverage[$file])) { - $this->functionCoverage[$file] = $functions; - - foreach ($this->functionCoverage[$file] as $functionName => $functionData) { - foreach (\array_keys($this->functionCoverage[$file][$functionName]['branches']) as $branchId) { - $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'] = []; - } - - foreach (\array_keys($this->functionCoverage[$file][$functionName]['paths']) as $pathId) { - $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'] = []; - } - } - } - } - } - - public function markCodeAsExecutedByTestCase(string $testCaseId, RawCodeCoverageData $executedCode): void - { - foreach ($executedCode->lineCoverage() as $file => $lines) { - foreach ($lines as $k => $v) { - if ($v === Driver::LINE_EXECUTED) { - if (empty($this->lineCoverage[$file][$k]) || !\in_array($testCaseId, $this->lineCoverage[$file][$k], true)) { - $this->lineCoverage[$file][$k][] = $testCaseId; - } - } - } - } - - foreach ($executedCode->functionCoverage() as $file => $functions) { - foreach ($functions as $functionName => $functionData) { - foreach ($functionData['branches'] as $branchId => $branchData) { - if ($branchData['hit'] === Driver::BRANCH_HIT) { - if (!\in_array($testCaseId, $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'], true)) { - $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'][] = $testCaseId; - } - } - } - - foreach ($functionData['paths'] as $pathId => $pathData) { - if ($pathData['hit'] === Driver::BRANCH_HIT) { - if (!\in_array($testCaseId, $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'], true)) { - $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'][] = $testCaseId; - } - } - } - } - } - } - - public function setLineCoverage(array $lineCoverage): void - { - $this->lineCoverage = $lineCoverage; - } - - public function lineCoverage(): array - { - \ksort($this->lineCoverage); - - return $this->lineCoverage; - } - - public function functionCoverage(): array - { - \ksort($this->functionCoverage); - - return $this->functionCoverage; - } - - public function coveredFiles(): array - { - return \array_keys($this->lineCoverage); - } - - public function renameFile(string $oldFile, string $newFile): void - { - $this->lineCoverage[$newFile] = $this->lineCoverage[$oldFile]; - - if (isset($this->functionCoverage[$oldFile])) { - $this->functionCoverage[$newFile] = $this->functionCoverage[$oldFile]; - } - - unset($this->lineCoverage[$oldFile], $this->functionCoverage[$oldFile]); - } - - public function merge(self $newData): void - { - foreach ($newData->lineCoverage as $file => $lines) { - if (!isset($this->lineCoverage[$file])) { - $this->lineCoverage[$file] = $lines; - - continue; - } - - // we should compare the lines if any of two contains data - $compareLineNumbers = \array_unique( - \array_merge( - \array_keys($this->lineCoverage[$file]), - \array_keys($newData->lineCoverage[$file]) - ) - ); - - foreach ($compareLineNumbers as $line) { - $thatPriority = $this->priorityForLine($newData->lineCoverage[$file], $line); - $thisPriority = $this->priorityForLine($this->lineCoverage[$file], $line); - - if ($thatPriority > $thisPriority) { - $this->lineCoverage[$file][$line] = $newData->lineCoverage[$file][$line]; - } elseif ($thatPriority === $thisPriority && \is_array($this->lineCoverage[$file][$line])) { - $this->lineCoverage[$file][$line] = \array_unique( - \array_merge($this->lineCoverage[$file][$line], $newData->lineCoverage[$file][$line]) - ); - } - } - } - - foreach ($newData->functionCoverage as $file => $functions) { - if (!isset($this->functionCoverage[$file])) { - $this->functionCoverage[$file] = $functions; - - continue; - } - - foreach ($functions as $functionName => $functionData) { - foreach ($functionData['branches'] as $branchId => $branchData) { - $this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'] = \array_unique(\array_merge($this->functionCoverage[$file][$functionName]['branches'][$branchId]['hit'], $branchData['hit'])); - } - - foreach ($functionData['paths'] as $pathId => $pathData) { - $this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'] = \array_unique(\array_merge($this->functionCoverage[$file][$functionName]['paths'][$pathId]['hit'], $pathData['hit'])); - } - } - } - } - - /** - * Determine the priority for a line - * - * 1 = the line is not set - * 2 = the line has not been tested - * 3 = the line is dead code - * 4 = the line has been tested - * - * During a merge, a higher number is better. - */ - private function priorityForLine(array $data, int $line): int - { - if (!\array_key_exists($line, $data)) { - return 1; - } - - if (\is_array($data[$line]) && \count($data[$line]) === 0) { - return 2; - } - - if ($data[$line] === null) { - return 3; - } - - return 4; - } -} diff --git a/src/RawCodeCoverageData.php b/src/RawCodeCoverageData.php deleted file mode 100644 index ceb12346b..000000000 --- a/src/RawCodeCoverageData.php +++ /dev/null @@ -1,260 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -namespace SebastianBergmann\CodeCoverage; - -use PHP_Token_Stream; -use SebastianBergmann\CodeCoverage\Driver\Driver; - -/** - * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage - */ -final class RawCodeCoverageData -{ - /** - * @var array - * - * @see https://xdebug.org/docs/code_coverage for format - */ - private $lineCoverage = []; - - /** - * @var array - * - * @see https://xdebug.org/docs/code_coverage for format - */ - private $functionCoverage = []; - - public static function fromXdebugWithoutPathCoverage(array $rawCoverage): self - { - return new self($rawCoverage, []); - } - - public static function fromXdebugWithPathCoverage(array $rawCoverage): self - { - $lineCoverage = []; - $functionCoverage = []; - - foreach ($rawCoverage as $file => $fileCoverageData) { - if (!isset($fileCoverageData['functions'])) { - // Current file does not have functions, so line coverage - // is stored in $fileCoverageData, not in $fileCoverageData['lines'] - $lineCoverage[$file] = $fileCoverageData; - - continue; - } - - $lineCoverage[$file] = $fileCoverageData['lines']; - $functionCoverage[$file] = $fileCoverageData['functions']; - } - - return new self($lineCoverage, $functionCoverage); - } - - public static function fromUncoveredFile(string $filename, PHP_Token_Stream $tokens): self - { - $lineCoverage = []; - - $lines = \file($filename); - $lineCount = \count($lines); - - for ($i = 1; $i <= $lineCount; $i++) { - $lineCoverage[$i] = Driver::LINE_NOT_EXECUTED; - } - - //remove empty lines - foreach ($lines as $index => $line) { - if (!\trim($line)) { - unset($lineCoverage[$index + 1]); - } - } - - //not all lines are actually executable though, remove these - try { - foreach ($tokens->getInterfaces() as $interface) { - $interfaceStartLine = $interface['startLine']; - $interfaceEndLine = $interface['endLine']; - - foreach (\range($interfaceStartLine, $interfaceEndLine) as $line) { - unset($lineCoverage[$line]); - } - } - - foreach (\array_merge($tokens->getClasses(), $tokens->getTraits()) as $classOrTrait) { - $classOrTraitStartLine = $classOrTrait['startLine']; - $classOrTraitEndLine = $classOrTrait['endLine']; - - if (empty($classOrTrait['methods'])) { - foreach (\range($classOrTraitStartLine, $classOrTraitEndLine) as $line) { - unset($lineCoverage[$line]); - } - - continue; - } - - $firstMethod = \array_shift($classOrTrait['methods']); - $firstMethodStartLine = $firstMethod['startLine']; - $lastMethodEndLine = $firstMethod['endLine']; - - do { - $lastMethod = \array_pop($classOrTrait['methods']); - } while ($lastMethod !== null && 0 === \strpos($lastMethod['signature'], 'anonymousFunction')); - - if ($lastMethod !== null) { - $lastMethodEndLine = $lastMethod['endLine']; - } - - foreach (\range($classOrTraitStartLine, $firstMethodStartLine) as $line) { - unset($lineCoverage[$line]); - } - - foreach (\range($lastMethodEndLine + 1, $classOrTraitEndLine) as $line) { - unset($lineCoverage[$line]); - } - } - - foreach ($tokens->tokens() as $token) { - switch (\get_class($token)) { - case \PHP_Token_COMMENT::class: - case \PHP_Token_DOC_COMMENT::class: - $_token = \trim((string) $token); - $_line = \trim($lines[$token->getLine() - 1]); - - $start = $token->getLine(); - $end = $start + \substr_count((string) $token, "\n"); - - // Do not ignore the first line when there is a token - // before the comment - if (0 !== \strpos($_token, $_line)) { - $start++; - } - - for ($i = $start; $i < $end; $i++) { - unset($lineCoverage[$i]); - } - - // A DOC_COMMENT token or a COMMENT token starting with "/*" - // does not contain the final \n character in its text - if (isset($lines[$i - 1]) && 0 === \strpos($_token, '/*') && '*/' === \substr(\trim($lines[$i - 1]), -2)) { - unset($lineCoverage[$i]); - } - - break; - - /* @noinspection PhpMissingBreakStatementInspection */ - case \PHP_Token_NAMESPACE::class: - unset($lineCoverage[$token->getEndLine()]); - - // Intentional fallthrough - - case \PHP_Token_INTERFACE::class: - case \PHP_Token_TRAIT::class: - case \PHP_Token_CLASS::class: - case \PHP_Token_FUNCTION::class: - case \PHP_Token_DECLARE::class: - case \PHP_Token_OPEN_TAG::class: - case \PHP_Token_CLOSE_TAG::class: - case \PHP_Token_USE::class: - case \PHP_Token_USE_FUNCTION::class: - unset($lineCoverage[$token->getLine()]); - - break; - } - } - } catch (\Exception $e) { // This can happen with PHP_Token_Stream if the file is syntactically invalid - // do nothing - } - - return new self([$filename => $lineCoverage], []); - } - - private function __construct(array $lineCoverage, array $functionCoverage) - { - $this->lineCoverage = $lineCoverage; - $this->functionCoverage = $functionCoverage; - } - - public function clear(): void - { - $this->lineCoverage = $this->functionCoverage = []; - } - - public function lineCoverage(): array - { - return $this->lineCoverage; - } - - public function functionCoverage(): array - { - return $this->functionCoverage; - } - - public function removeCoverageDataForFile(string $filename): void - { - unset($this->lineCoverage[$filename], $this->functionCoverage[$filename]); - } - - /** - * @param int[] $lines - */ - public function keepCoverageDataOnlyForLines(string $filename, array $lines): void - { - $this->lineCoverage[$filename] = \array_intersect_key( - $this->lineCoverage[$filename], - \array_flip($lines) - ); - - if (isset($this->functionCoverage[$filename])) { - foreach ($this->functionCoverage[$filename] as $functionName => $functionData) { - foreach ($functionData['branches'] as $branchId => $branch) { - if (\count(\array_diff(\range($branch['line_start'], $branch['line_end']), $lines)) > 0) { - unset($this->functionCoverage[$filename][$functionName]['branches'][$branchId]); - - foreach ($functionData['paths'] as $pathId => $path) { - if (\in_array($branchId, $path['path'], true)) { - unset($this->functionCoverage[$filename][$functionName]['paths'][$pathId]); - } - } - } - } - } - } - } - - /** - * @param int[] $lines - */ - public function removeCoverageDataForLines(string $filename, array $lines): void - { - if (empty($lines)) { - return; - } - - $this->lineCoverage[$filename] = \array_diff_key( - $this->lineCoverage[$filename], - \array_flip($lines) - ); - - if (isset($this->functionCoverage[$filename])) { - foreach ($this->functionCoverage[$filename] as $functionName => $functionData) { - foreach ($functionData['branches'] as $branchId => $branch) { - if (\count(\array_intersect($lines, \range($branch['line_start'], $branch['line_end']))) > 0) { - unset($this->functionCoverage[$filename][$functionName]['branches'][$branchId]); - - foreach ($functionData['paths'] as $pathId => $path) { - if (\in_array($branchId, $path['path'], true)) { - unset($this->functionCoverage[$filename][$functionName]['paths'][$pathId]); - } - } - } - } - } - } - } -} diff --git a/src/Report/Clover.php b/src/Report/Clover.php index f6e5d844d..a5f1c09e6 100644 --- a/src/Report/Clover.php +++ b/src/Report/Clover.php @@ -9,24 +9,31 @@ */ namespace SebastianBergmann\CodeCoverage\Report; +use function count; +use function dirname; +use function file_put_contents; +use function is_string; +use function ksort; +use function max; +use function range; +use function str_contains; +use function time; +use DOMDocument; use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Directory; use SebastianBergmann\CodeCoverage\Node\File; -use SebastianBergmann\CodeCoverage\RuntimeException; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; -/** - * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage - */ final class Clover { /** - * @throws \RuntimeException + * @throws WriteOperationFailedException */ public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { - $time = (string) \time(); + $time = (string) time(); - $xmlDocument = new \DOMDocument('1.0', 'UTF-8'); + $xmlDocument = new DOMDocument('1.0', 'UTF-8'); $xmlDocument->formatOutput = true; $xmlCoverage = $xmlDocument->createElement('coverage'); @@ -36,7 +43,7 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $xmlProject = $xmlDocument->createElement('project'); $xmlProject->setAttribute('timestamp', $time); - if (\is_string($name)) { + if (is_string($name)) { $xmlProject->setAttribute('name', $name); } @@ -66,73 +73,48 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $coveredMethods = 0; $classMethods = 0; + // Assumption: one namespace per file + if ($class['namespace'] !== '') { + $namespace = $class['namespace']; + } + foreach ($class['methods'] as $methodName => $method) { + /** @phpstan-ignore equal.notAllowed */ if ($method['executableLines'] == 0) { continue; } $classMethods++; - $classStatements += $method['executableLines']; + $classStatements += $method['executableLines']; $coveredClassStatements += $method['executedLines']; + /** @phpstan-ignore equal.notAllowed */ if ($method['coverage'] == 100) { $coveredMethods++; } $methodCount = 0; - foreach (\range($method['startLine'], $method['endLine']) as $line) { - if (isset($coverageData[$line]) && ($coverageData[$line] !== null)) { - $methodCount = \max($methodCount, \count($coverageData[$line])); + foreach (range($method['startLine'], $method['endLine']) as $line) { + if (isset($coverageData[$line])) { + $methodCount = max($methodCount, count($coverageData[$line])); } } $lines[$method['startLine']] = [ - 'ccn' => $method['ccn'], - 'count' => $methodCount, - 'crap' => $method['crap'], - 'type' => 'method', - 'visibility' => $method['visibility'], - 'name' => $methodName, + 'ccn' => $method['ccn'], + 'count' => $methodCount, + 'crap' => $method['crap'], + 'type' => 'method', + 'visibility' => $method['visibility'], + 'name' => $methodName, ]; } - if (!empty($class['package']['namespace'])) { - $namespace = $class['package']['namespace']; - } - $xmlClass = $xmlDocument->createElement('class'); $xmlClass->setAttribute('name', $className); $xmlClass->setAttribute('namespace', $namespace); - if (!empty($class['package']['fullPackage'])) { - $xmlClass->setAttribute( - 'fullPackage', - $class['package']['fullPackage'] - ); - } - - if (!empty($class['package']['category'])) { - $xmlClass->setAttribute( - 'category', - $class['package']['category'] - ); - } - - if (!empty($class['package']['package'])) { - $xmlClass->setAttribute( - 'package', - $class['package']['package'] - ); - } - - if (!empty($class['package']['subpackage'])) { - $xmlClass->setAttribute( - 'subpackage', - $class['package']['subpackage'] - ); - } - $xmlFile->appendChild($xmlClass); $xmlMetrics = $xmlDocument->createElement('metrics'); @@ -154,11 +136,11 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string } $lines[$line] = [ - 'count' => \count($data), 'type' => 'stmt', + 'count' => count($data), 'type' => 'stmt', ]; } - \ksort($lines); + ksort($lines); foreach ($lines as $line => $data) { $xmlLine = $xmlDocument->createElement('line'); @@ -188,8 +170,8 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $linesOfCode = $item->linesOfCode(); $xmlMetrics = $xmlDocument->createElement('metrics'); - $xmlMetrics->setAttribute('loc', (string) $linesOfCode['loc']); - $xmlMetrics->setAttribute('ncloc', (string) $linesOfCode['ncloc']); + $xmlMetrics->setAttribute('loc', (string) $linesOfCode->linesOfCode()); + $xmlMetrics->setAttribute('ncloc', (string) $linesOfCode->nonCommentLinesOfCode()); $xmlMetrics->setAttribute('classes', (string) $item->numberOfClassesAndTraits()); $xmlMetrics->setAttribute('methods', (string) $item->numberOfMethods()); $xmlMetrics->setAttribute('coveredmethods', (string) $item->numberOfTestedMethods()); @@ -206,7 +188,7 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string } else { if (!isset($packages[$namespace])) { $packages[$namespace] = $xmlDocument->createElement( - 'package' + 'package', ); $packages[$namespace]->setAttribute('name', $namespace); @@ -220,9 +202,9 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $linesOfCode = $report->linesOfCode(); $xmlMetrics = $xmlDocument->createElement('metrics'); - $xmlMetrics->setAttribute('files', (string) \count($report)); - $xmlMetrics->setAttribute('loc', (string) $linesOfCode['loc']); - $xmlMetrics->setAttribute('ncloc', (string) $linesOfCode['ncloc']); + $xmlMetrics->setAttribute('files', (string) count($report)); + $xmlMetrics->setAttribute('loc', (string) $linesOfCode->linesOfCode()); + $xmlMetrics->setAttribute('ncloc', (string) $linesOfCode->nonCommentLinesOfCode()); $xmlMetrics->setAttribute('classes', (string) $report->numberOfClassesAndTraits()); $xmlMetrics->setAttribute('methods', (string) $report->numberOfMethods()); $xmlMetrics->setAttribute('coveredmethods', (string) $report->numberOfTestedMethods()); @@ -237,15 +219,12 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $buffer = $xmlDocument->saveXML(); if ($target !== null) { - Directory::create(\dirname($target)); - - if (@\file_put_contents($target, $buffer) === false) { - throw new RuntimeException( - \sprintf( - 'Could not write to "%s', - $target - ) - ); + if (!str_contains($target, '://')) { + Filesystem::createDirectory(dirname($target)); + } + + if (@file_put_contents($target, $buffer) === false) { + throw new WriteOperationFailedException($target); } } diff --git a/src/Report/Cobertura.php b/src/Report/Cobertura.php new file mode 100644 index 000000000..51786e5df --- /dev/null +++ b/src/Report/Cobertura.php @@ -0,0 +1,306 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report; + +use const DIRECTORY_SEPARATOR; +use function basename; +use function count; +use function dirname; +use function file_put_contents; +use function preg_match; +use function range; +use function str_contains; +use function str_replace; +use function time; +use DOMImplementation; +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; + +final class Cobertura +{ + /** + * @throws WriteOperationFailedException + */ + public function process(CodeCoverage $coverage, ?string $target = null): string + { + $time = (string) time(); + + $report = $coverage->getReport(); + + $implementation = new DOMImplementation; + + $documentType = $implementation->createDocumentType( + 'coverage', + '', + 'http://cobertura.sourceforge.net/xml/coverage-04.dtd', + ); + + $document = $implementation->createDocument('', '', $documentType); + $document->xmlVersion = '1.0'; + $document->encoding = 'UTF-8'; + $document->formatOutput = true; + + $coverageElement = $document->createElement('coverage'); + + $linesValid = $report->numberOfExecutableLines(); + $linesCovered = $report->numberOfExecutedLines(); + $lineRate = $linesValid === 0 ? 0 : ($linesCovered / $linesValid); + $coverageElement->setAttribute('line-rate', (string) $lineRate); + + $branchesValid = $report->numberOfExecutableBranches(); + $branchesCovered = $report->numberOfExecutedBranches(); + $branchRate = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid); + $coverageElement->setAttribute('branch-rate', (string) $branchRate); + + $coverageElement->setAttribute('lines-covered', (string) $report->numberOfExecutedLines()); + $coverageElement->setAttribute('lines-valid', (string) $report->numberOfExecutableLines()); + $coverageElement->setAttribute('branches-covered', (string) $report->numberOfExecutedBranches()); + $coverageElement->setAttribute('branches-valid', (string) $report->numberOfExecutableBranches()); + $coverageElement->setAttribute('complexity', ''); + $coverageElement->setAttribute('version', '0.4'); + $coverageElement->setAttribute('timestamp', $time); + + $document->appendChild($coverageElement); + + $sourcesElement = $document->createElement('sources'); + $coverageElement->appendChild($sourcesElement); + + $sourceElement = $document->createElement('source', $report->pathAsString()); + $sourcesElement->appendChild($sourceElement); + + $packagesElement = $document->createElement('packages'); + $coverageElement->appendChild($packagesElement); + + $complexity = 0; + + foreach ($report as $item) { + if (!$item instanceof File) { + continue; + } + + $packageElement = $document->createElement('package'); + $packageComplexity = 0; + + $packageElement->setAttribute('name', str_replace($report->pathAsString() . DIRECTORY_SEPARATOR, '', $item->pathAsString())); + + $linesValid = $item->numberOfExecutableLines(); + $linesCovered = $item->numberOfExecutedLines(); + $lineRate = $linesValid === 0 ? 0 : ($linesCovered / $linesValid); + + $packageElement->setAttribute('line-rate', (string) $lineRate); + + $branchesValid = $item->numberOfExecutableBranches(); + $branchesCovered = $item->numberOfExecutedBranches(); + $branchRate = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid); + + $packageElement->setAttribute('branch-rate', (string) $branchRate); + + $packageElement->setAttribute('complexity', ''); + $packagesElement->appendChild($packageElement); + + $classesElement = $document->createElement('classes'); + + $packageElement->appendChild($classesElement); + + $classes = $item->classesAndTraits(); + $coverageData = $item->lineCoverageData(); + + foreach ($classes as $className => $class) { + $complexity += $class['ccn']; + $packageComplexity += $class['ccn']; + + $linesValid = $class['executableLines']; + $linesCovered = $class['executedLines']; + $lineRate = $linesValid === 0 ? 0 : ($linesCovered / $linesValid); + + $branchesValid = $class['executableBranches']; + $branchesCovered = $class['executedBranches']; + $branchRate = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid); + + $classElement = $document->createElement('class'); + + $classElement->setAttribute('name', $className); + $classElement->setAttribute('filename', str_replace($report->pathAsString() . DIRECTORY_SEPARATOR, '', $item->pathAsString())); + $classElement->setAttribute('line-rate', (string) $lineRate); + $classElement->setAttribute('branch-rate', (string) $branchRate); + $classElement->setAttribute('complexity', (string) $class['ccn']); + + $classesElement->appendChild($classElement); + + $methodsElement = $document->createElement('methods'); + + $classElement->appendChild($methodsElement); + + $classLinesElement = $document->createElement('lines'); + + $classElement->appendChild($classLinesElement); + + foreach ($class['methods'] as $methodName => $method) { + if ($method['executableLines'] === 0) { + continue; + } + + preg_match("/\((.*?)\)/", $method['signature'], $signature); + + $linesValid = $method['executableLines']; + $linesCovered = $method['executedLines']; + $lineRate = $linesCovered / $linesValid; + + $branchesValid = $method['executableBranches']; + $branchesCovered = $method['executedBranches']; + $branchRate = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid); + + $methodElement = $document->createElement('method'); + + $methodElement->setAttribute('name', $methodName); + $methodElement->setAttribute('signature', $signature[1]); + $methodElement->setAttribute('line-rate', (string) $lineRate); + $methodElement->setAttribute('branch-rate', (string) $branchRate); + $methodElement->setAttribute('complexity', (string) $method['ccn']); + + $methodLinesElement = $document->createElement('lines'); + + $methodElement->appendChild($methodLinesElement); + + foreach (range($method['startLine'], $method['endLine']) as $line) { + if (!isset($coverageData[$line])) { + continue; + } + $methodLineElement = $document->createElement('line'); + + $methodLineElement->setAttribute('number', (string) $line); + $methodLineElement->setAttribute('hits', (string) count($coverageData[$line])); + + $methodLinesElement->appendChild($methodLineElement); + + $classLineElement = $methodLineElement->cloneNode(); + + $classLinesElement->appendChild($classLineElement); + } + + $methodsElement->appendChild($methodElement); + } + } + + if ($item->numberOfFunctions() === 0) { + $packageElement->setAttribute('complexity', (string) $packageComplexity); + + continue; + } + + $functionsComplexity = 0; + $functionsLinesValid = 0; + $functionsLinesCovered = 0; + $functionsBranchesValid = 0; + $functionsBranchesCovered = 0; + + $classElement = $document->createElement('class'); + $classElement->setAttribute('name', basename($item->pathAsString())); + $classElement->setAttribute('filename', str_replace($report->pathAsString() . DIRECTORY_SEPARATOR, '', $item->pathAsString())); + + $methodsElement = $document->createElement('methods'); + + $classElement->appendChild($methodsElement); + + $classLinesElement = $document->createElement('lines'); + + $classElement->appendChild($classLinesElement); + + $functions = $item->functions(); + + foreach ($functions as $functionName => $function) { + if ($function['executableLines'] === 0) { + continue; + } + + $complexity += $function['ccn']; + $packageComplexity += $function['ccn']; + $functionsComplexity += $function['ccn']; + + $linesValid = $function['executableLines']; + $linesCovered = $function['executedLines']; + $lineRate = $linesCovered / $linesValid; + + $functionsLinesValid += $linesValid; + $functionsLinesCovered += $linesCovered; + + $branchesValid = $function['executableBranches']; + $branchesCovered = $function['executedBranches']; + $branchRate = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid); + + $functionsBranchesValid += $branchesValid; + $functionsBranchesCovered += $branchesValid; + + $methodElement = $document->createElement('method'); + + $methodElement->setAttribute('name', $functionName); + $methodElement->setAttribute('signature', $function['signature']); + $methodElement->setAttribute('line-rate', (string) $lineRate); + $methodElement->setAttribute('branch-rate', (string) $branchRate); + $methodElement->setAttribute('complexity', (string) $function['ccn']); + + $methodLinesElement = $document->createElement('lines'); + + $methodElement->appendChild($methodLinesElement); + + foreach (range($function['startLine'], $function['endLine']) as $line) { + if (!isset($coverageData[$line])) { + continue; + } + $methodLineElement = $document->createElement('line'); + + $methodLineElement->setAttribute('number', (string) $line); + $methodLineElement->setAttribute('hits', (string) count($coverageData[$line])); + + $methodLinesElement->appendChild($methodLineElement); + + $classLineElement = $methodLineElement->cloneNode(); + + $classLinesElement->appendChild($classLineElement); + } + + $methodsElement->appendChild($methodElement); + } + + $packageElement->setAttribute('complexity', (string) $packageComplexity); + + if ($functionsLinesValid === 0) { + continue; + } + + $lineRate = $functionsLinesCovered / $functionsLinesValid; + $branchRate = $functionsBranchesValid === 0 ? 0 : ($functionsBranchesCovered / $functionsBranchesValid); + + $classElement->setAttribute('line-rate', (string) $lineRate); + $classElement->setAttribute('branch-rate', (string) $branchRate); + $classElement->setAttribute('complexity', (string) $functionsComplexity); + + $classesElement->appendChild($classElement); + } + + $coverageElement->setAttribute('complexity', (string) $complexity); + + $buffer = $document->saveXML(); + + if ($target !== null) { + if (!str_contains($target, '://')) { + Filesystem::createDirectory(dirname($target)); + } + + if (@file_put_contents($target, $buffer) === false) { + throw new WriteOperationFailedException($target); + } + } + + return $buffer; + } +} diff --git a/src/Report/Crap4j.php b/src/Report/Crap4j.php index 791b0e6dc..57fabfce5 100644 --- a/src/Report/Crap4j.php +++ b/src/Report/Crap4j.php @@ -9,20 +9,22 @@ */ namespace SebastianBergmann\CodeCoverage\Report; +use function date; +use function dirname; +use function file_put_contents; +use function htmlspecialchars; +use function is_string; +use function round; +use function str_contains; +use DOMDocument; use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Directory; use SebastianBergmann\CodeCoverage\Node\File; -use SebastianBergmann\CodeCoverage\RuntimeException; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; -/** - * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage - */ -final class Crap4j +final readonly class Crap4j { - /** - * @var int - */ - private $threshold; + private int $threshold; public function __construct(int $threshold = 30) { @@ -30,19 +32,19 @@ public function __construct(int $threshold = 30) } /** - * @throws \RuntimeException + * @throws WriteOperationFailedException */ public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { - $document = new \DOMDocument('1.0', 'UTF-8'); + $document = new DOMDocument('1.0', 'UTF-8'); $document->formatOutput = true; $root = $document->createElement('crap_result'); $document->appendChild($root); - $project = $document->createElement('project', \is_string($name) ? $name : ''); + $project = $document->createElement('project', is_string($name) ? $name : ''); $root->appendChild($project); - $root->appendChild($document->createElement('timestamp', \date('Y-m-d H:i:s'))); + $root->appendChild($document->createElement('timestamp', date('Y-m-d H:i:s'))); $stats = $document->createElement('stats'); $methodsNode = $document->createElement('methods'); @@ -71,7 +73,7 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string foreach ($class['methods'] as $methodName => $method) { $crapLoad = $this->crapLoad((float) $method['crap'], $method['ccn'], $method['coverage']); - $fullCrap += $method['crap']; + $fullCrap += $method['crap']; $fullCrapLoad += $crapLoad; $fullMethodCount++; @@ -81,19 +83,19 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $methodNode = $document->createElement('method'); - if (!empty($class['package']['namespace'])) { - $namespace = $class['package']['namespace']; + if ($class['namespace'] !== '') { + $namespace = $class['namespace']; } $methodNode->appendChild($document->createElement('package', $namespace)); $methodNode->appendChild($document->createElement('className', $className)); $methodNode->appendChild($document->createElement('methodName', $methodName)); - $methodNode->appendChild($document->createElement('methodSignature', \htmlspecialchars($method['signature']))); - $methodNode->appendChild($document->createElement('fullMethod', \htmlspecialchars($method['signature']))); + $methodNode->appendChild($document->createElement('methodSignature', htmlspecialchars($method['signature']))); + $methodNode->appendChild($document->createElement('fullMethod', htmlspecialchars($method['signature']))); $methodNode->appendChild($document->createElement('crap', (string) $this->roundValue((float) $method['crap']))); $methodNode->appendChild($document->createElement('complexity', (string) $method['ccn'])); $methodNode->appendChild($document->createElement('coverage', (string) $this->roundValue($method['coverage']))); - $methodNode->appendChild($document->createElement('crapLoad', (string) \round($crapLoad))); + $methodNode->appendChild($document->createElement('crapLoad', (string) round($crapLoad))); $methodsNode->appendChild($methodNode); } @@ -103,7 +105,7 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $stats->appendChild($document->createElement('name', 'Method Crap Stats')); $stats->appendChild($document->createElement('methodCount', (string) $fullMethodCount)); $stats->appendChild($document->createElement('crapMethodCount', (string) $fullCrapMethodCount)); - $stats->appendChild($document->createElement('crapLoad', (string) \round($fullCrapLoad))); + $stats->appendChild($document->createElement('crapLoad', (string) round($fullCrapLoad))); $stats->appendChild($document->createElement('totalCrap', (string) $fullCrap)); $crapMethodPercent = 0; @@ -120,15 +122,12 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $buffer = $document->saveXML(); if ($target !== null) { - Directory::create(\dirname($target)); - - if (@\file_put_contents($target, $buffer) === false) { - throw new RuntimeException( - \sprintf( - 'Could not write to "%s', - $target - ) - ); + if (!str_contains($target, '://')) { + Filesystem::createDirectory(dirname($target)); + } + + if (@file_put_contents($target, $buffer) === false) { + throw new WriteOperationFailedException($target); } } @@ -149,6 +148,6 @@ private function crapLoad(float $crapValue, int $cyclomaticComplexity, float $co private function roundValue(float $value): float { - return \round($value, 2); + return round($value, 2); } } diff --git a/src/Report/Html/Colors.php b/src/Report/Html/Colors.php new file mode 100644 index 000000000..c79bf9ee5 --- /dev/null +++ b/src/Report/Html/Colors.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html; + +/** + * @immutable + */ +final readonly class Colors +{ + private string $successLow; + private string $successMedium; + private string $successHigh; + private string $warning; + private string $danger; + + public static function default(): self + { + return new self('#dff0d8', '#c3e3b5', '#99cb84', '#fcf8e3', '#f2dede'); + } + + public static function from(string $successLow, string $successMedium, string $successHigh, string $warning, string $danger): self + { + return new self($successLow, $successMedium, $successHigh, $warning, $danger); + } + + private function __construct(string $successLow, string $successMedium, string $successHigh, string $warning, string $danger) + { + $this->successLow = $successLow; + $this->successMedium = $successMedium; + $this->successHigh = $successHigh; + $this->warning = $warning; + $this->danger = $danger; + } + + public function successLow(): string + { + return $this->successLow; + } + + public function successMedium(): string + { + return $this->successMedium; + } + + public function successHigh(): string + { + return $this->successHigh; + } + + public function warning(): string + { + return $this->warning; + } + + public function danger(): string + { + return $this->danger; + } +} diff --git a/src/Report/Html/CustomCssFile.php b/src/Report/Html/CustomCssFile.php new file mode 100644 index 000000000..5c272a0bc --- /dev/null +++ b/src/Report/Html/CustomCssFile.php @@ -0,0 +1,50 @@ + + * + * 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 function is_file; +use SebastianBergmann\CodeCoverage\InvalidArgumentException; + +/** + * @immutable + */ +final readonly class CustomCssFile +{ + private string $path; + + public static function default(): self + { + return new self(__DIR__ . '/Renderer/Template/css/custom.css'); + } + + /** + * @throws InvalidArgumentException + */ + public static function from(string $path): self + { + if (!is_file($path)) { + throw new InvalidArgumentException( + '$path does not exist', + ); + } + + return new self($path); + } + + private function __construct(string $path) + { + $this->path = $path; + } + + public function path(): string + { + return $this->path; + } +} diff --git a/src/Report/Html/Facade.php b/src/Report/Html/Facade.php index b7fbff517..0e8b230aa 100644 --- a/src/Report/Html/Facade.php +++ b/src/Report/Html/Facade.php @@ -9,79 +9,64 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; +use const DIRECTORY_SEPARATOR; +use function copy; +use function date; +use function dirname; +use function str_ends_with; use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Directory as DirectoryUtil; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; -use SebastianBergmann\CodeCoverage\RuntimeException; +use SebastianBergmann\CodeCoverage\Report\Thresholds; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\Template\Exception; +use SebastianBergmann\Template\Template; -/** - * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage - */ -final class Facade +final readonly class Facade { - /** - * @var string - */ - private $templatePath; - - /** - * @var string - */ - private $generator; - - /** - * @var int - */ - private $lowUpperBound; - - /** - * @var int - */ - private $highLowerBound; - - public function __construct(int $lowUpperBound = 50, int $highLowerBound = 90, string $generator = '') + private string $templatePath; + private string $generator; + private Colors $colors; + private Thresholds $thresholds; + private CustomCssFile $customCssFile; + + public function __construct(string $generator = '', ?Colors $colors = null, ?Thresholds $thresholds = null, ?CustomCssFile $customCssFile = null) { - $this->generator = $generator; - $this->highLowerBound = $highLowerBound; - $this->lowUpperBound = $lowUpperBound; - $this->templatePath = __DIR__ . '/Renderer/Template/'; + $this->generator = $generator; + $this->colors = $colors ?? Colors::default(); + $this->thresholds = $thresholds ?? Thresholds::default(); + $this->customCssFile = $customCssFile ?? CustomCssFile::default(); + $this->templatePath = __DIR__ . '/Renderer/Template/'; } - /** - * @throws RuntimeException - * @throws \RuntimeException - */ public function process(CodeCoverage $coverage, string $target): void { $target = $this->directory($target); $report = $coverage->getReport(); - $date = (string) \date('D M j G:i:s T Y'); + $date = date('D M j G:i:s T Y'); $dashboard = new Dashboard( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound, - $coverage->collectsBranchAndPathCoverage() + $this->thresholds, + $coverage->collectsBranchAndPathCoverage(), ); $directory = new Directory( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound, - $coverage->collectsBranchAndPathCoverage() + $this->thresholds, + $coverage->collectsBranchAndPathCoverage(), ); $file = new File( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound, - $coverage->collectsBranchAndPathCoverage() + $this->thresholds, + $coverage->collectsBranchAndPathCoverage(), ); $directory->render($report, $target . 'index.html'); @@ -91,58 +76,77 @@ public function process(CodeCoverage $coverage, string $target): void $id = $node->id(); if ($node instanceof DirectoryNode) { - DirectoryUtil::create($target . $id); + Filesystem::createDirectory($target . $id); $directory->render($node, $target . $id . '/index.html'); $dashboard->render($node, $target . $id . '/dashboard.html'); } else { - $dir = \dirname($target . $id); + $dir = dirname($target . $id); - DirectoryUtil::create($dir); + Filesystem::createDirectory($dir); - $file->render($node, $target . $id . '.html'); + $file->render($node, $target . $id); } } $this->copyFiles($target); + $this->renderCss($target); } - /** - * @throws RuntimeException - */ private function copyFiles(string $target): void { $dir = $this->directory($target . '_css'); - \copy($this->templatePath . 'css/bootstrap.min.css', $dir . 'bootstrap.min.css'); - \copy($this->templatePath . 'css/nv.d3.min.css', $dir . 'nv.d3.min.css'); - \copy($this->templatePath . 'css/style.css', $dir . 'style.css'); - \copy($this->templatePath . 'css/custom.css', $dir . 'custom.css'); - \copy($this->templatePath . 'css/octicons.css', $dir . 'octicons.css'); + copy($this->templatePath . 'css/billboard.min.css', $dir . 'billboard.min.css'); + copy($this->templatePath . 'css/bootstrap.min.css', $dir . 'bootstrap.min.css'); + copy($this->customCssFile->path(), $dir . 'custom.css'); + copy($this->templatePath . 'css/octicons.css', $dir . 'octicons.css'); $dir = $this->directory($target . '_icons'); - \copy($this->templatePath . 'icons/file-code.svg', $dir . 'file-code.svg'); - \copy($this->templatePath . 'icons/file-directory.svg', $dir . 'file-directory.svg'); + copy($this->templatePath . 'icons/file-code.svg', $dir . 'file-code.svg'); + copy($this->templatePath . 'icons/file-directory.svg', $dir . 'file-directory.svg'); $dir = $this->directory($target . '_js'); - \copy($this->templatePath . 'js/bootstrap.min.js', $dir . 'bootstrap.min.js'); - \copy($this->templatePath . 'js/popper.min.js', $dir . 'popper.min.js'); - \copy($this->templatePath . 'js/d3.min.js', $dir . 'd3.min.js'); - \copy($this->templatePath . 'js/jquery.min.js', $dir . 'jquery.min.js'); - \copy($this->templatePath . 'js/nv.d3.min.js', $dir . 'nv.d3.min.js'); - \copy($this->templatePath . 'js/file.js', $dir . 'file.js'); + copy($this->templatePath . 'js/billboard.pkgd.min.js', $dir . 'billboard.pkgd.min.js'); + copy($this->templatePath . 'js/bootstrap.bundle.min.js', $dir . 'bootstrap.bundle.min.js'); + copy($this->templatePath . 'js/jquery.min.js', $dir . 'jquery.min.js'); + copy($this->templatePath . 'js/file.js', $dir . 'file.js'); + } + + private function renderCss(string $target): void + { + $template = new Template($this->templatePath . 'css/style.css', '{{', '}}'); + + $template->setVar( + [ + 'success-low' => $this->colors->successLow(), + 'success-medium' => $this->colors->successMedium(), + 'success-high' => $this->colors->successHigh(), + 'warning' => $this->colors->warning(), + 'danger' => $this->colors->danger(), + ], + ); + + try { + $template->renderTo($this->directory($target . '_css') . 'style.css'); + // @codeCoverageIgnoreStart + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + // @codeCoverageIgnoreEnd + } } - /** - * @throws RuntimeException - */ private function directory(string $directory): string { - if (\substr($directory, -1, 1) != \DIRECTORY_SEPARATOR) { - $directory .= \DIRECTORY_SEPARATOR; + if (!str_ends_with($directory, DIRECTORY_SEPARATOR)) { + $directory .= DIRECTORY_SEPARATOR; } - DirectoryUtil::create($directory); + Filesystem::createDirectory($directory); return $directory; } diff --git a/src/Report/Html/Renderer.php b/src/Report/Html/Renderer.php index 690df21b6..49a03e5cf 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -9,9 +9,15 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; +use function array_pop; +use function count; +use function sprintf; +use function str_repeat; +use function substr_count; use SebastianBergmann\CodeCoverage\Node\AbstractNode; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; use SebastianBergmann\CodeCoverage\Node\File as FileNode; +use SebastianBergmann\CodeCoverage\Report\Thresholds; use SebastianBergmann\CodeCoverage\Version; use SebastianBergmann\Environment\Runtime; use SebastianBergmann\Template\Template; @@ -21,55 +27,29 @@ */ abstract class Renderer { - /** - * @var string - */ - protected $templatePath; - - /** - * @var string - */ - protected $generator; - - /** - * @var string - */ - protected $date; - - /** - * @var int - */ - protected $lowUpperBound; - - /** - * @var int - */ - protected $highLowerBound; - - /** - * @var bool - */ - protected $hasBranchCoverage; - - /** - * @var string - */ - protected $version; - - public function __construct(string $templatePath, string $generator, string $date, int $lowUpperBound, int $highLowerBound, bool $hasBranchCoverage) + protected string $templatePath; + protected string $generator; + protected string $date; + protected Thresholds $thresholds; + protected bool $hasBranchCoverage; + protected string $version; + + public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage) { $this->templatePath = $templatePath; $this->generator = $generator; $this->date = $date; - $this->lowUpperBound = $lowUpperBound; - $this->highLowerBound = $highLowerBound; + $this->thresholds = $thresholds; $this->version = Version::id(); $this->hasBranchCoverage = $hasBranchCoverage; } + /** + * @param array $data + */ protected function renderItemTemplate(Template $template, array $data): string { - $numSeparator = ' / '; + $numSeparator = ' / '; if (isset($data['numClasses']) && $data['numClasses'] > 0) { $classesLevel = $this->colorLevel($data['testedClassesPercent']); @@ -78,7 +58,7 @@ protected function renderItemTemplate(Template $template, array $data): string $data['numClasses']; $classesBar = $this->coverageBar( - $data['testedClassesPercent'] + $data['testedClassesPercent'], ); } else { $classesLevel = ''; @@ -94,7 +74,7 @@ protected function renderItemTemplate(Template $template, array $data): string $data['numMethods']; $methodsBar = $this->coverageBar( - $data['testedMethodsPercent'] + $data['testedMethodsPercent'], ); } else { $methodsLevel = ''; @@ -110,7 +90,7 @@ protected function renderItemTemplate(Template $template, array $data): string $data['numExecutableLines']; $linesBar = $this->coverageBar( - $data['linesExecutedPercent'] + $data['linesExecutedPercent'], ); } else { $linesLevel = ''; @@ -126,7 +106,7 @@ protected function renderItemTemplate(Template $template, array $data): string $data['numExecutablePaths']; $pathsBar = $this->coverageBar( - $data['pathsExecutedPercent'] + $data['pathsExecutedPercent'], ); } else { $pathsLevel = ''; @@ -142,7 +122,7 @@ protected function renderItemTemplate(Template $template, array $data): string $data['numExecutableBranches']; $branchesBar = $this->coverageBar( - $data['branchesExecutedPercent'] + $data['branchesExecutedPercent'], ); } else { $branchesLevel = ''; @@ -176,7 +156,7 @@ protected function renderItemTemplate(Template $template, array $data): string 'classes_tested_percent' => $data['testedClassesPercentAsString'] ?? '', 'classes_level' => $classesLevel, 'classes_number' => $classesNumber, - ] + ], ); return $template->render(); @@ -194,9 +174,9 @@ protected function setCommonTemplateVariables(Template $template, AbstractNode $ 'version' => $this->version, 'runtime' => $this->runtimeString(), 'generator' => $this->generator, - 'low_upper_bound' => $this->lowUpperBound, - 'high_lower_bound' => $this->highLowerBound, - ] + 'low_upper_bound' => (string) $this->thresholds->lowUpperBound(), + 'high_lower_bound' => (string) $this->thresholds->highLowerBound(), + ], ); } @@ -205,21 +185,21 @@ protected function breadcrumbs(AbstractNode $node): string $breadcrumbs = ''; $path = $node->pathAsArray(); $pathToRoot = []; - $max = \count($path); + $max = count($path); if ($node instanceof FileNode) { $max--; } for ($i = 0; $i < $max; $i++) { - $pathToRoot[] = \str_repeat('../', $i); + $pathToRoot[] = str_repeat('../', $i); } foreach ($path as $step) { if ($step !== $node) { $breadcrumbs .= $this->inactiveBreadcrumb( $step, - \array_pop($pathToRoot) + array_pop($pathToRoot), ); } else { $breadcrumbs .= $this->activeBreadcrumb($step); @@ -231,9 +211,9 @@ protected function breadcrumbs(AbstractNode $node): string protected function activeBreadcrumb(AbstractNode $node): string { - $buffer = \sprintf( + $buffer = sprintf( ' ' . "\n", - $node->name() + $node->name(), ); if ($node instanceof DirectoryNode) { @@ -245,24 +225,24 @@ protected function activeBreadcrumb(AbstractNode $node): string protected function inactiveBreadcrumb(AbstractNode $node, string $pathToRoot): string { - return \sprintf( + return sprintf( ' ' . "\n", $pathToRoot, - $node->name() + $node->name(), ); } protected function pathToRoot(AbstractNode $node): string { $id = $node->id(); - $depth = \substr_count($id, '/'); + $depth = substr_count($id, '/'); if ($id !== 'index' && $node instanceof DirectoryNode) { $depth++; } - return \str_repeat('../', $depth); + return str_repeat('../', $depth); } protected function coverageBar(float $percent): string @@ -273,22 +253,22 @@ protected function coverageBar(float $percent): string $template = new Template( $templateName, '{{', - '}}' + '}}', ); - $template->setVar(['level' => $level, 'percent' => \sprintf('%.2F', $percent)]); + $template->setVar(['level' => $level, 'percent' => sprintf('%.2F', $percent)]); return $template->render(); } protected function colorLevel(float $percent): string { - if ($percent <= $this->lowUpperBound) { + if ($percent <= $this->thresholds->lowUpperBound()) { return 'danger'; } - if ($percent > $this->lowUpperBound && - $percent < $this->highLowerBound) { + if ($percent > $this->thresholds->lowUpperBound() && + $percent < $this->thresholds->highLowerBound()) { return 'warning'; } @@ -299,29 +279,11 @@ private function runtimeString(): string { $runtime = new Runtime; - $buffer = \sprintf( + return sprintf( '%s %s', $runtime->getVendorUrl(), $runtime->getName(), - $runtime->getVersion() + $runtime->getVersion(), ); - - if ($runtime->hasPHPDBGCodeCoverage()) { - return $buffer; - } - - if ($runtime->hasPCOV()) { - $buffer .= \sprintf( - ' with PCOV %s', - \phpversion('pcov') - ); - } elseif ($runtime->hasXdebug()) { - $buffer .= \sprintf( - ' with Xdebug %s', - \phpversion('xdebug') - ); - } - - return $buffer; } } diff --git a/src/Report/Html/Renderer/Dashboard.php b/src/Report/Html/Renderer/Dashboard.php index a962927f5..305c7fa10 100644 --- a/src/Report/Html/Renderer/Dashboard.php +++ b/src/Report/Html/Renderer/Dashboard.php @@ -9,18 +9,32 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; +use function array_values; +use function asort; +use function assert; +use function count; +use function explode; +use function floor; +use function json_encode; +use function sprintf; +use function str_replace; +use function uasort; +use function usort; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\AbstractNode; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; +use SebastianBergmann\CodeCoverage\Node\File as FileNode; +use SebastianBergmann\Template\Exception; use SebastianBergmann\Template\Template; /** + * @phpstan-import-type ProcessedClassType from FileNode + * @phpstan-import-type ProcessedTraitType from FileNode + * * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ final class Dashboard extends Renderer { - /** - * @throws \RuntimeException - */ public function render(DirectoryNode $node, string $file): void { $classes = $node->classesAndTraits(); @@ -28,7 +42,7 @@ public function render(DirectoryNode $node, string $file): void $template = new Template( $templateName, '{{', - '}}' + '}}', ); $this->setCommonTemplateVariables($template, $node); @@ -49,23 +63,33 @@ public function render(DirectoryNode $node, string $file): void 'complexity_method' => $complexity['method'], 'class_coverage_distribution' => $coverageDistribution['class'], 'method_coverage_distribution' => $coverageDistribution['method'], - ] + ], ); - $template->renderTo($file); + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } } protected function activeBreadcrumb(AbstractNode $node): string { - return \sprintf( + return sprintf( ' ' . "\n" . ' ' . "\n", - $node->name() + $node->name(), ); } /** - * Returns the data for the Class/Method Complexity charts. + * @param array $classes + * + * @return array{class: non-empty-string, method: non-empty-string} */ private function complexity(array $classes, string $baseLink): array { @@ -80,33 +104,39 @@ private function complexity(array $classes, string $baseLink): array $result['method'][] = [ $method['coverage'], $method['ccn'], - \sprintf( - '%s', - \str_replace($baseLink, '', $method['link']), - $methodName - ), + str_replace($baseLink, '', $method['link']), + $methodName, + $method['crap'], ]; } $result['class'][] = [ $class['coverage'], $class['ccn'], - \sprintf( - '%s', - \str_replace($baseLink, '', $class['link']), - $className - ), + str_replace($baseLink, '', $class['link']), + $className, + $class['crap'], ]; } - return [ - 'class' => \json_encode($result['class']), - 'method' => \json_encode($result['method']), - ]; + usort($result['class'], static fn (mixed $a, mixed $b) => ($a[0] <=> $b[0])); + usort($result['method'], static fn (mixed $a, mixed $b) => ($a[0] <=> $b[0])); + + $class = json_encode($result['class']); + + assert($class !== false); + + $method = json_encode($result['method']); + + assert($method !== false); + + return ['class' => $class, 'method' => $method]; } /** - * Returns the data for the Class / Method Coverage Distribution chart. + * @param array $classes + * + * @return array{class: non-empty-string, method: non-empty-string} */ private function coverageDistribution(array $classes): array { @@ -148,7 +178,7 @@ private function coverageDistribution(array $classes): array } elseif ($method['coverage'] === 100) { $result['method']['100%']++; } else { - $key = \floor($method['coverage'] / 10) * 10; + $key = floor($method['coverage'] / 10) * 10; $key = $key . '-' . ($key + 10) . '%'; $result['method'][$key]++; } @@ -159,20 +189,27 @@ private function coverageDistribution(array $classes): array } elseif ($class['coverage'] === 100) { $result['class']['100%']++; } else { - $key = \floor($class['coverage'] / 10) * 10; + $key = floor($class['coverage'] / 10) * 10; $key = $key . '-' . ($key + 10) . '%'; $result['class'][$key]++; } } - return [ - 'class' => \json_encode(\array_values($result['class'])), - 'method' => \json_encode(\array_values($result['method'])), - ]; + $class = json_encode(array_values($result['class'])); + + assert($class !== false); + + $method = json_encode(array_values($result['method'])); + + assert($method !== false); + + return ['class' => $class, 'method' => $method]; } /** - * Returns the classes / methods with insufficient coverage. + * @param array $classes + * + * @return array{class: string, method: string} */ private function insufficientCoverage(array $classes, string $baseLink): array { @@ -182,7 +219,7 @@ private function insufficientCoverage(array $classes, string $baseLink): array foreach ($classes as $className => $class) { foreach ($class['methods'] as $methodName => $method) { - if ($method['coverage'] < $this->highLowerBound) { + if ($method['coverage'] < $this->thresholds->highLowerBound()) { $key = $methodName; if ($className !== '*') { @@ -193,32 +230,32 @@ private function insufficientCoverage(array $classes, string $baseLink): array } } - if ($class['coverage'] < $this->highLowerBound) { + if ($class['coverage'] < $this->thresholds->highLowerBound()) { $leastTestedClasses[$className] = $class['coverage']; } } - \asort($leastTestedClasses); - \asort($leastTestedMethods); + asort($leastTestedClasses); + asort($leastTestedMethods); foreach ($leastTestedClasses as $className => $coverage) { - $result['class'] .= \sprintf( + $result['class'] .= sprintf( ' %s%d%%' . "\n", - \str_replace($baseLink, '', $classes[$className]['link']), + str_replace($baseLink, '', $classes[$className]['link']), $className, - $coverage + $coverage, ); } foreach ($leastTestedMethods as $methodName => $coverage) { - [$class, $method] = \explode('::', $methodName); + [$class, $method] = explode('::', $methodName); - $result['method'] .= \sprintf( + $result['method'] .= sprintf( ' %s%d%%' . "\n", - \str_replace($baseLink, '', $classes[$class]['methods'][$method]['link']), + str_replace($baseLink, '', $classes[$class]['methods'][$method]['link']), $methodName, $method, - $coverage + $coverage, ); } @@ -226,7 +263,9 @@ private function insufficientCoverage(array $classes, string $baseLink): array } /** - * Returns the project risks according to the CRAP index. + * @param array $classes + * + * @return array{class: string, method: string} */ private function projectRisks(array $classes, string $baseLink): array { @@ -236,44 +275,54 @@ private function projectRisks(array $classes, string $baseLink): array foreach ($classes as $className => $class) { foreach ($class['methods'] as $methodName => $method) { - if ($method['coverage'] < $this->highLowerBound && $method['ccn'] > 1) { + if ($method['coverage'] < $this->thresholds->highLowerBound() && $method['ccn'] > 1) { $key = $methodName; if ($className !== '*') { $key = $className . '::' . $methodName; } - $methodRisks[$key] = $method['crap']; + $methodRisks[$key] = $method; } } - if ($class['coverage'] < $this->highLowerBound && - $class['ccn'] > \count($class['methods'])) { - $classRisks[$className] = $class['crap']; + if ($class['coverage'] < $this->thresholds->highLowerBound() && + $class['ccn'] > count($class['methods'])) { + $classRisks[$className] = $class; } } - \arsort($classRisks); - \arsort($methodRisks); - - foreach ($classRisks as $className => $crap) { - $result['class'] .= \sprintf( - ' %s%d' . "\n", - \str_replace($baseLink, '', $classes[$className]['link']), + uasort($classRisks, static function (array $a, array $b) + { + return ((int) ($a['crap']) <=> (int) ($b['crap'])) * -1; + }); + uasort($methodRisks, static function (array $a, array $b) + { + return ((int) ($a['crap']) <=> (int) ($b['crap'])) * -1; + }); + + foreach ($classRisks as $className => $class) { + $result['class'] .= sprintf( + ' %s%.1f%%%d%d' . "\n", + str_replace($baseLink, '', $classes[$className]['link']), $className, - $crap + $class['coverage'], + $class['ccn'], + $class['crap'], ); } - foreach ($methodRisks as $methodName => $crap) { - [$class, $method] = \explode('::', $methodName); + foreach ($methodRisks as $methodName => $methodVals) { + [$class, $method] = explode('::', $methodName); - $result['method'] .= \sprintf( - ' %s%d' . "\n", - \str_replace($baseLink, '', $classes[$class]['methods'][$method]['link']), + $result['method'] .= sprintf( + ' %s%.1f%%%d%d' . "\n", + str_replace($baseLink, '', $classes[$class]['methods'][$method]['link']), $methodName, $method, - $crap + $methodVals['coverage'], + $methodVals['ccn'], + $methodVals['crap'], ); } diff --git a/src/Report/Html/Renderer/Directory.php b/src/Report/Html/Renderer/Directory.php index 34fdcb345..1d7334b3a 100644 --- a/src/Report/Html/Renderer/Directory.php +++ b/src/Report/Html/Renderer/Directory.php @@ -9,8 +9,13 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; +use function count; +use function sprintf; +use function str_repeat; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\AbstractNode as Node; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; +use SebastianBergmann\Template\Exception; use SebastianBergmann\Template\Template; /** @@ -18,9 +23,6 @@ */ final class Directory extends Renderer { - /** - * @throws \RuntimeException - */ public function render(DirectoryNode $node, string $file): void { $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'directory_branch.html' : 'directory.html'); @@ -42,10 +44,18 @@ public function render(DirectoryNode $node, string $file): void [ 'id' => $node->id(), 'items' => $items, - ] + ], ); - $template->renderTo($file); + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } } private function renderItem(Node $node, bool $total = false): string @@ -76,26 +86,30 @@ private function renderItem(Node $node, bool $total = false): string if ($total) { $data['name'] = 'Total'; } else { + $up = str_repeat('../', count($node->pathAsArray()) - 2); + $data['icon'] = sprintf('', $up); + if ($node instanceof DirectoryNode) { - $data['name'] = \sprintf( + $data['name'] = sprintf( '%s', $node->name(), - $node->name() + $node->name(), + ); + $data['icon'] = sprintf('', $up); + } elseif ($this->hasBranchCoverage) { + $data['name'] = sprintf( + '%s [line] [branch] [path]', + $node->name(), + $node->name(), + $node->name(), + $node->name(), ); - - $up = \str_repeat('../', \count($node->pathAsArray()) - 2); - - $data['icon'] = \sprintf('', $up); } else { - $data['name'] = \sprintf( + $data['name'] = sprintf( '%s', $node->name(), - $node->name() + $node->name(), ); - - $up = \str_repeat('../', \count($node->pathAsArray()) - 2); - - $data['icon'] = \sprintf('', $up); } } @@ -103,7 +117,7 @@ private function renderItem(Node $node, bool $total = false): string return $this->renderItemTemplate( new Template($templateName, '{{', '}}'), - $data + $data, ); } } diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 6f1c6767b..09dbe31fe 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -9,38 +9,260 @@ */ 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_ENUM; +use const T_EVAL; +use const T_EXIT; +use const T_EXTENDS; +use const T_FINAL; +use const T_FINALLY; +use const T_FN; +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_MATCH; +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_READONLY; +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 const TOKEN_PARSE; +use function array_key_exists; +use function array_keys; +use function array_merge; +use function array_pop; +use function array_unique; +use function count; +use function explode; +use function file_get_contents; +use function htmlspecialchars; +use function is_string; +use function ksort; +use function range; +use function sort; +use function sprintf; +use function str_ends_with; +use function str_replace; +use function token_get_all; +use function trim; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\File as FileNode; -use SebastianBergmann\CodeCoverage\Percentage; +use SebastianBergmann\CodeCoverage\Util\Percentage; +use SebastianBergmann\Template\Exception; use SebastianBergmann\Template\Template; /** + * @phpstan-import-type ProcessedClassType from FileNode + * @phpstan-import-type ProcessedTraitType from FileNode + * @phpstan-import-type ProcessedMethodType from FileNode + * @phpstan-import-type ProcessedFunctionType from FileNode + * * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ final class File extends Renderer { /** - * @var int + * @var array */ - private $htmlSpecialCharsFlags = \ENT_COMPAT | \ENT_HTML401 | \ENT_SUBSTITUTE; + private const array KEYWORD_TOKENS = [ + 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_ENUM => true, + T_EVAL => true, + T_EXIT => true, + T_EXTENDS => true, + T_FINAL => true, + T_FINALLY => true, + T_FN => 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_MATCH => true, + T_NAMESPACE => true, + T_NEW => true, + T_PRINT => true, + T_PRIVATE => true, + T_PROTECTED => true, + T_PUBLIC => true, + T_READONLY => 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, + ]; + + private const int HTML_SPECIAL_CHARS_FLAGS = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE; /** - * @throws \RuntimeException + * @var array> */ + private static array $formattedSourceCache = []; + 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->renderSource($node), - ] + 'items' => $this->renderItems($node), + 'lines' => $this->renderSourceWithLineCoverage($node), + 'legend' => '

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

', + 'structure' => '', + ], ); - $this->setCommonTemplateVariables($template, $node); + try { + $template->renderTo($file . '.html'); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } + + if ($this->hasBranchCoverage) { + $template->setVar( + [ + 'items' => $this->renderItems($node), + 'lines' => $this->renderSourceWithBranchCoverage($node), + 'legend' => '

Fully coveredPartially coveredNot covered

', + 'structure' => $this->renderBranchStructure($node), + ], + ); + + try { + $template->renderTo($file . '_branch.html'); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } - $template->renderTo($file); + $template->setVar( + [ + 'items' => $this->renderItems($node), + 'lines' => $this->renderSourceWithPathCoverage($node), + 'legend' => '

Fully coveredPartially coveredNot covered

', + 'structure' => $this->renderPathStructure($node), + ], + ); + + try { + $template->renderTo($file . '_path.html'); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } + } } private function renderItems(FileNode $node): string @@ -52,7 +274,7 @@ private function renderItems(FileNode $node): string $methodItemTemplate = new Template( $methodTemplateName, '{{', - '}}' + '}}', ); $items = $this->renderItemTemplate( @@ -80,34 +302,37 @@ private function renderItems(FileNode $node): string 'testedClassesPercent' => $node->percentageOfTestedClassesAndTraits()->asFloat(), 'testedClassesPercentAsString' => $node->percentageOfTestedClassesAndTraits()->asString(), 'crap' => 'CRAP', - ] + ], ); $items .= $this->renderFunctionItems( $node->functions(), - $methodItemTemplate + $methodItemTemplate, ); $items .= $this->renderTraitOrClassItems( $node->traits(), $template, - $methodItemTemplate + $methodItemTemplate, ); $items .= $this->renderTraitOrClassItems( $node->classes(), $template, - $methodItemTemplate + $methodItemTemplate, ); return $items; } + /** + * @param array $items + */ private function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string { $buffer = ''; - if (empty($items)) { + if ($items === []) { return $buffer; } @@ -127,22 +352,22 @@ private function renderTraitOrClassItems(array $items, Template $template, Templ if ($item['executableLines'] > 0) { $numClasses = 1; - $numTestedClasses = $numTestedMethods == $numMethods ? 1 : 0; + $numTestedClasses = $numTestedMethods === $numMethods ? 1 : 0; $linesExecutedPercentAsString = Percentage::fromFractionAndTotal( $item['executedLines'], - $item['executableLines'] + $item['executableLines'], )->asString(); $branchesExecutedPercentAsString = Percentage::fromFractionAndTotal( $item['executedBranches'], - $item['executableBranches'] + $item['executableBranches'], )->asString(); $pathsExecutedPercentAsString = Percentage::fromFractionAndTotal( $item['executedPaths'], - $item['executablePaths'] + $item['executablePaths'], )->asString(); } else { - $numClasses = 'n/a'; - $numTestedClasses = 'n/a'; + $numClasses = 0; + $numTestedClasses = 0; $linesExecutedPercentAsString = 'n/a'; $branchesExecutedPercentAsString = 'n/a'; $pathsExecutedPercentAsString = 'n/a'; @@ -150,30 +375,30 @@ private function renderTraitOrClassItems(array $items, Template $template, Templ $testedMethodsPercentage = Percentage::fromFractionAndTotal( $numTestedMethods, - $numMethods + $numMethods, ); $testedClassesPercentage = Percentage::fromFractionAndTotal( $numTestedMethods === $numMethods ? 1 : 0, - 1 + 1, ); $buffer .= $this->renderItemTemplate( $template, [ - 'name' => $this->abbreviateClassName($name), - 'numClasses' => $numClasses, - 'numTestedClasses' => $numTestedClasses, - 'numMethods' => $numMethods, - 'numTestedMethods' => $numTestedMethods, - 'linesExecutedPercent' => Percentage::fromFractionAndTotal( + '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( + 'linesExecutedPercentAsString' => $linesExecutedPercentAsString, + 'numExecutedLines' => $item['executedLines'], + 'numExecutableLines' => $item['executableLines'], + 'branchesExecutedPercent' => Percentage::fromFractionAndTotal( $item['executedBranches'], $item['executableBranches'], )->asFloat(), @@ -182,7 +407,7 @@ private function renderTraitOrClassItems(array $items, Template $template, Templ 'numExecutableBranches' => $item['executableBranches'], 'pathsExecutedPercent' => Percentage::fromFractionAndTotal( $item['executedPaths'], - $item['executablePaths'] + $item['executablePaths'], )->asFloat(), 'pathsExecutedPercentAsString' => $pathsExecutedPercentAsString, 'numExecutedPaths' => $item['executedPaths'], @@ -192,14 +417,14 @@ private function renderTraitOrClassItems(array $items, Template $template, Templ 'testedClassesPercent' => $testedClassesPercentage->asFloat(), 'testedClassesPercentAsString' => $testedClassesPercentage->asString(), 'crap' => $item['crap'], - ] + ], ); foreach ($item['methods'] as $method) { $buffer .= $this->renderFunctionOrMethodItem( $methodItemTemplate, $method, - ' ' + ' ', ); } } @@ -207,9 +432,12 @@ private function renderTraitOrClassItems(array $items, Template $template, Templ return $buffer; } + /** + * @param array $functions + */ private function renderFunctionItems(array $functions, Template $template): string { - if (empty($functions)) { + if ($functions === []) { return ''; } @@ -218,13 +446,16 @@ private function renderFunctionItems(array $functions, Template $template): stri foreach ($functions as $function) { $buffer .= $this->renderFunctionOrMethodItem( $template, - $function + $function, ); } return $buffer; } + /** + * @param ProcessedFunctionType|ProcessedMethodType $item + */ private function renderFunctionOrMethodItem(Template $template, array $item, string $indent = ''): string { $numMethods = 0; @@ -240,33 +471,33 @@ private function renderFunctionOrMethodItem(Template $template, array $item, str $executedLinesPercentage = Percentage::fromFractionAndTotal( $item['executedLines'], - $item['executableLines'] + $item['executableLines'], ); $executedBranchesPercentage = Percentage::fromFractionAndTotal( $item['executedBranches'], - $item['executableBranches'] + $item['executableBranches'], ); $executedPathsPercentage = Percentage::fromFractionAndTotal( $item['executedPaths'], - $item['executablePaths'] + $item['executablePaths'], ); $testedMethodsPercentage = Percentage::fromFractionAndTotal( $numTestedMethods, - 1 + 1, ); return $this->renderItemTemplate( $template, [ - 'name' => \sprintf( + 'name' => sprintf( '%s%s', $indent, $item['startLine'], - \htmlspecialchars($item['signature'], $this->htmlSpecialCharsFlags), - $item['functionName'] ?? $item['methodName'] + htmlspecialchars($item['signature'], self::HTML_SPECIAL_CHARS_FLAGS), + $item['functionName'] ?? $item['methodName'], ), 'numMethods' => $numMethods, 'numTestedMethods' => $numTestedMethods, @@ -285,12 +516,15 @@ private function renderFunctionOrMethodItem(Template $template, array $item, str 'testedMethodsPercent' => $testedMethodsPercentage->asFloat(), 'testedMethodsPercentAsString' => $testedMethodsPercentage->asString(), 'crap' => $item['crap'], - ] + ], ); } - private function renderSource(FileNode $node): string + 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()); @@ -302,136 +536,502 @@ private function renderSource(FileNode $node): string $popoverContent = ''; $popoverTitle = ''; - if (\array_key_exists($i, $coverageData)) { - $numTests = ($coverageData[$i] ? \count($coverageData[$i]) : 0); + if (array_key_exists($i, $coverageData)) { + $numTests = ($coverageData[$i] !== null ? count($coverageData[$i]) : 0); if ($coverageData[$i] === null) { - $trClass = ' class="warning"'; - } elseif ($numTests == 0) { - $trClass = ' class="danger"'; + $trClass = 'warning'; + } elseif ($numTests === 0) { + $trClass = 'danger'; } else { - $lineCss = 'covered-by-large-tests'; - $popoverContent = '
    '; - if ($numTests > 1) { $popoverTitle = $numTests . ' tests cover line ' . $i; } else { $popoverTitle = '1 test covers line ' . $i; } + $lineCss = 'covered-by-large-tests'; + $popoverContent = '
      '; + foreach ($coverageData[$i] as $test) { - if ($lineCss == 'covered-by-large-tests' && $testData[$test]['size'] == 'medium') { + if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') { $lineCss = 'covered-by-medium-tests'; - } elseif ($testData[$test]['size'] == 'small') { + } elseif ($testData[$test]['size'] === 'small') { $lineCss = 'covered-by-small-tests'; } - switch ($testData[$test]['status']) { - case 0: - switch ($testData[$test]['size']) { - case 'small': - $testCSS = ' class="covered-by-small-tests"'; + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); + } + + $popoverContent .= '
    '; + $trClass = $lineCss . ' popin'; + } + } + + $popover = ''; + + if ($popoverTitle !== '') { + $popover = sprintf( + ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', + $popoverTitle, + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } + + $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 = []; + + 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 = '
      '; - break; + 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.'; - case 'medium': - $testCSS = ' class="covered-by-medium-tests"'; + foreach ($lineData[$i]['tests'] as $test) { + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); + } - break; + $popoverContent .= '
    '; + $trClass = $lineCss . ' popin'; - default: - $testCSS = ' class="covered-by-large-tests"'; + $popover = sprintf( + ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', + $popoverTitle, + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } - break; - } + $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover); - break; + $i++; + } - case 1: - case 2: - $testCSS = ' class="warning"'; + $linesTemplate->setVar(['lines' => $lines]); - break; + return $linesTemplate->render(); + } - case 3: - $testCSS = ' class="danger"'; + private function renderSourceWithPathCoverage(FileNode $node): string + { + $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); + $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); - break; + $functionCoverageData = $node->functionCoverageData(); + $testData = $node->testData(); + $codeLines = $this->loadFile($node->pathAsString()); - case 4: - $testCSS = ' class="danger"'; + $lineData = []; - break; + foreach (array_keys($codeLines) as $line) { + $lineData[$line + 1] = [ + 'includedInPaths' => [], + 'includedInHitPaths' => [], + 'tests' => [], + ]; + } - default: - $testCSS = ''; + 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; - $popoverContent .= \sprintf( - '%s', - $testCSS, - \htmlspecialchars($test, $this->htmlSpecialCharsFlags) - ); + if ($path['hit']) { + $lineData[$line]['includedInHitPaths'][] = $pathId; + $lineData[$line]['tests'] = array_unique(array_merge($lineData[$line]['tests'], $path['hit'])); + } } + } + } + } - $popoverContent .= '
'; - $trClass = ' class="' . $lineCss . ' popin"'; + $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 = '
    '; + + 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 .= '
'; + $trClass = $lineCss . ' popin'; + + $popover = sprintf( + ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', + $popoverTitle, + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } + + $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 .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; + $branches .= $branchStructure; + } + } + + $branchesTemplate->setVar(['branches' => $branches]); + + return $branchesTemplate->render(); + } + + /** + * @param list $codeLines + */ + 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 = '