diff --git a/.gitattributes b/.gitattributes index 90b60b613..e389d472f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,14 @@ -/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 8edad4279..000000000 --- a/ChangeLog.md +++ /dev/null @@ -1,149 +0,0 @@ -# ChangeLog - -All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [8.0.1] - 2020-02-19 - -### Fixed - -* Fixed [#731](https://github.com/sebastianbergmann/php-code-coverage/pull/731): Confusing footer in the HTML report - -## [8.0.0] - 2020-02-07 - -### Fixed - -* Implemented [#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 - -* 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 - -* Implemented [#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 - -* Fixed [#681](https://github.com/sebastianbergmann/php-code-coverage/pull/681): `use function` statements are not ignored - -## [7.0.4] - 2019-05-29 - -### Fixed - -* 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 - -* 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 - -* Fixed [#667](https://github.com/sebastianbergmann/php-code-coverage/pull/667): `TypeError` in PHP reporter - -## [7.0.1] - 2019-02-01 - -### Fixed - -* 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 - -* Implemented [#663](https://github.com/sebastianbergmann/php-code-coverage/pull/663): Support for PCOV - -### Fixed - -* Fixed [#654](https://github.com/sebastianbergmann/php-code-coverage/issues/654): HTML report fails to load assets -* Fixed [#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 - -* 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 - -* 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 - -[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 c7a490a62..daf51c7b1 100644 --- a/build.xml +++ b/build.xml @@ -13,19 +13,47 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 1c8abd036..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.0-dev" + "dev-main": "12.3.x-dev" } } } diff --git a/phive.xml b/phive.xml deleted file mode 100644 index bd74b9524..000000000 --- a/phive.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - 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 765629d33..bedc11927 100644 --- a/src/CodeCoverage.php +++ b/src/CodeCoverage.php @@ -9,148 +9,71 @@ */ 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\PCOV; -use SebastianBergmann\CodeCoverage\Driver\PHPDBG; -use SebastianBergmann\CodeCoverage\Driver\Xdebug; 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 { - /** - * @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; - - /** - * @var bool - */ - private $ignoreDeprecatedCode = false; - - /** - * @var PhptTestCase|string|TestCase - */ - private $currentId; - - /** - * Code coverage data. - * - * @var array - */ - 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; + 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; /** - * Determine whether we need to check for dead and unused code on each test - * - * @var bool + * @var array */ - private $shouldCheckForDeadAndUnused = true; + private array $tests = []; /** - * @var Directory + * @var list */ - private $report; + private array $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = []; + private ?FileAnalyser $analyser = null; + private ?string $cacheDirectory = null; + private ?Directory $cachedReport = null; - /** - * @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; } /** @@ -158,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; } /** @@ -170,11 +93,19 @@ public function getReport(): Directory */ public function clear(): void { - $this->isInitialized = false; - $this->currentId = null; - $this->data = []; - $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; } /** @@ -188,10 +119,12 @@ public function filter(): Filter /** * Returns the collected code coverage data. */ - public function getData(bool $raw = false): array + public function getData(bool $raw = false): ProcessedCodeCoverageData { - if (!$raw && $this->addUncoveredFilesFromWhitelist) { - $this->addUncoveredFilesFromWhitelist(); + if (!$raw) { + if ($this->includeUncoveredFiles) { + $this->addUncoveredFilesFromFilter(); + } } return $this->data; @@ -200,14 +133,13 @@ public function getData(bool $raw = false): array /** * Sets the coverage data. */ - public function setData(array $data): void + 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 { @@ -215,670 +147,414 @@ 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->currentSize = $size; - $this->currentId = $id; + $this->driver->start(); - $this->driver->start($this->shouldCheckForDeadAndUnused); + $this->cachedReport = null; } - /** - * Stop collection of code coverage information. - * - * @param array|false $linesToBeCovered - * - * @throws MissingCoversAnnotationException - * @throws CoveredCodeNotExecutedException - * @throws RuntimeException - * @throws InvalidArgumentException - * @throws \ReflectionException - */ - public function stop(bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = [], bool $ignoreForceCoversAnnotation = false): array + 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 InvalidArgumentException::create( - 2, - '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 \SebastianBergmann\CodeCoverage\InvalidArgumentException - * @throws RuntimeException + * @throws ReflectionException + * @throws TestIdMissingException + * @throws UnintentionallyCoveredCodeException */ - public function append(array $data, $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($data); - $this->applyIgnoredLinesFilter($data); - $this->initializeFilesThatAreSeenTheFirstTime($data); + if ($status === null) { + $status = TestStatus::unknown(); + } - if (!$append) { - return; + if ($covers === null) { + $covers = TargetCollection::fromArray([]); } - if ($id !== 'UNCOVERED_FILES_FROM_WHITELIST') { - $this->applyCoversAnnotationFilter( - $data, - $linesToBeCovered, - $linesToBeUsed, - $ignoreForceCoversAnnotation - ); + if ($uses === null) { + $uses = TargetCollection::fromArray([]); } - if (empty($data)) { - return; + $size = $this->currentSize; + + 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->tests[$id] = ['size' => $size, 'status' => $status]; + $this->data->initializeUnseenData($rawData); - foreach ($data as $file => $lines) { - if (!$this->filter->isFile($file)) { - continue; - } + if (!$append) { + return; + } - foreach ($lines as $k => $v) { - if ($v === Driver::LINE_EXECUTED) { - if (empty($this->data[$file][$k]) || !\in_array($id, $this->data[$file][$k])) { - $this->data[$file][$k][] = $id; - } - } - } + if ($id === self::UNCOVERED_FILES) { + return; + } + + $linesToBeCovered = false; + $linesToBeUsed = []; + + if ($covers !== false) { + $linesToBeCovered = $this->targetMapper()->mapTargets($covers); + } + + if ($linesToBeCovered !== false) { + $linesToBeUsed = $this->targetMapper()->mapTargets($uses); + } + + $this->applyCoversAndUsesFilter( + $rawData, + $linesToBeCovered, + $linesToBeUsed, + $size, + ); + + if ($rawData->lineCoverage() === []) { + return; } - $this->report = null; + $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(), ); - foreach ($that->data as $file => $lines) { - if (!isset($this->data[$file])) { - if (!$this->filter->isFiltered($file)) { - $this->data[$file] = $lines; - } - - continue; - } - - // we should compare the lines if any of two contains data - $compareLineNumbers = \array_unique( - \array_merge( - \array_keys($this->data[$file]), - \array_keys($that->data[$file]) - ) - ); + $this->data->merge($that->data); - foreach ($compareLineNumbers as $line) { - $thatPriority = $this->getLinePriority($that->data[$file], $line); - $thisPriority = $this->getLinePriority($this->data[$file], $line); + $this->tests = array_merge($this->tests, $that->getTests()); - if ($thatPriority > $thisPriority) { - $this->data[$file][$line] = $that->data[$file][$line]; - } elseif ($thatPriority === $thisPriority && \is_array($this->data[$file][$line])) { - $this->data[$file][$line] = \array_unique( - \array_merge($this->data[$file][$line], $that->data[$file][$line]) - ); - } - } - } - - $this->tests = \array_merge($this->tests, $that->getTests()); - $this->report = null; + $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; } /** - * 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. - * - * @param array $data - * @param int $line - * - * @return int + * @throws StaticAnalysisCacheNotConfiguredException */ - private function getLinePriority($data, $line) + public function cacheDirectory(): string { - 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; + if (!$this->cachesStaticAnalysis()) { + throw new StaticAnalysisCacheNotConfiguredException( + 'The static analysis cache is not configured', + ); } - return 4; + return $this->cacheDirectory; } /** - * Applies the @covers annotation filtering. - * - * @param array|false $linesToBeCovered - * - * @throws \SebastianBergmann\CodeCoverage\CoveredCodeNotExecutedException - * @throws \ReflectionException - * @throws MissingCoversAnnotationException - * @throws UnintentionallyCoveredCodeException + * @param class-string $className */ - private function applyCoversAnnotationFilter(array &$data, $linesToBeCovered, array $linesToBeUsed, bool $ignoreForceCoversAnnotation): void + public function excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck(string $className): void { - if ($linesToBeCovered === false || - ($this->forceCoversAnnotation && empty($linesToBeCovered) && !$ignoreForceCoversAnnotation)) { - if ($this->checkForMissingCoversAnnotation) { - throw new MissingCoversAnnotationException; - } - - $data = []; - - return; - } - - if (empty($linesToBeCovered)) { - return; - } - - if ($this->checkForUnintentionallyCoveredCode && - (!$this->currentId instanceof TestCase || - (!$this->currentId->isMedium() && !$this->currentId->isLarge()))) { - $this->performUnintentionallyCoveredCodeCheck($data, $linesToBeCovered, $linesToBeUsed); - } - - if ($this->checkForUnexecutedCoveredCode) { - $this->performUnexecutedCoveredCodeCheck($data, $linesToBeCovered, $linesToBeUsed); - } - - $data = \array_intersect_key($data, $linesToBeCovered); - - foreach (\array_keys($data) as $filename) { - $_linesToBeCovered = \array_flip($linesToBeCovered[$filename]); - $data[$filename] = \array_intersect_key($data[$filename], $_linesToBeCovered); - } + $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck[] = $className; } - private function applyWhitelistFilter(array &$data): void + public function enableBranchAndPathCoverage(): void { - foreach (\array_keys($data) as $filename) { - if ($this->filter->isFiltered($filename)) { - unset($data[$filename]); - } - } + $this->driver->enableBranchAndPathCoverage(); } - /** - * @throws \SebastianBergmann\CodeCoverage\InvalidArgumentException - */ - private function applyIgnoredLinesFilter(array &$data): void + public function disableBranchAndPathCoverage(): void { - foreach (\array_keys($data) as $filename) { - if (!$this->filter->isFile($filename)) { - continue; - } - - foreach ($this->getLinesToBeIgnored($filename) as $line) { - unset($data[$filename][$line]); - } - } + $this->driver->disableBranchAndPathCoverage(); } - private function initializeFilesThatAreSeenTheFirstTime(array $data): void + public function collectsBranchAndPathCoverage(): bool { - foreach ($data as $file => $lines) { - if (!isset($this->data[$file]) && $this->filter->isFile($file)) { - $this->data[$file] = []; + return $this->driver->collectsBranchAndPathCoverage(); + } - foreach ($lines as $k => $v) { - $this->data[$file][$k] = $v === -2 ? null : []; - } - } - } + public function validate(TargetCollection $targets): ValidationResult + { + return (new TargetCollectionValidator)->validate($this->targetMapper(), $targets); } /** - * @throws CoveredCodeNotExecutedException - * @throws InvalidArgumentException - * @throws MissingCoversAnnotationException - * @throws RuntimeException + * @param false|TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed + * + * @throws ReflectionException * @throws UnintentionallyCoveredCodeException - * @throws \ReflectionException */ - private function addUncoveredFilesFromWhitelist(): void + private function applyCoversAndUsesFilter(RawCodeCoverageData $rawData, array|false $linesToBeCovered, array $linesToBeUsed, TestSize $size): void { - $data = []; - $uncoveredFiles = \array_diff( - $this->filter->getWhitelist(), - \array_keys($this->data) - ); - - foreach ($uncoveredFiles as $uncoveredFile) { - if (!\file_exists($uncoveredFile)) { - continue; - } + if ($linesToBeCovered === false) { + $rawData->clear(); - $data[$uncoveredFile] = []; + return; + } - $lines = \count(\file($uncoveredFile)); + if ($linesToBeCovered === []) { + return; + } - for ($i = 1; $i <= $lines; $i++) { - $data[$uncoveredFile][$i] = Driver::LINE_NOT_EXECUTED; - } + if ($this->checkForUnintentionallyCoveredCode && !$size->isMedium() && !$size->isLarge()) { + $this->performUnintentionallyCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed); } - $this->append($data, 'UNCOVERED_FILES_FROM_WHITELIST'); - } + $rawLineData = $rawData->lineCoverage(); + $filesWithNoCoverage = array_diff_key($rawLineData, $linesToBeCovered); - private function getLinesToBeIgnored(string $fileName): array - { - if (isset($this->ignoredLines[$fileName])) { - return $this->ignoredLines[$fileName]; + foreach (array_keys($filesWithNoCoverage) as $fileWithNoCoverage) { + $rawData->removeCoverageDataForFile($fileWithNoCoverage); } - 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 []; + foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) { + $rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines); + $rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines); } } - private function getLinesToBeIgnoredInner(string $fileName): array + private function applyFilter(RawCodeCoverageData $data): void { - $this->ignoredLines[$fileName] = []; - - $lines = \file($fileName); - - foreach ($lines as $index => $line) { - if (!\trim($line)) { - $this->ignoredLines[$fileName][] = $index + 1; - } - } - - if ($this->cacheTokens) { - $tokens = \PHP_Token_Stream_CachingFactory::get($fileName); - } else { - $tokens = new \PHP_Token_Stream($fileName); + if ($this->filter->isEmpty()) { + return; } - foreach ($tokens->getInterfaces() as $interface) { - $interfaceStartLine = $interface['startLine']; - $interfaceEndLine = $interface['endLine']; - - foreach (\range($interfaceStartLine, $interfaceEndLine) as $line) { - $this->ignoredLines[$fileName][] = $line; + foreach (array_keys($data->lineCoverage()) as $filename) { + if ($this->filter->isExcluded($filename)) { + $data->removeCoverageDataForFile($filename); } } + } - 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) { - $this->ignoredLines[$fileName][] = $line; - } - + private function applyExecutableLinesFilter(RawCodeCoverageData $data): void + { + foreach (array_keys($data->lineCoverage()) as $filename) { + if (!$this->filter->isFile($filename)) { 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')); + $linesToBranchMap = $this->analyser()->analyse($filename)->executableLines(); - if ($lastMethod !== null) { - $lastMethodEndLine = $lastMethod['endLine']; - } - - foreach (\range($classOrTraitStartLine, $firstMethodStartLine) as $line) { - $this->ignoredLines[$fileName][] = $line; - } - - foreach (\range($lastMethodEndLine + 1, $classOrTraitEndLine) as $line) { - $this->ignoredLines[$fileName][] = $line; - } - } - - if ($this->disableIgnoredLines) { - $this->ignoredLines[$fileName] = \array_unique($this->ignoredLines[$fileName]); - \sort($this->ignoredLines[$fileName]); + $data->keepLineCoverageDataOnlyForLines( + $filename, + array_keys($linesToBranchMap), + ); - return $this->ignoredLines[$fileName]; + $data->markExecutableLineByBranch( + $filename, + $linesToBranchMap, + ); } + } - $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; - } - - if (!$ignore) { - $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++) { - $this->ignoredLines[$fileName][] = $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)) { - $this->ignoredLines[$fileName][] = $i; - } - } - - break; - - 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(); - - $this->ignoredLines[$fileName][] = $token->getLine(); - - if (\strpos($docblock, '@codeCoverageIgnore') || ($this->ignoreDeprecatedCode && \strpos($docblock, '@deprecated'))) { - $endLine = $token->getEndLine(); - - for ($i = $token->getLine(); $i <= $endLine; $i++) { - $this->ignoredLines[$fileName][] = $i; - } - } - - break; - - /* @noinspection PhpMissingBreakStatementInspection */ - case \PHP_Token_NAMESPACE::class: - $this->ignoredLines[$fileName][] = $token->getEndLine(); - - // Intentional fallthrough - 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: - $this->ignoredLines[$fileName][] = $token->getLine(); - - break; + private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void + { + foreach (array_keys($data->lineCoverage()) as $filename) { + if (!$this->filter->isFile($filename)) { + continue; } - if ($ignore) { - $this->ignoredLines[$fileName][] = $token->getLine(); - - if ($stop) { - $ignore = false; - $stop = false; - } - } + $data->removeCoverageDataForLines( + $filename, + $this->analyser()->analyse($filename)->ignoredLines(), + ); } + } - $this->ignoredLines[$fileName][] = \count($lines) + 1; - - $this->ignoredLines[$fileName] = \array_unique( - $this->ignoredLines[$fileName] + /** + * @throws UnintentionallyCoveredCodeException + */ + private function addUncoveredFilesFromFilter(): void + { + $uncoveredFiles = array_diff( + $this->filter->files(), + $this->data->coveredFiles(), ); - $this->ignoredLines[$fileName] = \array_unique($this->ignoredLines[$fileName]); - \sort($this->ignoredLines[$fileName]); - - return $this->ignoredLines[$fileName]; + foreach ($uncoveredFiles as $uncoveredFile) { + if (is_file($uncoveredFile)) { + $this->append( + RawCodeCoverageData::fromUncoveredFile( + $uncoveredFile, + $this->analyser(), + ), + self::UNCOVERED_FILES, + ); + } + } } /** - * @throws \ReflectionException + * @param TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed + * + * @throws ReflectionException * @throws UnintentionallyCoveredCodeException */ - private function performUnintentionallyCoveredCodeCheck(array &$data, array $linesToBeCovered, array $linesToBeUsed): void + private function performUnintentionallyCoveredCodeCheck(RawCodeCoverageData $data, array $linesToBeCovered, array $linesToBeUsed): void { $allowedLines = $this->getAllowedLines( $linesToBeCovered, - $linesToBeUsed + $linesToBeUsed, ); $unintentionallyCoveredUnits = []; - foreach ($data as $file => $_data) { + 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(array &$data, array $linesToBeCovered, array $linesToBeUsed): void - { - $executedCodeUnits = $this->coverageToCodeUnits($data); - $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]), ); } @@ -886,121 +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 PHPDBG; - } - - if ($runtime->hasPCOV()) { - return new PCOV; - } - - if ($runtime->hasXdebug()) { - return new Xdebug($filter); - } - - throw new RuntimeException('No code coverage driver available'); - } - private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array { - $unintentionallyCoveredUnits = \array_unique($unintentionallyCoveredUnits); - \sort($unintentionallyCoveredUnits); - - foreach (\array_keys($unintentionallyCoveredUnits) as $k => $v) { - $unit = \explode('::', $unintentionallyCoveredUnits[$k]); - - if (\count($unit) !== 2) { - continue; - } + $unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits); + $processed = []; - $class = new \ReflectionClass($unit[0]); + foreach ($unintentionallyCoveredUnits as $unintentionallyCoveredUnit) { + $tmp = explode('::', $unintentionallyCoveredUnit); - foreach ($this->unintentionallyCoveredSubclassesWhitelist as $whitelisted) { - if ($class->isSubclassOf($whitelisted)) { - unset($unintentionallyCoveredUnits[$k]); + if (count($tmp) !== 2) { + $processed[] = $unintentionallyCoveredUnit; - break; - } + continue; } - } - return \array_values($unintentionallyCoveredUnits); - } + try { + $class = new ReflectionClass($tmp[0]); - /** - * @throws CoveredCodeNotExecutedException - * @throws InvalidArgumentException - * @throws MissingCoversAnnotationException - * @throws RuntimeException - * @throws UnintentionallyCoveredCodeException - * @throws \ReflectionException - */ - private function initializeData(): void - { - $this->isInitialized = true; - - if ($this->processUncoveredFilesFromWhitelist) { - $this->shouldCheckForDeadAndUnused = false; - - $this->driver->start(); - - foreach ($this->filter->getWhitelist() as $file) { - if ($this->filter->isFile($file)) { - include_once $file; + foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) { + if ($class->isSubclassOf($parentClass)) { + continue 2; + } } + } catch (\ReflectionException $e) { + throw new ReflectionException( + $e->getMessage(), + $e->getCode(), + $e, + ); } - $data = []; + $processed[] = $tmp[0]; + } - foreach ($this->driver->stop() as $file => $fileCoverage) { - if ($this->filter->isFiltered($file)) { - continue; - } + $processed = array_unique($processed); - foreach (\array_keys($fileCoverage) as $key) { - if ($fileCoverage[$key] === Driver::LINE_EXECUTED) { - $fileCoverage[$key] = Driver::LINE_NOT_EXECUTED; - } - } + sort($processed); - $data[$file] = $fileCoverage; - } - - $this->append($data, 'UNCOVERED_FILES_FROM_WHITELIST'); - } + return $processed; } - private function coverageToCodeUnits(array $data): array + private function targetMapper(): Mapper { - $codeUnits = []; - - foreach ($data 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 8a5f4bf08..b839cca53 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -9,39 +9,76 @@ */ namespace SebastianBergmann\CodeCoverage\Driver; +use function sprintf; +use SebastianBergmann\CodeCoverage\BranchAndPathCoverageNotSupportedException; +use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; + /** - * Interface for code coverage drivers. + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ -interface Driver +abstract class Driver { /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - public const LINE_EXECUTED = 1; + 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_NOT_EXECUTABLE = -2; + public const int LINE_EXECUTED = 1; /** - * Start collection of code coverage information. + * @see http://xdebug.org/docs/code_coverage */ - public function start(bool $determineUnusedAndDead = true): void; + public const int BRANCH_NOT_HIT = 0; /** - * Stop collection of code coverage information. + * @see http://xdebug.org/docs/code_coverage */ - public function stop(): array; + public const int BRANCH_HIT = 1; + private bool $collectBranchAndPathCoverage = false; + + public function canCollectBranchAndPathCoverage(): bool + { + return false; + } + + public function collectsBranchAndPathCoverage(): bool + { + return $this->collectBranchAndPathCoverage; + } + + /** + * @throws BranchAndPathCoverageNotSupportedException + */ + public function enableBranchAndPathCoverage(): void + { + if (!$this->canCollectBranchAndPathCoverage()) { + throw new BranchAndPathCoverageNotSupportedException( + sprintf( + '%s does not support branch and path coverage', + $this->nameAndVersion(), + ), + ); + } + + $this->collectBranchAndPathCoverage = true; + } + + public function disableBranchAndPathCoverage(): void + { + $this->collectBranchAndPathCoverage = false; + } + + abstract public function nameAndVersion(): string; + + abstract public function start(): void; + + abstract public function stop(): RawCodeCoverageData; } diff --git a/src/Driver/PCOV.php b/src/Driver/PCOV.php deleted file mode 100644 index dc42e7b11..000000000 --- a/src/Driver/PCOV.php +++ /dev/null @@ -1,45 +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; - -/** - * Driver for PCOV code coverage functionality. - * - * @codeCoverageIgnore - */ -final class PCOV implements Driver -{ - /** - * Start collection of code coverage information. - */ - public function start(bool $determineUnusedAndDead = true): void - { - \pcov\start(); - } - - /** - * Stop collection of code coverage information. - */ - public function stop(): array - { - \pcov\stop(); - - $waiting = \pcov\waiting(); - $collect = []; - - if ($waiting) { - $collect = \pcov\collect(\pcov\inclusive, $waiting); - - \pcov\clear(); - } - - return $collect; - } -} diff --git a/src/Driver/PHPDBG.php b/src/Driver/PHPDBG.php deleted file mode 100644 index 58dfc8d62..000000000 --- a/src/Driver/PHPDBG.php +++ /dev/null @@ -1,96 +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\RuntimeException; - -/** - * Driver for PHPDBG's code coverage functionality. - * - * @codeCoverageIgnore - */ -final class PHPDBG implements 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' - ); - } - } - - /** - * Start collection of code coverage information. - */ - public function start(bool $determineUnusedAndDead = true): void - { - \phpdbg_start_oplog(); - } - - /** - * Stop collection of code coverage information. - */ - public function stop(): array - { - 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 $this->detectExecutedLines($fetchedLines, $dbgData); - } - - /** - * Convert phpdbg based data into the format CodeCoverage expects - */ - 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/PcovDriver.php b/src/Driver/PcovDriver.php new file mode 100644 index 000000000..d7975efe3 --- /dev/null +++ b/src/Driver/PcovDriver.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +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; + +/** + * @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; + + /** + * @throws PcovNotAvailableException + */ + public function __construct(Filter $filter) + { + $this->ensurePcovIsAvailable(); + + $this->filter = $filter; + } + + /** + * @codeCoverageIgnore + */ + public function start(): void + { + start(); + } + + public function stop(): RawCodeCoverageData + { + stop(); + + // @codeCoverageIgnoreStart + $filesToCollectCoverageFor = waiting(); + $collected = []; + + if ($filesToCollectCoverageFor !== []) { + if (!$this->filter->isEmpty()) { + $filesToCollectCoverageFor = array_intersect($filesToCollectCoverageFor, $this->filter->files()); + } + + $collected = collect(inclusive, $filesToCollectCoverageFor); + + clear(); + } + + return RawCodeCoverageData::fromXdebugWithoutPathCoverage($collected); + // @codeCoverageIgnoreEnd + } + + public function nameAndVersion(): string + { + return 'PCOV ' . phpversion('pcov'); + } + + /** + * @throws PcovNotAvailableException + */ + private function ensurePcovIsAvailable(): void + { + if (!extension_loaded('pcov')) { + throw new PcovNotAvailableException; + } + } +} 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/Xdebug.php b/src/Driver/Xdebug.php deleted file mode 100644 index d0bf3ecb6..000000000 --- a/src/Driver/Xdebug.php +++ /dev/null @@ -1,112 +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\Filter; -use SebastianBergmann\CodeCoverage\RuntimeException; - -/** - * Driver for Xdebug's code coverage functionality. - * - * @codeCoverageIgnore - */ -final class Xdebug implements Driver -{ - /** - * @var array - */ - private $cacheNumLines = []; - - /** - * @var Filter - */ - private $filter; - - /** - * @throws RuntimeException - */ - public function __construct(Filter $filter = null) - { - 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'); - } - - if ($filter === null) { - $filter = new Filter; - } - - $this->filter = $filter; - } - - /** - * Start collection of code coverage information. - */ - public function start(bool $determineUnusedAndDead = true): void - { - if ($determineUnusedAndDead) { - \xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); - } else { - \xdebug_start_code_coverage(); - } - } - - /** - * Stop collection of code coverage information. - */ - public function stop(): array - { - $data = \xdebug_get_code_coverage(); - - \xdebug_stop_code_coverage(); - - return $this->cleanup($data); - } - - private function cleanup(array $data): array - { - foreach (\array_keys($data) as $file) { - unset($data[$file][0]); - - if (!$this->filter->isFile($file)) { - continue; - } - - $numLines = $this->getNumberOfLinesInFile($file); - - foreach (\array_keys($data[$file]) as $line) { - if ($line > $numLines) { - unset($data[$file][$line]); - } - } - } - - return $data; - } - - private function getNumberOfLinesInFile(string $fileName): int - { - if (!isset($this->cacheNumLines[$fileName])) { - $buffer = \file_get_contents($fileName); - $lines = \substr_count($buffer, "\n"); - - if (\substr($buffer, -1) !== "\n") { - $lines++; - } - - $this->cacheNumLines[$fileName] = $lines; - } - - return $this->cacheNumLines[$fileName]; - } -} diff --git a/src/Driver/XdebugDriver.php b/src/Driver/XdebugDriver.php new file mode 100644 index 000000000..039df00d0 --- /dev/null +++ b/src/Driver/XdebugDriver.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +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; + +/** + * @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 XdebugNotAvailableException + * @throws XdebugNotEnabledException + * @throws XdebugVersionNotSupportedException + */ + public function __construct(Filter $filter) + { + $this->ensureXdebugIsAvailable(); + + if (!$filter->isEmpty()) { + xdebug_set_filter( + XDEBUG_FILTER_CODE_COVERAGE, + XDEBUG_PATH_INCLUDE, + $filter->files(), + ); + } + } + + public function canCollectBranchAndPathCoverage(): bool + { + return true; + } + + public function start(): void + { + $flags = XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE; + + if ($this->collectsBranchAndPathCoverage()) { + $flags |= XDEBUG_CC_BRANCH_CHECK; + } + + xdebug_start_code_coverage($flags); + } + + public function stop(): RawCodeCoverageData + { + $data = xdebug_get_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 nameAndVersion(): string + { + return 'Xdebug ' . phpversion('xdebug'); + } + + /** + * @throws XdebugNotAvailableException + * @throws XdebugNotEnabledException + * @throws XdebugVersionNotSupportedException + */ + private function ensureXdebugIsAvailable(): void + { + 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/CoveredCodeNotExecutedException.php b/src/Exception/BranchAndPathCoverageNotSupportedException.php similarity index 69% rename from src/Exception/CoveredCodeNotExecutedException.php rename to src/Exception/BranchAndPathCoverageNotSupportedException.php index 147be6b73..ab2089197 100644 --- a/src/Exception/CoveredCodeNotExecutedException.php +++ b/src/Exception/BranchAndPathCoverageNotSupportedException.php @@ -9,9 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -/** - * Exception that is raised when covered code is not executed. - */ -final class CoveredCodeNotExecutedException extends RuntimeException +use RuntimeException; + +final class BranchAndPathCoverageNotSupportedException extends RuntimeException implements Exception { } diff --git a/src/Exception/DirectoryCouldNotBeCreatedException.php b/src/Exception/DirectoryCouldNotBeCreatedException.php new file mode 100644 index 000000000..fdd9bfdf1 --- /dev/null +++ b/src/Exception/DirectoryCouldNotBeCreatedException.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Util; + +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/MissingCoversAnnotationException.php b/src/Exception/FileCouldNotBeWrittenException.php similarity index 68% rename from src/Exception/MissingCoversAnnotationException.php rename to src/Exception/FileCouldNotBeWrittenException.php index 5add9c27c..db9cdac34 100644 --- a/src/Exception/MissingCoversAnnotationException.php +++ b/src/Exception/FileCouldNotBeWrittenException.php @@ -9,9 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -/** - * Exception that is raised when @covers must be used but is not. - */ -final class MissingCoversAnnotationException extends RuntimeException +use RuntimeException; + +final class FileCouldNotBeWrittenException extends RuntimeException implements Exception { } diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index f1e8e6290..17e4b7076 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -11,26 +11,4 @@ final class InvalidArgumentException extends \InvalidArgumentException implements Exception { - /** - * @param int $argument - * @param string $type - * @param null|mixed $value - * - * @return InvalidArgumentException - */ - public static function create($argument, $type, $value = null): self - { - $stack = \debug_backtrace(0); - - return new self( - \sprintf( - 'Argument #%d%sof %s::%s() must be a %s', - $argument, - $value !== null ? ' (' . \gettype($value) . '#' . $value . ')' : ' (No Value) ', - $stack[1]['class'], - $stack[1]['function'], - $type - ) - ); - } } 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/ParserException.php b/src/Exception/ParserException.php new file mode 100644 index 000000000..a907e34e8 --- /dev/null +++ b/src/Exception/ParserException.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 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/ReflectionException.php b/src/Exception/ReflectionException.php new file mode 100644 index 000000000..78db430be --- /dev/null +++ b/src/Exception/ReflectionException.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 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 efeec8d9f..bb7d88c97 100644 --- a/src/Exception/UnintentionallyCoveredCodeException.php +++ b/src/Exception/UnintentionallyCoveredCodeException.php @@ -9,16 +9,18 @@ */ namespace SebastianBergmann\CodeCoverage; -/** - * Exception that is raised when code is unintentionally covered. - */ -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; @@ -26,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/RuntimeException.php b/src/Exception/XmlException.php similarity index 77% rename from src/Exception/RuntimeException.php rename to src/Exception/XmlException.php index d0f93942a..31e4623df 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/XmlException.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage; -class RuntimeException extends \RuntimeException implements Exception +use RuntimeException; + +final class XmlException extends RuntimeException implements Exception { } diff --git a/src/Filter.php b/src/Filter.php index 67501ee28..f9086542b 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -9,166 +9,85 @@ */ 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. - * - * @var array + * @var array */ - private $whitelistedFiles = []; + private array $files = []; /** - * Remembers the result of the `is_file()` calls. - * - * @var bool[] + * @var array */ - private $isFileCallsCache = []; + 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 { - $facade = new FileIteratorFacade; - $files = $facade->getFilesAsArray($directory, $suffix, $prefix); - - foreach ($files 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. - * - * @param string[] $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 - { - $facade = new FileIteratorFacade; - $files = $facade->getFilesAsArray($directory, $suffix, $prefix); - - foreach ($files 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->isFileCallsCache[$filename])) { - return $this->isFileCallsCache[$filename]; + if (isset($this->isFileCache[$filename])) { + return $this->isFileCache[$filename]; } 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->isFileCallsCache[$filename] = $isFile; + $this->isFileCache[$filename] = $isFile; return $isFile; } - /** - * Checks whether or not a file is filtered. - */ - public function isFiltered(string $filename): bool - { - if (!$this->isFile($filename)) { - return true; - } - - return !isset($this->whitelistedFiles[$filename]); - } - - /** - * Returns the list of whitelisted files. - * - * @return string[] - */ - public function getWhitelist(): array + public function isExcluded(string $filename): bool { - return \array_keys($this->whitelistedFiles); + return !isset($this->files[$filename]) || !$this->isFile($filename); } /** - * Returns whether this filter has a whitelist. + * @return list */ - public function hasWhitelist(): bool + public function files(): array { - return !empty($this->whitelistedFiles); + return array_keys($this->files); } - /** - * Returns the whitelisted files. - * - * @return string[] - */ - public function getWhitelistedFiles(): array - { - return $this->whitelistedFiles; - } - - /** - * Sets the whitelisted files. - */ - 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 411c04e4c..7e82a3daf 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -9,320 +9,267 @@ */ namespace SebastianBergmann\CodeCoverage\Node; -use SebastianBergmann\CodeCoverage\Util; +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; /** - * Base class for nodes in the code coverage information tree. + * @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; - - /** - * @var array - */ - private $pathArray; - - /** - * @var AbstractNode - */ - private $parent; + private readonly string $name; + private string $pathAsString; /** - * @var string + * @var non-empty-list */ - private $id; + private array $pathAsArray; + private readonly ?AbstractNode $parent; + private string $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 getName(): string + public function name(): string { return $this->name; } - public function getId(): string + public function id(): string { - if ($this->id === null) { - $parent = $this->getParent(); - - if ($parent === null) { - $this->id = 'index'; - } else { - $parentId = $parent->getId(); - - if ($parentId === 'index') { - $this->id = \str_replace(':', '_', $this->name); - } else { - $this->id = $parentId . '/' . $this->name; - } - } - } - return $this->id; } - public function getPath(): string + public function pathAsString(): string { - if ($this->path === null) { - if ($this->parent === null || $this->parent->getPath() === null || $this->parent->getPath() === false) { - $this->path = $this->name; - } else { - $this->path = $this->parent->getPath() . \DIRECTORY_SEPARATOR . $this->name; - } - } + return $this->pathAsString; + } - return $this->path; + /** + * @return non-empty-list + */ + public function pathAsArray(): array + { + return $this->pathAsArray; } - public function getPathAsArray(): array + public function parent(): ?self { - if ($this->pathArray === null) { - if ($this->parent === null) { - $this->pathArray = []; - } else { - $this->pathArray = $this->parent->getPathAsArray(); - } - - $this->pathArray[] = $this; - } + return $this->parent; + } - return $this->pathArray; + public function percentageOfTestedClasses(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfTestedClasses(), + $this->numberOfClasses(), + ); } - public function getParent(): ?self + public function percentageOfTestedTraits(): Percentage { - return $this->parent; + return Percentage::fromFractionAndTotal( + $this->numberOfTestedTraits(), + $this->numberOfTraits(), + ); } - /** - * Returns the percentage of classes that has been tested. - * - * @return int|string - */ - public function getTestedClassesPercent(bool $asString = true) + public function percentageOfTestedClassesAndTraits(): Percentage { - return Util::percent( - $this->getNumTestedClasses(), - $this->getNumClasses(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedClassesAndTraits(), + $this->numberOfClassesAndTraits(), ); } - /** - * Returns the percentage of traits that has been tested. - * - * @return int|string - */ - public function getTestedTraitsPercent(bool $asString = true) + public function percentageOfTestedFunctions(): Percentage { - return Util::percent( - $this->getNumTestedTraits(), - $this->getNumTraits(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedFunctions(), + $this->numberOfFunctions(), ); } - /** - * Returns the percentage of classes and traits that has been tested. - * - * @return int|string - */ - public function getTestedClassesAndTraitsPercent(bool $asString = true) + public function percentageOfTestedMethods(): Percentage { - return Util::percent( - $this->getNumTestedClassesAndTraits(), - $this->getNumClassesAndTraits(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedMethods(), + $this->numberOfMethods(), ); } - /** - * Returns the percentage of functions that has been tested. - * - * @return int|string - */ - public function getTestedFunctionsPercent(bool $asString = true) + public function percentageOfTestedFunctionsAndMethods(): Percentage { - return Util::percent( - $this->getNumTestedFunctions(), - $this->getNumFunctions(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedFunctionsAndMethods(), + $this->numberOfFunctionsAndMethods(), ); } - /** - * Returns the percentage of methods that has been tested. - * - * @return int|string - */ - public function getTestedMethodsPercent(bool $asString = true) + public function percentageOfExecutedLines(): Percentage { - return Util::percent( - $this->getNumTestedMethods(), - $this->getNumMethods(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedLines(), + $this->numberOfExecutableLines(), ); } - /** - * Returns the percentage of functions and methods that has been tested. - * - * @return int|string - */ - public function getTestedFunctionsAndMethodsPercent(bool $asString = true) + public function percentageOfExecutedBranches(): Percentage { - return Util::percent( - $this->getNumTestedFunctionsAndMethods(), - $this->getNumFunctionsAndMethods(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedBranches(), + $this->numberOfExecutableBranches(), ); } - /** - * Returns the percentage of executed lines. - * - * @return int|string - */ - public function getLineExecutedPercent(bool $asString = true) + public function percentageOfExecutedPaths(): Percentage { - return Util::percent( - $this->getNumExecutedLines(), - $this->getNumExecutableLines(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedPaths(), + $this->numberOfExecutablePaths(), ); } - /** - * Returns the number of classes and traits. - */ - public function getNumClassesAndTraits(): int + public function numberOfClassesAndTraits(): int { - return $this->getNumClasses() + $this->getNumTraits(); + return $this->numberOfClasses() + $this->numberOfTraits(); } - /** - * Returns the number of tested classes and traits. - */ - public function getNumTestedClassesAndTraits(): int + public function numberOfTestedClassesAndTraits(): int { - return $this->getNumTestedClasses() + $this->getNumTestedTraits(); + return $this->numberOfTestedClasses() + $this->numberOfTestedTraits(); } /** - * Returns the classes and traits of this node. + * @return array */ - public function getClassesAndTraits(): array + public function classesAndTraits(): array { - return \array_merge($this->getClasses(), $this->getTraits()); + return array_merge($this->classes(), $this->traits()); } - /** - * Returns the number of functions and methods. - */ - public function getNumFunctionsAndMethods(): int + public function numberOfFunctionsAndMethods(): int { - return $this->getNumFunctions() + $this->getNumMethods(); + return $this->numberOfFunctions() + $this->numberOfMethods(); } - /** - * Returns the number of tested functions and methods. - */ - public function getNumTestedFunctionsAndMethods(): int + public function numberOfTestedFunctionsAndMethods(): int { - return $this->getNumTestedFunctions() + $this->getNumTestedMethods(); + return $this->numberOfTestedFunctions() + $this->numberOfTestedMethods(); } /** - * Returns the functions and methods of this node. + * @return non-negative-int */ - public function getFunctionsAndMethods(): array + public function cyclomaticComplexity(): int { - return \array_merge($this->getFunctions(), $this->getMethods()); + $ccn = 0; + + foreach ($this->classesAndTraits() as $classLike) { + $ccn += $classLike['ccn']; + } + + foreach ($this->functions() as $function) { + $ccn += $function['ccn']; + } + + return $ccn; } /** - * Returns the classes of this node. + * @return array */ - abstract public function getClasses(): array; + abstract public function classes(): array; /** - * Returns the traits of this node. + * @return array */ - abstract public function getTraits(): array; + abstract public function traits(): array; /** - * Returns the functions of this node. + * @return array */ - abstract public function getFunctions(): array; + abstract public function functions(): array; - /** - * Returns the LOC/CLOC/NCLOC of this node. - */ - abstract public function getLinesOfCode(): array; + abstract public function linesOfCode(): LinesOfCode; - /** - * Returns the number of executable lines. - */ - abstract public function getNumExecutableLines(): int; + abstract public function numberOfExecutableLines(): int; - /** - * Returns the number of executed lines. - */ - abstract public function getNumExecutedLines(): int; + abstract public function numberOfExecutedLines(): int; - /** - * Returns the number of classes. - */ - abstract public function getNumClasses(): int; + abstract public function numberOfExecutableBranches(): int; - /** - * Returns the number of tested classes. - */ - abstract public function getNumTestedClasses(): int; + abstract public function numberOfExecutedBranches(): int; - /** - * Returns the number of traits. - */ - abstract public function getNumTraits(): int; + abstract public function numberOfExecutablePaths(): int; - /** - * Returns the number of tested traits. - */ - abstract public function getNumTestedTraits(): int; + abstract public function numberOfExecutedPaths(): int; - /** - * Returns the number of methods. - */ - abstract public function getNumMethods(): int; + abstract public function numberOfClasses(): int; - /** - * Returns the number of tested methods. - */ - abstract public function getNumTestedMethods(): int; + abstract public function numberOfTestedClasses(): int; - /** - * Returns the number of functions. - */ - abstract public function getNumFunctions(): int; + abstract public function numberOfTraits(): int; - /** - * Returns the number of tested functions. - */ - abstract public function getNumTestedFunctions(): int; + abstract public function numberOfTestedTraits(): int; + + abstract public function numberOfMethods(): int; + + 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 7c20ba752..19fc3a24d 100644 --- a/src/Node/Builder.php +++ b/src/Node/Builder.php @@ -9,43 +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\Data\ProcessedCodeCoverageData; +use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser; -final class Builder +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type TestType from CodeCoverage + */ +final readonly class Builder { + private FileAnalyser $analyser; + + public function __construct(FileAnalyser $analyser) + { + $this->analyser = $analyser; + } + public function build(CodeCoverage $coverage): Directory { - $files = $coverage->getData(); - $commonPath = $this->reducePaths($files); + $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($files), + $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 (\file_exists($root->getPath() . \DIRECTORY_SEPARATOR . $key)) { - $root->addFile($key, $value, $tests, $cacheTokens); + 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); + + $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); } } } @@ -89,15 +133,17 @@ private function addItems(Directory $root, array $items, array $tests, bool $cac * ) * ) * + * + * @return array, functionCoverage: array>}>> */ - private function buildDirectoryStructure(array $files): array + private function buildDirectoryStructure(ProcessedCodeCoverageData $data): array { $result = []; - foreach ($files as $path => $file) { - $path = \explode(\DIRECTORY_SEPARATOR, $path); + foreach ($data->coveredFiles() as $originalPath) { + $path = explode(DIRECTORY_SEPARATOR, $originalPath); $pointer = &$result; - $max = \count($path); + $max = count($path); for ($i = 0; $i < $max; $i++) { $type = ''; @@ -109,7 +155,10 @@ private function buildDirectoryStructure(array $files): array $pointer = &$pointer[$path[$i] . $type]; } - $pointer = $file; + $pointer = [ + 'lineCoverage' => $data->lineCoverage()[$originalPath] ?? [], + 'functionCoverage' => $data->functionCoverage()[$originalPath] ?? [], + ]; } return $result; @@ -152,41 +201,40 @@ private function buildDirectoryStructure(array $files): array * ) * */ - private function reducePaths(array &$files): string + private function reducePaths(ProcessedCodeCoverageData $coverage): string { - if (empty($files)) { + if ($coverage->coveredFiles() === []) { return '.'; } $commonPath = ''; - $paths = \array_keys($files); + $paths = $coverage->coveredFiles(); - if (\count($files) === 1) { - $commonPath = \dirname($paths[0]) . \DIRECTORY_SEPARATOR; - $files[\basename($paths[0])] = $files[$paths[0]]; - - unset($files[$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++) { @@ -202,26 +250,23 @@ private function reducePaths(array &$files): 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 = \array_keys($files); - $max = \count($original); + $original = $coverage->coveredFiles(); + $max = count($original); for ($i = 0; $i < $max; $i++) { - $files[\implode(\DIRECTORY_SEPARATOR, $paths[$i])] = $files[$original[$i]]; - unset($files[$original[$i]]); + $coverage->renameFile($original[$i], implode(DIRECTORY_SEPARATOR, $paths[$i])); } - \ksort($files); - - 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 0b1876190..2802f93ab 100644 --- a/src/Node/Directory.php +++ b/src/Node/Directory.php @@ -9,113 +9,77 @@ */ namespace SebastianBergmann\CodeCoverage\Node; -use SebastianBergmann\CodeCoverage\InvalidArgumentException; +use function array_merge; +use function assert; +use function count; +use IteratorAggregate; +use RecursiveIteratorIterator; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; /** - * Represents a directory in the code coverage information tree. + * @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 - */ - private $functions; - - /** - * @var array - */ - private $linesOfCode; - - /** - * @var int - */ - private $numFiles = -1; - - /** - * @var int + * @var list */ - private $numExecutableLines = -1; + private array $children = []; /** - * @var int + * @var list */ - private $numExecutedLines = -1; + private array $directories = []; /** - * @var int + * @var list */ - private $numClasses = -1; + private array $files = []; /** - * @var int + * @var ?array */ - private $numTestedClasses = -1; + private ?array $classes = null; /** - * @var int + * @var ?array */ - private $numTraits = -1; + private ?array $traits = null; /** - * @var int + * @var ?array */ - private $numTestedTraits = -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; - /** - * @var int - */ - private $numMethods = -1; - - /** - * @var int - */ - private $numTestedMethods = -1; - - /** - * @var int - */ - private $numFunctions = -1; - - /** - * @var int - */ - private $numTestedFunctions = -1; - - /** - * Returns the number of files in/under this node. - */ public function count(): int { if ($this->numFiles === -1) { $this->numFiles = 0; foreach ($this->children as $child) { - $this->numFiles += \count($child); + $this->numFiles += count($child); } } @@ -123,83 +87,73 @@ public function count(): int } /** - * Returns an iterator for this node. + * @return RecursiveIteratorIterator> */ - public function getIterator(): \RecursiveIteratorIterator + public function getIterator(): RecursiveIteratorIterator { - return new \RecursiveIteratorIterator( + return new RecursiveIteratorIterator( new Iterator($this), - \RecursiveIteratorIterator::SELF_FIRST + RecursiveIteratorIterator::SELF_FIRST, ); } - /** - * Adds a new directory. - */ 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; } - /** - * Adds a new file. - * - * @throws InvalidArgumentException - */ - public function addFile(string $name, array $coverageData, array $testData, bool $cacheTokens): File + public function addFile(File $file): void { - $file = new File($name, $this, $coverageData, $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; } /** - * Returns the directories in this directory. + * @return list */ - public function getDirectories(): array + public function directories(): array { return $this->directories; } /** - * Returns the files in this directory. + * @return list */ - public function getFiles(): array + public function files(): array { return $this->files; } /** - * Returns the child nodes of this node. + * @return list */ - public function getChildNodes(): array + public function children(): array { return $this->children; } /** - * Returns the classes of this node. + * @return array */ - public function getClasses(): 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->getClasses() + $child->classes(), ); } } @@ -208,17 +162,17 @@ public function getClasses(): array } /** - * Returns the traits of this node. + * @return array */ - public function getTraits(): 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->getTraits() + $child->traits(), ); } } @@ -227,17 +181,17 @@ public function getTraits(): array } /** - * Returns the functions of this node. + * @return array */ - public function getFunctions(): 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->getFunctions() + $child->functions(), ); } } @@ -245,180 +199,203 @@ public function getFunctions(): array return $this->functions; } - /** - * Returns the LOC/CLOC/NCLOC of this node. - */ - public function getLinesOfCode(): 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->getLinesOfCode(); + $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; } - /** - * Returns the number of executable lines. - */ - public function getNumExecutableLines(): int + public function numberOfExecutableLines(): int { if ($this->numExecutableLines === -1) { $this->numExecutableLines = 0; foreach ($this->children as $child) { - $this->numExecutableLines += $child->getNumExecutableLines(); + $this->numExecutableLines += $child->numberOfExecutableLines(); } } return $this->numExecutableLines; } - /** - * Returns the number of executed lines. - */ - public function getNumExecutedLines(): int + public function numberOfExecutedLines(): int { if ($this->numExecutedLines === -1) { $this->numExecutedLines = 0; foreach ($this->children as $child) { - $this->numExecutedLines += $child->getNumExecutedLines(); + $this->numExecutedLines += $child->numberOfExecutedLines(); } } return $this->numExecutedLines; } - /** - * Returns the number of classes. - */ - public function getNumClasses(): int + public function numberOfExecutableBranches(): int + { + if ($this->numExecutableBranches === -1) { + $this->numExecutableBranches = 0; + + foreach ($this->children as $child) { + $this->numExecutableBranches += $child->numberOfExecutableBranches(); + } + } + + return $this->numExecutableBranches; + } + + public function numberOfExecutedBranches(): int + { + if ($this->numExecutedBranches === -1) { + $this->numExecutedBranches = 0; + + foreach ($this->children as $child) { + $this->numExecutedBranches += $child->numberOfExecutedBranches(); + } + } + + return $this->numExecutedBranches; + } + + public function numberOfExecutablePaths(): int + { + if ($this->numExecutablePaths === -1) { + $this->numExecutablePaths = 0; + + foreach ($this->children as $child) { + $this->numExecutablePaths += $child->numberOfExecutablePaths(); + } + } + + return $this->numExecutablePaths; + } + + public function numberOfExecutedPaths(): int + { + if ($this->numExecutedPaths === -1) { + $this->numExecutedPaths = 0; + + foreach ($this->children as $child) { + $this->numExecutedPaths += $child->numberOfExecutedPaths(); + } + } + + return $this->numExecutedPaths; + } + + public function numberOfClasses(): int { if ($this->numClasses === -1) { $this->numClasses = 0; foreach ($this->children as $child) { - $this->numClasses += $child->getNumClasses(); + $this->numClasses += $child->numberOfClasses(); } } return $this->numClasses; } - /** - * Returns the number of tested classes. - */ - public function getNumTestedClasses(): int + public function numberOfTestedClasses(): int { if ($this->numTestedClasses === -1) { $this->numTestedClasses = 0; foreach ($this->children as $child) { - $this->numTestedClasses += $child->getNumTestedClasses(); + $this->numTestedClasses += $child->numberOfTestedClasses(); } } return $this->numTestedClasses; } - /** - * Returns the number of traits. - */ - public function getNumTraits(): int + public function numberOfTraits(): int { if ($this->numTraits === -1) { $this->numTraits = 0; foreach ($this->children as $child) { - $this->numTraits += $child->getNumTraits(); + $this->numTraits += $child->numberOfTraits(); } } return $this->numTraits; } - /** - * Returns the number of tested traits. - */ - public function getNumTestedTraits(): int + public function numberOfTestedTraits(): int { if ($this->numTestedTraits === -1) { $this->numTestedTraits = 0; foreach ($this->children as $child) { - $this->numTestedTraits += $child->getNumTestedTraits(); + $this->numTestedTraits += $child->numberOfTestedTraits(); } } return $this->numTestedTraits; } - /** - * Returns the number of methods. - */ - public function getNumMethods(): int + public function numberOfMethods(): int { if ($this->numMethods === -1) { $this->numMethods = 0; foreach ($this->children as $child) { - $this->numMethods += $child->getNumMethods(); + $this->numMethods += $child->numberOfMethods(); } } return $this->numMethods; } - /** - * Returns the number of tested methods. - */ - public function getNumTestedMethods(): int + public function numberOfTestedMethods(): int { if ($this->numTestedMethods === -1) { $this->numTestedMethods = 0; foreach ($this->children as $child) { - $this->numTestedMethods += $child->getNumTestedMethods(); + $this->numTestedMethods += $child->numberOfTestedMethods(); } } return $this->numTestedMethods; } - /** - * Returns the number of functions. - */ - public function getNumFunctions(): int + public function numberOfFunctions(): int { if ($this->numFunctions === -1) { $this->numFunctions = 0; foreach ($this->children as $child) { - $this->numFunctions += $child->getNumFunctions(); + $this->numFunctions += $child->numberOfFunctions(); } } return $this->numFunctions; } - /** - * Returns the number of tested functions. - */ - public function getNumTestedFunctions(): int + public function numberOfTestedFunctions(): int { if ($this->numTestedFunctions === -1) { $this->numTestedFunctions = 0; foreach ($this->children as $child) { - $this->numTestedFunctions += $child->getNumTestedFunctions(); + $this->numTestedFunctions += $child->numberOfTestedFunctions(); } } diff --git a/src/Node/File.php b/src/Node/File.php index 5268edc5a..54ee70b4a 100644 --- a/src/Node/File.php +++ b/src/Node/File.php @@ -9,183 +9,242 @@ */ 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_; + /** - * Represents a file in the code coverage information tree. + * @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 $coverageData; - - /** - * @var array - */ - private $testData; - - /** - * @var int - */ - private $numExecutableLines = 0; - - /** - * @var int - */ - private $numExecutedLines = 0; - - /** - * @var array - */ - private $classes = []; - - /** - * @var array + * @var array> */ - private $traits = []; + private array $lineCoverageData; + private array $functionCoverageData; /** - * @var array + * @var array */ - private $functions = []; + 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 $linesOfCode = []; + private array $classes = []; /** - * @var int + * @var array */ - private $numClasses; + private array $traits = []; /** - * @var int + * @var array */ - private $numTestedClasses = 0; + 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 $numTraits; + private array $codeUnitsByLine = []; /** - * @var int + * @param array> $lineCoverageData + * @param array $testData + * @param array $classes + * @param array $traits + * @param array $functions */ - 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 $coverageData, 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->coverageData = $coverageData; - $this->testData = $testData; - $this->cacheTokens = $cacheTokens; + $this->lineCoverageData = $lineCoverageData; + $this->functionCoverageData = $functionCoverageData; + $this->testData = $testData; + $this->linesOfCode = $linesOfCode; - $this->calculateStatistics(); + $this->calculateStatistics($classes, $traits, $functions); } - /** - * Returns the number of files in/under this node. - */ public function count(): int { return 1; } /** - * Returns the code coverage data of this node. + * @return array> */ - public function getCoverageData(): array + public function lineCoverageData(): array { - return $this->coverageData; + return $this->lineCoverageData; + } + + public function functionCoverageData(): array + { + return $this->functionCoverageData; } /** - * Returns the test data of this node. + * @return array */ - public function getTestData(): array + public function testData(): array { return $this->testData; } /** - * Returns the classes of this node. + * @return array */ - public function getClasses(): array + public function classes(): array { return $this->classes; } /** - * Returns the traits of this node. + * @return array */ - public function getTraits(): array + public function traits(): array { return $this->traits; } /** - * Returns the functions of this node. + * @return array */ - public function getFunctions(): array + public function functions(): array { return $this->functions; } - /** - * Returns the LOC/CLOC/NCLOC of this node. - */ - public function getLinesOfCode(): array + public function linesOfCode(): LinesOfCode { return $this->linesOfCode; } - /** - * Returns the number of executable lines. - */ - public function getNumExecutableLines(): int + public function numberOfExecutableLines(): int { return $this->numExecutableLines; } - /** - * Returns the number of executed lines. - */ - public function getNumExecutedLines(): int + public function numberOfExecutedLines(): int { return $this->numExecutedLines; } - /** - * Returns the number of classes. - */ - public function getNumClasses(): int + public function numberOfExecutableBranches(): int + { + return $this->numExecutableBranches; + } + + public function numberOfExecutedBranches(): int + { + return $this->numExecutedBranches; + } + + public function numberOfExecutablePaths(): int + { + return $this->numExecutablePaths; + } + + public function numberOfExecutedPaths(): int + { + return $this->numExecutedPaths; + } + + public function numberOfClasses(): int { if ($this->numClasses === null) { $this->numClasses = 0; @@ -204,18 +263,12 @@ public function getNumClasses(): int return $this->numClasses; } - /** - * Returns the number of tested classes. - */ - public function getNumTestedClasses(): int + public function numberOfTestedClasses(): int { return $this->numTestedClasses; } - /** - * Returns the number of traits. - */ - public function getNumTraits(): int + public function numberOfTraits(): int { if ($this->numTraits === null) { $this->numTraits = 0; @@ -234,18 +287,12 @@ public function getNumTraits(): int return $this->numTraits; } - /** - * Returns the number of tested traits. - */ - public function getNumTestedTraits(): int + public function numberOfTestedTraits(): int { return $this->numTestedTraits; } - /** - * Returns the number of methods. - */ - public function getNumMethods(): int + public function numberOfMethods(): int { if ($this->numMethods === null) { $this->numMethods = 0; @@ -270,10 +317,7 @@ public function getNumMethods(): int return $this->numMethods; } - /** - * Returns the number of tested methods. - */ - public function getNumTestedMethods(): int + public function numberOfTestedMethods(): int { if ($this->numTestedMethods === null) { $this->numTestedMethods = 0; @@ -300,18 +344,12 @@ public function getNumTestedMethods(): int return $this->numTestedMethods; } - /** - * Returns the number of functions. - */ - public function getNumFunctions(): int + public function numberOfFunctions(): int { - return \count($this->functions); + return count($this->functions); } - /** - * Returns the number of tested functions. - */ - public function getNumTestedFunctions(): int + public function numberOfTestedFunctions(): int { if ($this->numTestedFunctions === null) { $this->numTestedFunctions = 0; @@ -327,32 +365,23 @@ public function getNumTestedFunctions(): 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->getPath()); - } else { - $tokens = new \PHP_Token_Stream($this->getPath()); - } - - $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) { - if (isset($this->coverageData[$lineNumber])) { + foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) { + if (isset($this->lineCoverageData[$lineNumber])) { foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) { $codeUnit['executableLines']++; } @@ -361,7 +390,7 @@ private function calculateStatistics(): void $this->numExecutableLines++; - if (\count($this->coverageData[$lineNumber]) > 0) { + if (count($this->lineCoverageData[$lineNumber]) > 0) { foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) { $codeUnit['executedLines']++; } @@ -375,134 +404,114 @@ private function calculateStatistics(): void foreach ($this->traits as &$trait) { foreach ($trait['methods'] as &$method) { - if ($method['executableLines'] > 0) { - $method['coverage'] = ($method['executedLines'] / - $method['executableLines']) * 100; - } else { - $method['coverage'] = 100; - } + $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['crap'] = $this->crap( - $method['ccn'], - $method['coverage'] - ); + $method['coverage'] = $methodBranchCoverage > 0 ? $methodBranchCoverage : $methodLineCoverage; + $method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage > 0 ? $methodPathCoverage : $methodLineCoverage))->asString(); $trait['ccn'] += $method['ccn']; } unset($method); - if ($trait['executableLines'] > 0) { - $trait['coverage'] = ($trait['executedLines'] / - $trait['executableLines']) * 100; + $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; - if ($trait['coverage'] === 100) { - $this->numTestedClasses++; - } - } else { - $trait['coverage'] = 100; - } + $trait['coverage'] = $traitBranchCoverage > 0 ? $traitBranchCoverage : $traitLineCoverage; + $trait['crap'] = (new CrapIndex($trait['ccn'], $traitPathCoverage > 0 ? $traitPathCoverage : $traitLineCoverage))->asString(); - $trait['crap'] = $this->crap( - $trait['ccn'], - $trait['coverage'] - ); + if ($trait['executableLines'] > 0 && $trait['coverage'] === 100) { + $this->numTestedClasses++; + } } unset($trait); foreach ($this->classes as &$class) { foreach ($class['methods'] as &$method) { - if ($method['executableLines'] > 0) { - $method['coverage'] = ($method['executedLines'] / - $method['executableLines']) * 100; - } else { - $method['coverage'] = 100; - } + $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['crap'] = $this->crap( - $method['ccn'], - $method['coverage'] - ); + $method['coverage'] = $methodBranchCoverage > 0 ? $methodBranchCoverage : $methodLineCoverage; + $method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage > 0 ? $methodPathCoverage : $methodLineCoverage))->asString(); $class['ccn'] += $method['ccn']; } unset($method); - if ($class['executableLines'] > 0) { - $class['coverage'] = ($class['executedLines'] / - $class['executableLines']) * 100; + $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; - if ($class['coverage'] === 100) { - $this->numTestedClasses++; - } - } else { - $class['coverage'] = 100; - } + $class['coverage'] = $classBranchCoverage > 0 ? $classBranchCoverage : $classLineCoverage; + $class['crap'] = (new CrapIndex($class['ccn'], $classPathCoverage > 0 ? $classPathCoverage : $classLineCoverage))->asString(); - $class['crap'] = $this->crap( - $class['ccn'], - $class['coverage'] - ); + if ($class['executableLines'] > 0 && $class['coverage'] === 100) { + $this->numTestedClasses++; + } } unset($class); foreach ($this->functions as &$function) { - if ($function['executableLines'] > 0) { - $function['coverage'] = ($function['executedLines'] / - $function['executableLines']) * 100; - } else { - $function['coverage'] = 100; - } + $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 > 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'], - $function['coverage'] - ); } } - private function processClasses(\PHP_Token_Stream $tokens): void + /** + * @param array $classes + */ + private function processClasses(array $classes): void { - $classes = $tokens->getClasses(); - $link = $this->getId() . '.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, - 'methods' => [], - 'startLine' => $class['startLine'], - 'executableLines' => 0, - 'executedLines' => 0, - 'ccn' => 0, - 'coverage' => 0, - 'crap' => 0, - 'package' => $class['package'], - 'link' => $link . $class['startLine'], + 'className' => $className, + 'namespace' => $class->namespace(), + 'methods' => [], + 'startLine' => $class->startLine(), + 'executableLines' => 0, + 'executedLines' => 0, + 'executableBranches' => 0, + 'executedBranches' => 0, + 'executablePaths' => 0, + 'executedPaths' => 0, + 'ccn' => 0, + 'coverage' => 0, + 'crap' => 0, + 'link' => $link . $class->startLine(), ]; - foreach ($class['methods'] as $methodName => $method) { - if (\strpos($methodName, 'anonymous') === 0) { - continue; - } + 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]['methods'][$methodName] = $this->newMethod($methodName, $method, $link); + $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->classes[$className], &$this->classes[$className]['methods'][$methodName], @@ -512,33 +521,46 @@ 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->getId() . '.html#'; + $link = $this->id() . '.html#'; foreach ($traits as $traitName => $trait) { $this->traits[$traitName] = [ - 'traitName' => $traitName, - 'methods' => [], - 'startLine' => $trait['startLine'], - 'executableLines' => 0, - 'executedLines' => 0, - 'ccn' => 0, - 'coverage' => 0, - 'crap' => 0, - 'package' => $trait['package'], - 'link' => $link . $trait['startLine'], + 'traitName' => $traitName, + 'namespace' => $trait->namespace(), + 'methods' => [], + 'startLine' => $trait->startLine(), + 'executableLines' => 0, + 'executedLines' => 0, + 'executableBranches' => 0, + 'executedBranches' => 0, + 'executablePaths' => 0, + 'executedPaths' => 0, + 'ccn' => 0, + 'coverage' => 0, + 'crap' => 0, + 'link' => $link . $trait->startLine(), ]; - foreach ($trait['methods'] as $methodName => $method) { - if (\strpos($methodName, 'anonymous') === 0) { - continue; - } + foreach ($trait->methods() as $methodName => $method) { + $methodData = $this->newMethod($traitName, $method, $link); + $this->traits[$traitName]['methods'][$methodName] = $methodData; - $this->traits[$traitName]['methods'][$methodName] = $this->newMethod($methodName, $method, $link); + $this->traits[$traitName]['executableBranches'] += $methodData['executableBranches']; + $this->traits[$traitName]['executedBranches'] += $methodData['executedBranches']; + $this->traits[$traitName]['executablePaths'] += $methodData['executablePaths']; + $this->traits[$traitName]['executedPaths'] += $methodData['executedPaths']; - foreach (\range($method['startLine'], $method['endLine']) as $lineNumber) { + $this->numExecutableBranches += $methodData['executableBranches']; + $this->numExecutedBranches += $methodData['executedBranches']; + $this->numExecutablePaths += $methodData['executablePaths']; + $this->numExecutedPaths += $methodData['executedPaths']; + + foreach (range($method->startLine(), $method->endLine()) as $lineNumber) { $this->codeUnitsByLine[$lineNumber] = [ &$this->traits[$traitName], &$this->traits[$traitName]['methods'][$methodName], @@ -548,64 +570,132 @@ 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->getId() . '.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'], - 'executableLines' => 0, - 'executedLines' => 0, - 'ccn' => $function['ccn'], - 'coverage' => 0, - 'crap' => 0, - 'link' => $link . $function['startLine'], + 'functionName' => $functionName, + '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->cyclomaticComplexity(), + 'coverage' => 0, + 'crap' => 0, + '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]]; } + + if (isset($this->functionCoverageData[$functionName]['branches'])) { + $this->functions[$functionName]['executableBranches'] = count( + $this->functionCoverageData[$functionName]['branches'], + ); + + $this->functions[$functionName]['executedBranches'] = count( + array_filter( + $this->functionCoverageData[$functionName]['branches'], + static function (array $branch) + { + return (bool) $branch['hit']; + }, + ), + ); + } + + 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 crap(int $ccn, float $coverage): string + /** + * @return ProcessedMethodType + */ + private function newMethod(string $className, Method $method, string $link): array { - if ($coverage === 0.0) { - return (string) ($ccn ** 2 + $ccn); - } + $methodData = [ + '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->cyclomaticComplexity(), + 'coverage' => 0, + 'crap' => 0, + 'link' => $link . $method->startLine(), + ]; + + $key = $className . '->' . $method->name(); + + if (isset($this->functionCoverageData[$key]['branches'])) { + $methodData['executableBranches'] = count( + $this->functionCoverageData[$key]['branches'], + ); - if ($coverage >= 95) { - return (string) $ccn; + $methodData['executedBranches'] = count( + array_filter( + $this->functionCoverageData[$key]['branches'], + static function (array $branch) + { + return (bool) $branch['hit']; + }, + ), + ); } - return \sprintf( - '%01.2F', - $ccn ** 2 * (1 - $coverage / 100) ** 3 + $ccn - ); - } + if (isset($this->functionCoverageData[$key]['paths'])) { + $methodData['executablePaths'] = count( + $this->functionCoverageData[$key]['paths'], + ); - private function newMethod(string $methodName, array $method, string $link): array - { - return [ - 'methodName' => $methodName, - 'visibility' => $method['visibility'], - 'signature' => $method['signature'], - 'startLine' => $method['startLine'], - 'endLine' => $method['endLine'], - 'executableLines' => 0, - 'executedLines' => 0, - 'ccn' => $method['ccn'], - 'coverage' => 0, - 'crap' => 0, - 'link' => $link . $method['startLine'], - ]; + $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 2e65fac2a..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; + /** - * Recursive iterator for node object graphs. + * @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->getChildNodes(); + $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 + 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/Report/Clover.php b/src/Report/Clover.php index dd0db40be..a5f1c09e6 100644 --- a/src/Report/Clover.php +++ b/src/Report/Clover.php @@ -9,31 +9,41 @@ */ 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\Node\File; -use SebastianBergmann\CodeCoverage\RuntimeException; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; -/** - * Generates a Clover XML logfile from a code coverage object. - */ final class Clover { /** - * @throws \RuntimeException + * @throws WriteOperationFailedException */ public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { - $xmlDocument = new \DOMDocument('1.0', 'UTF-8'); + $time = (string) time(); + + $xmlDocument = new DOMDocument('1.0', 'UTF-8'); $xmlDocument->formatOutput = true; $xmlCoverage = $xmlDocument->createElement('coverage'); - $xmlCoverage->setAttribute('generated', (string) $_SERVER['REQUEST_TIME']); + $xmlCoverage->setAttribute('generated', $time); $xmlDocument->appendChild($xmlCoverage); $xmlProject = $xmlDocument->createElement('project'); - $xmlProject->setAttribute('timestamp', (string) $_SERVER['REQUEST_TIME']); + $xmlProject->setAttribute('timestamp', $time); - if (\is_string($name)) { + if (is_string($name)) { $xmlProject->setAttribute('name', $name); } @@ -50,10 +60,10 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string /* @var File $item */ $xmlFile = $xmlDocument->createElement('file'); - $xmlFile->setAttribute('name', $item->getPath()); + $xmlFile->setAttribute('name', $item->pathAsString()); - $classes = $item->getClassesAndTraits(); - $coverageData = $item->getCoverageData(); + $classes = $item->classesAndTraits(); + $coverageData = $item->lineCoverageData(); $lines = []; $namespace = 'global'; @@ -63,85 +73,60 @@ 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'); $xmlMetrics->setAttribute('complexity', (string) $class['ccn']); $xmlMetrics->setAttribute('methods', (string) $classMethods); $xmlMetrics->setAttribute('coveredmethods', (string) $coveredMethods); - $xmlMetrics->setAttribute('conditionals', '0'); - $xmlMetrics->setAttribute('coveredconditionals', '0'); + $xmlMetrics->setAttribute('conditionals', (string) $class['executableBranches']); + $xmlMetrics->setAttribute('coveredconditionals', (string) $class['executedBranches']); $xmlMetrics->setAttribute('statements', (string) $classStatements); $xmlMetrics->setAttribute('coveredstatements', (string) $coveredClassStatements); - $xmlMetrics->setAttribute('elements', (string) ($classMethods + $classStatements /* + conditionals */)); - $xmlMetrics->setAttribute('coveredelements', (string) ($coveredMethods + $coveredClassStatements /* + coveredconditionals */)); + $xmlMetrics->setAttribute('elements', (string) ($classMethods + $classStatements + $class['executableBranches'])); + $xmlMetrics->setAttribute('coveredelements', (string) ($coveredMethods + $coveredClassStatements + $class['executedBranches'])); $xmlClass->appendChild($xmlMetrics); } @@ -151,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'); @@ -182,20 +167,20 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $xmlFile->appendChild($xmlLine); } - $linesOfCode = $item->getLinesOfCode(); + $linesOfCode = $item->linesOfCode(); $xmlMetrics = $xmlDocument->createElement('metrics'); - $xmlMetrics->setAttribute('loc', (string) $linesOfCode['loc']); - $xmlMetrics->setAttribute('ncloc', (string) $linesOfCode['ncloc']); - $xmlMetrics->setAttribute('classes', (string) $item->getNumClassesAndTraits()); - $xmlMetrics->setAttribute('methods', (string) $item->getNumMethods()); - $xmlMetrics->setAttribute('coveredmethods', (string) $item->getNumTestedMethods()); - $xmlMetrics->setAttribute('conditionals', '0'); - $xmlMetrics->setAttribute('coveredconditionals', '0'); - $xmlMetrics->setAttribute('statements', (string) $item->getNumExecutableLines()); - $xmlMetrics->setAttribute('coveredstatements', (string) $item->getNumExecutedLines()); - $xmlMetrics->setAttribute('elements', (string) ($item->getNumMethods() + $item->getNumExecutableLines() /* + conditionals */)); - $xmlMetrics->setAttribute('coveredelements', (string) ($item->getNumTestedMethods() + $item->getNumExecutedLines() /* + coveredconditionals */)); + $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()); + $xmlMetrics->setAttribute('conditionals', (string) $item->numberOfExecutableBranches()); + $xmlMetrics->setAttribute('coveredconditionals', (string) $item->numberOfExecutedBranches()); + $xmlMetrics->setAttribute('statements', (string) $item->numberOfExecutableLines()); + $xmlMetrics->setAttribute('coveredstatements', (string) $item->numberOfExecutedLines()); + $xmlMetrics->setAttribute('elements', (string) ($item->numberOfMethods() + $item->numberOfExecutableLines() + $item->numberOfExecutableBranches())); + $xmlMetrics->setAttribute('coveredelements', (string) ($item->numberOfTestedMethods() + $item->numberOfExecutedLines() + $item->numberOfExecutedBranches())); $xmlFile->appendChild($xmlMetrics); if ($namespace === 'global') { @@ -203,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); @@ -214,45 +199,35 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string } } - $linesOfCode = $report->getLinesOfCode(); + $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('classes', (string) $report->getNumClassesAndTraits()); - $xmlMetrics->setAttribute('methods', (string) $report->getNumMethods()); - $xmlMetrics->setAttribute('coveredmethods', (string) $report->getNumTestedMethods()); - $xmlMetrics->setAttribute('conditionals', '0'); - $xmlMetrics->setAttribute('coveredconditionals', '0'); - $xmlMetrics->setAttribute('statements', (string) $report->getNumExecutableLines()); - $xmlMetrics->setAttribute('coveredstatements', (string) $report->getNumExecutedLines()); - $xmlMetrics->setAttribute('elements', (string) ($report->getNumMethods() + $report->getNumExecutableLines() /* + conditionals */)); - $xmlMetrics->setAttribute('coveredelements', (string) ($report->getNumTestedMethods() + $report->getNumExecutedLines() /* + coveredconditionals */)); + $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()); + $xmlMetrics->setAttribute('conditionals', (string) $report->numberOfExecutableBranches()); + $xmlMetrics->setAttribute('coveredconditionals', (string) $report->numberOfExecutedBranches()); + $xmlMetrics->setAttribute('statements', (string) $report->numberOfExecutableLines()); + $xmlMetrics->setAttribute('coveredstatements', (string) $report->numberOfExecutedLines()); + $xmlMetrics->setAttribute('elements', (string) ($report->numberOfMethods() + $report->numberOfExecutableLines() + $report->numberOfExecutableBranches())); + $xmlMetrics->setAttribute('coveredelements', (string) ($report->numberOfTestedMethods() + $report->numberOfExecutedLines() + $report->numberOfExecutedBranches())); $xmlProject->appendChild($xmlMetrics); $buffer = $xmlDocument->saveXML(); if ($target !== null) { - if (!$this->createDirectory(\dirname($target))) { - throw new \RuntimeException(\sprintf('Directory "%s" was not created', \dirname($target))); + if (!str_contains($target, '://')) { + Filesystem::createDirectory(dirname($target)); } - if (@\file_put_contents($target, $buffer) === false) { - throw new RuntimeException( - \sprintf( - 'Could not write to "%s', - $target - ) - ); + if (@file_put_contents($target, $buffer) === false) { + throw new WriteOperationFailedException($target); } } return $buffer; } - - private function createDirectory(string $directory): bool - { - return !(!\is_dir($directory) && !@\mkdir($directory, 0777, true) && !\is_dir($directory)); - } } 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 53dc1ba9d..57fabfce5 100644 --- a/src/Report/Crap4j.php +++ b/src/Report/Crap4j.php @@ -9,16 +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\Node\File; -use SebastianBergmann\CodeCoverage\RuntimeException; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; -final class Crap4j +final readonly class Crap4j { - /** - * @var int - */ - private $threshold; + private int $threshold; public function __construct(int $threshold = 30) { @@ -26,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', $_SERVER['REQUEST_TIME']))); + $root->appendChild($document->createElement('timestamp', date('Y-m-d H:i:s'))); $stats = $document->createElement('stats'); $methodsNode = $document->createElement('methods'); @@ -59,15 +65,15 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string } $file = $document->createElement('file'); - $file->setAttribute('name', $item->getPath()); + $file->setAttribute('name', $item->pathAsString()); - $classes = $item->getClassesAndTraits(); + $classes = $item->classesAndTraits(); foreach ($classes as $className => $class) { foreach ($class['methods'] as $methodName => $method) { - $crapLoad = $this->getCrapLoad((float) $method['crap'], $method['ccn'], $method['coverage']); + $crapLoad = $this->crapLoad((float) $method['crap'], $method['ccn'], $method['coverage']); - $fullCrap += $method['crap']; + $fullCrap += $method['crap']; $fullCrapLoad += $crapLoad; $fullMethodCount++; @@ -77,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); } @@ -99,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; @@ -116,24 +122,19 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $buffer = $document->saveXML(); if ($target !== null) { - if (!$this->createDirectory(\dirname($target))) { - throw new \RuntimeException(\sprintf('Directory "%s" was not created', \dirname($target))); + if (!str_contains($target, '://')) { + Filesystem::createDirectory(dirname($target)); } - if (@\file_put_contents($target, $buffer) === false) { - throw new RuntimeException( - \sprintf( - 'Could not write to "%s', - $target - ) - ); + if (@file_put_contents($target, $buffer) === false) { + throw new WriteOperationFailedException($target); } } return $buffer; } - private function getCrapLoad(float $crapValue, int $cyclomaticComplexity, float $coveragePercent): float + private function crapLoad(float $crapValue, int $cyclomaticComplexity, float $coveragePercent): float { $crapLoad = 0; @@ -147,11 +148,6 @@ private function getCrapLoad(float $crapValue, int $cyclomaticComplexity, float private function roundValue(float $value): float { - return \round($value, 2); - } - - private function createDirectory(string $directory): bool - { - return !(!\is_dir($directory) && !@\mkdir($directory, 0777, true) && !\is_dir($directory)); + 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 71e3fb5fb..0e8b230aa 100644 --- a/src/Report/Html/Facade.php +++ b/src/Report/Html/Facade.php @@ -9,159 +9,145 @@ */ 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\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; -/** - * Generates an HTML report from a code coverage object. - */ -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 \InvalidArgumentException - * @throws \RuntimeException - */ public function process(CodeCoverage $coverage, string $target): void { - $target = $this->getDirectory($target); + $target = $this->directory($target); $report = $coverage->getReport(); - - if (!isset($_SERVER['REQUEST_TIME'])) { - $_SERVER['REQUEST_TIME'] = \time(); - } - - $date = \date('D M j G:i:s T Y', $_SERVER['REQUEST_TIME']); + $date = date('D M j G:i:s T Y'); $dashboard = new Dashboard( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound + $this->thresholds, + $coverage->collectsBranchAndPathCoverage(), ); $directory = new Directory( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound + $this->thresholds, + $coverage->collectsBranchAndPathCoverage(), ); $file = new File( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound + $this->thresholds, + $coverage->collectsBranchAndPathCoverage(), ); $directory->render($report, $target . 'index.html'); $dashboard->render($report, $target . 'dashboard.html'); foreach ($report as $node) { - $id = $node->getId(); + $id = $node->id(); if ($node instanceof DirectoryNode) { - if (!$this->createDirectory($target . $id)) { - throw new \RuntimeException(\sprintf('Directory "%s" was not created', $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); - if (!$this->createDirectory($dir)) { - throw new \RuntimeException(\sprintf('Directory "%s" was not created', $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->getDirectory($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'); - - $dir = $this->getDirectory($target . '_icons'); - \copy($this->templatePath . 'icons/file-code.svg', $dir . 'file-code.svg'); - \copy($this->templatePath . 'icons/file-directory.svg', $dir . 'file-directory.svg'); - - $dir = $this->getDirectory($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'); + $dir = $this->directory($target . '_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'); + + $dir = $this->directory($target . '_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'); } - /** - * @throws RuntimeException - */ - private function getDirectory(string $directory): string + private function renderCss(string $target): void { - if (\substr($directory, -1, 1) != \DIRECTORY_SEPARATOR) { - $directory .= \DIRECTORY_SEPARATOR; - } + $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(), + ], + ); - if (!$this->createDirectory($directory)) { - throw new RuntimeException( - \sprintf( - 'Directory "%s" does not exist.', - $directory - ) + try { + $template->renderTo($this->directory($target . '_css') . 'style.css'); + // @codeCoverageIgnoreStart + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, ); + // @codeCoverageIgnoreEnd } - - return $directory; } - private function createDirectory(string $directory): bool + private function directory(string $directory): string { - return !(!\is_dir($directory) && !@\mkdir($directory, 0777, true) && !\is_dir($directory)); + if (!str_ends_with($directory, DIRECTORY_SEPARATOR)) { + $directory .= DIRECTORY_SEPARATOR; + } + + Filesystem::createDirectory($directory); + + return $directory; } } diff --git a/src/Report/Html/Renderer.php b/src/Report/Html/Renderer.php index 1d99c8899..49a03e5cf 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -9,70 +9,56 @@ */ 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; /** - * Base class for node renderers. + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ abstract class Renderer { - /** - * @var string - */ - protected $templatePath; - - /** - * @var string - */ - protected $generator; - - /** - * @var string - */ - protected $date; - - /** - * @var int - */ - protected $lowUpperBound; - - /** - * @var int - */ - protected $highLowerBound; - - /** - * @var string - */ - protected $version; - - public function __construct(string $templatePath, string $generator, string $date, int $lowUpperBound, int $highLowerBound) + 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->version = Version::id(); + $this->templatePath = $templatePath; + $this->generator = $generator; + $this->date = $date; + $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->getColorLevel($data['testedClassesPercent']); + $classesLevel = $this->colorLevel($data['testedClassesPercent']); $classesNumber = $data['numTestedClasses'] . $numSeparator . $data['numClasses']; - $classesBar = $this->getCoverageBar( - $data['testedClassesPercent'] + $classesBar = $this->coverageBar( + $data['testedClassesPercent'], ); } else { $classesLevel = ''; @@ -82,13 +68,13 @@ protected function renderItemTemplate(Template $template, array $data): string } if ($data['numMethods'] > 0) { - $methodsLevel = $this->getColorLevel($data['testedMethodsPercent']); + $methodsLevel = $this->colorLevel($data['testedMethodsPercent']); $methodsNumber = $data['numTestedMethods'] . $numSeparator . $data['numMethods']; - $methodsBar = $this->getCoverageBar( - $data['testedMethodsPercent'] + $methodsBar = $this->coverageBar( + $data['testedMethodsPercent'], ); } else { $methodsLevel = ''; @@ -98,13 +84,13 @@ protected function renderItemTemplate(Template $template, array $data): string } if ($data['numExecutableLines'] > 0) { - $linesLevel = $this->getColorLevel($data['linesExecutedPercent']); + $linesLevel = $this->colorLevel($data['linesExecutedPercent']); $linesNumber = $data['numExecutedLines'] . $numSeparator . $data['numExecutableLines']; - $linesBar = $this->getCoverageBar( - $data['linesExecutedPercent'] + $linesBar = $this->coverageBar( + $data['linesExecutedPercent'], ); } else { $linesLevel = ''; @@ -113,24 +99,64 @@ protected function renderItemTemplate(Template $template, array $data): string $data['linesExecutedPercentAsString'] = 'n/a'; } + if ($data['numExecutablePaths'] > 0) { + $pathsLevel = $this->colorLevel($data['pathsExecutedPercent']); + + $pathsNumber = $data['numExecutedPaths'] . $numSeparator . + $data['numExecutablePaths']; + + $pathsBar = $this->coverageBar( + $data['pathsExecutedPercent'], + ); + } else { + $pathsLevel = ''; + $pathsNumber = '0' . $numSeparator . '0'; + $pathsBar = ''; + $data['pathsExecutedPercentAsString'] = 'n/a'; + } + + if ($data['numExecutableBranches'] > 0) { + $branchesLevel = $this->colorLevel($data['branchesExecutedPercent']); + + $branchesNumber = $data['numExecutedBranches'] . $numSeparator . + $data['numExecutableBranches']; + + $branchesBar = $this->coverageBar( + $data['branchesExecutedPercent'], + ); + } else { + $branchesLevel = ''; + $branchesNumber = '0' . $numSeparator . '0'; + $branchesBar = ''; + $data['branchesExecutedPercentAsString'] = 'n/a'; + } + $template->setVar( [ - 'icon' => $data['icon'] ?? '', - 'crap' => $data['crap'] ?? '', - 'name' => $data['name'], - 'lines_bar' => $linesBar, - 'lines_executed_percent' => $data['linesExecutedPercentAsString'], - 'lines_level' => $linesLevel, - 'lines_number' => $linesNumber, - 'methods_bar' => $methodsBar, - 'methods_tested_percent' => $data['testedMethodsPercentAsString'], - 'methods_level' => $methodsLevel, - 'methods_number' => $methodsNumber, - 'classes_bar' => $classesBar, - 'classes_tested_percent' => $data['testedClassesPercentAsString'] ?? '', - 'classes_level' => $classesLevel, - 'classes_number' => $classesNumber, - ] + 'icon' => $data['icon'] ?? '', + 'crap' => $data['crap'] ?? '', + 'name' => $data['name'], + 'lines_bar' => $linesBar, + 'lines_executed_percent' => $data['linesExecutedPercentAsString'], + 'lines_level' => $linesLevel, + 'lines_number' => $linesNumber, + 'paths_bar' => $pathsBar, + 'paths_executed_percent' => $data['pathsExecutedPercentAsString'], + 'paths_level' => $pathsLevel, + 'paths_number' => $pathsNumber, + 'branches_bar' => $branchesBar, + 'branches_executed_percent' => $data['branchesExecutedPercentAsString'], + 'branches_level' => $branchesLevel, + 'branches_number' => $branchesNumber, + 'methods_bar' => $methodsBar, + 'methods_tested_percent' => $data['testedMethodsPercentAsString'], + 'methods_level' => $methodsLevel, + 'methods_number' => $methodsNumber, + 'classes_bar' => $classesBar, + 'classes_tested_percent' => $data['testedClassesPercentAsString'] ?? '', + 'classes_level' => $classesLevel, + 'classes_number' => $classesNumber, + ], ); return $template->render(); @@ -140,54 +166,54 @@ protected function setCommonTemplateVariables(Template $template, AbstractNode $ { $template->setVar( [ - 'id' => $node->getId(), - 'full_path' => $node->getPath(), - 'path_to_root' => $this->getPathToRoot($node), - 'breadcrumbs' => $this->getBreadcrumbs($node), + 'id' => $node->id(), + 'full_path' => $node->pathAsString(), + 'path_to_root' => $this->pathToRoot($node), + 'breadcrumbs' => $this->breadcrumbs($node), 'date' => $this->date, 'version' => $this->version, - 'runtime' => $this->getRuntimeString(), + '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(), + ], ); } - protected function getBreadcrumbs(AbstractNode $node): string + protected function breadcrumbs(AbstractNode $node): string { $breadcrumbs = ''; - $path = $node->getPathAsArray(); + $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->getInactiveBreadcrumb( + $breadcrumbs .= $this->inactiveBreadcrumb( $step, - \array_pop($pathToRoot) + array_pop($pathToRoot), ); } else { - $breadcrumbs .= $this->getActiveBreadcrumb($step); + $breadcrumbs .= $this->activeBreadcrumb($step); } } return $breadcrumbs; } - protected function getActiveBreadcrumb(AbstractNode $node): string + protected function activeBreadcrumb(AbstractNode $node): string { - $buffer = \sprintf( + $buffer = sprintf( ' ' . "\n", - $node->getName() + $node->name(), ); if ($node instanceof DirectoryNode) { @@ -197,84 +223,67 @@ protected function getActiveBreadcrumb(AbstractNode $node): string return $buffer; } - protected function getInactiveBreadcrumb(AbstractNode $node, string $pathToRoot): string + protected function inactiveBreadcrumb(AbstractNode $node, string $pathToRoot): string { - return \sprintf( + return sprintf( ' ' . "\n", $pathToRoot, - $node->getName() + $node->name(), ); } - protected function getPathToRoot(AbstractNode $node): string + protected function pathToRoot(AbstractNode $node): string { - $id = $node->getId(); - $depth = \substr_count($id, '/'); + $id = $node->id(); + $depth = substr_count($id, '/'); if ($id !== 'index' && $node instanceof DirectoryNode) { $depth++; } - return \str_repeat('../', $depth); + return str_repeat('../', $depth); } - protected function getCoverageBar(float $percent): string + protected function coverageBar(float $percent): string { - $level = $this->getColorLevel($percent); + $level = $this->colorLevel($percent); - $template = new Template( - $this->templatePath . 'coverage_bar.html', + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'coverage_bar_branch.html' : 'coverage_bar.html'); + $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 getColorLevel(float $percent): string + 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'; } return 'success'; } - private function getRuntimeString(): string + 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 d3430dcd9..305c7fa10 100644 --- a/src/Report/Html/Renderer/Dashboard.php +++ b/src/Report/Html/Renderer/Dashboard.php @@ -9,31 +9,45 @@ */ 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; /** - * Renders the dashboard for a directory node. + * @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 \InvalidArgumentException - * @throws \RuntimeException - */ public function render(DirectoryNode $node, string $file): void { - $classes = $node->getClassesAndTraits(); - $template = new Template( - $this->templatePath . 'dashboard.html', + $classes = $node->classesAndTraits(); + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'dashboard_branch.html' : 'dashboard.html'); + $template = new Template( + $templateName, '{{', - '}}' + '}}', ); $this->setCommonTemplateVariables($template, $node); - $baseLink = $node->getId() . '/'; + $baseLink = $node->id() . '/'; $complexity = $this->complexity($classes, $baseLink); $coverageDistribution = $this->coverageDistribution($classes); $insufficientCoverage = $this->insufficientCoverage($classes, $baseLink); @@ -49,16 +63,35 @@ 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( + ' ' . "\n" . + ' ' . "\n", + $node->name(), + ); } /** - * Returns the data for the Class/Method Complexity charts. + * @param array $classes + * + * @return array{class: non-empty-string, method: non-empty-string} */ - protected function complexity(array $classes, string $baseLink): array + private function complexity(array $classes, string $baseLink): array { $result = ['class' => [], 'method' => []]; @@ -71,35 +104,41 @@ protected 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} */ - protected function coverageDistribution(array $classes): array + private function coverageDistribution(array $classes): array { $result = [ 'class' => [ @@ -139,7 +178,7 @@ protected 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]++; } @@ -150,22 +189,29 @@ protected 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} */ - protected function insufficientCoverage(array $classes, string $baseLink): array + private function insufficientCoverage(array $classes, string $baseLink): array { $leastTestedClasses = []; $leastTestedMethods = []; @@ -173,7 +219,7 @@ protected 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 !== '*') { @@ -184,32 +230,32 @@ protected 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, ); } @@ -217,9 +263,11 @@ protected 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} */ - protected function projectRisks(array $classes, string $baseLink): array + private function projectRisks(array $classes, string $baseLink): array { $classRisks = []; $methodRisks = []; @@ -227,56 +275,57 @@ protected 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'], ); } return $result; } - - protected function getActiveBreadcrumb(AbstractNode $node): string - { - return \sprintf( - ' ' . "\n" . - ' ' . "\n", - $node->getName() - ); - } } diff --git a/src/Report/Html/Renderer/Directory.php b/src/Report/Html/Renderer/Directory.php index c35e04c15..1d7334b3a 100644 --- a/src/Report/Html/Renderer/Directory.php +++ b/src/Report/Html/Renderer/Directory.php @@ -9,91 +9,115 @@ */ 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; /** - * Renders a directory node. + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ final class Directory extends Renderer { - /** - * @throws \InvalidArgumentException - * @throws \RuntimeException - */ public function render(DirectoryNode $node, string $file): void { - $template = new Template($this->templatePath . 'directory.html', '{{', '}}'); + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'directory_branch.html' : 'directory.html'); + $template = new Template($templateName, '{{', '}}'); $this->setCommonTemplateVariables($template, $node); $items = $this->renderItem($node, true); - foreach ($node->getDirectories() as $item) { + foreach ($node->directories() as $item) { $items .= $this->renderItem($item); } - foreach ($node->getFiles() as $item) { + foreach ($node->files() as $item) { $items .= $this->renderItem($item); } $template->setVar( [ - 'id' => $node->getId(), + 'id' => $node->id(), 'items' => $items, - ] + ], ); - $template->renderTo($file); + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } } - protected function renderItem(Node $node, bool $total = false): string + private function renderItem(Node $node, bool $total = false): string { $data = [ - 'numClasses' => $node->getNumClassesAndTraits(), - 'numTestedClasses' => $node->getNumTestedClassesAndTraits(), - 'numMethods' => $node->getNumFunctionsAndMethods(), - 'numTestedMethods' => $node->getNumTestedFunctionsAndMethods(), - 'linesExecutedPercent' => $node->getLineExecutedPercent(false), - 'linesExecutedPercentAsString' => $node->getLineExecutedPercent(), - 'numExecutedLines' => $node->getNumExecutedLines(), - 'numExecutableLines' => $node->getNumExecutableLines(), - 'testedMethodsPercent' => $node->getTestedFunctionsAndMethodsPercent(false), - 'testedMethodsPercentAsString' => $node->getTestedFunctionsAndMethodsPercent(), - 'testedClassesPercent' => $node->getTestedClassesAndTraitsPercent(false), - 'testedClassesPercentAsString' => $node->getTestedClassesAndTraitsPercent(), + 'numClasses' => $node->numberOfClassesAndTraits(), + 'numTestedClasses' => $node->numberOfTestedClassesAndTraits(), + 'numMethods' => $node->numberOfFunctionsAndMethods(), + 'numTestedMethods' => $node->numberOfTestedFunctionsAndMethods(), + 'linesExecutedPercent' => $node->percentageOfExecutedLines()->asFloat(), + 'linesExecutedPercentAsString' => $node->percentageOfExecutedLines()->asString(), + 'numExecutedLines' => $node->numberOfExecutedLines(), + 'numExecutableLines' => $node->numberOfExecutableLines(), + 'branchesExecutedPercent' => $node->percentageOfExecutedBranches()->asFloat(), + 'branchesExecutedPercentAsString' => $node->percentageOfExecutedBranches()->asString(), + 'numExecutedBranches' => $node->numberOfExecutedBranches(), + 'numExecutableBranches' => $node->numberOfExecutableBranches(), + 'pathsExecutedPercent' => $node->percentageOfExecutedPaths()->asFloat(), + 'pathsExecutedPercentAsString' => $node->percentageOfExecutedPaths()->asString(), + 'numExecutedPaths' => $node->numberOfExecutedPaths(), + 'numExecutablePaths' => $node->numberOfExecutablePaths(), + 'testedMethodsPercent' => $node->percentageOfTestedFunctionsAndMethods()->asFloat(), + 'testedMethodsPercentAsString' => $node->percentageOfTestedFunctionsAndMethods()->asString(), + 'testedClassesPercent' => $node->percentageOfTestedClassesAndTraits()->asFloat(), + 'testedClassesPercentAsString' => $node->percentageOfTestedClassesAndTraits()->asString(), ]; 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->getName(), - $node->getName() + $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->getPathAsArray()) - 2); - - $data['icon'] = \sprintf('', $up); } else { - $data['name'] = \sprintf( + $data['name'] = sprintf( '%s', - $node->getName(), - $node->getName() + $node->name(), + $node->name(), ); - - $up = \str_repeat('../', \count($node->getPathAsArray()) - 2); - - $data['icon'] = \sprintf('', $up); } } + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'directory_item_branch.html' : 'directory_item.html'); + return $this->renderItemTemplate( - new Template($this->templatePath . 'directory_item.html', '{{', '}}'), - $data + new Template($templateName, '{{', '}}'), + $data, ); } } diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index ed0e61eba..09dbe31fe 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -9,94 +9,330 @@ */ 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\Util; +use SebastianBergmann\CodeCoverage\Util\Percentage; +use SebastianBergmann\Template\Exception; use SebastianBergmann\Template\Template; /** - * Renders a file node. + * @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 { - $template = new Template($this->templatePath . 'file.html', '{{', '}}'); + $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), + ], + ); - $template->renderTo($file); + try { + $template->renderTo($file . '_branch.html'); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } + + $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, + ); + } + } } - protected function renderItems(FileNode $node): string + private function renderItems(FileNode $node): string { - $template = new Template($this->templatePath . 'file_item.html', '{{', '}}'); + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_item_branch.html' : 'file_item.html'); + $template = new Template($templateName, '{{', '}}'); + $methodTemplateName = $this->templatePath . ($this->hasBranchCoverage ? 'method_item_branch.html' : 'method_item.html'); $methodItemTemplate = new Template( - $this->templatePath . 'method_item.html', + $methodTemplateName, '{{', - '}}' + '}}', ); $items = $this->renderItemTemplate( $template, [ - 'name' => 'Total', - 'numClasses' => $node->getNumClassesAndTraits(), - 'numTestedClasses' => $node->getNumTestedClassesAndTraits(), - 'numMethods' => $node->getNumFunctionsAndMethods(), - 'numTestedMethods' => $node->getNumTestedFunctionsAndMethods(), - 'linesExecutedPercent' => $node->getLineExecutedPercent(false), - 'linesExecutedPercentAsString' => $node->getLineExecutedPercent(), - 'numExecutedLines' => $node->getNumExecutedLines(), - 'numExecutableLines' => $node->getNumExecutableLines(), - 'testedMethodsPercent' => $node->getTestedFunctionsAndMethodsPercent(false), - 'testedMethodsPercentAsString' => $node->getTestedFunctionsAndMethodsPercent(), - 'testedClassesPercent' => $node->getTestedClassesAndTraitsPercent(false), - 'testedClassesPercentAsString' => $node->getTestedClassesAndTraitsPercent(), - 'crap' => 'CRAP', - ] + 'name' => 'Total', + 'numClasses' => $node->numberOfClassesAndTraits(), + 'numTestedClasses' => $node->numberOfTestedClassesAndTraits(), + 'numMethods' => $node->numberOfFunctionsAndMethods(), + 'numTestedMethods' => $node->numberOfTestedFunctionsAndMethods(), + 'linesExecutedPercent' => $node->percentageOfExecutedLines()->asFloat(), + 'linesExecutedPercentAsString' => $node->percentageOfExecutedLines()->asString(), + 'numExecutedLines' => $node->numberOfExecutedLines(), + 'numExecutableLines' => $node->numberOfExecutableLines(), + 'branchesExecutedPercent' => $node->percentageOfExecutedBranches()->asFloat(), + 'branchesExecutedPercentAsString' => $node->percentageOfExecutedBranches()->asString(), + 'numExecutedBranches' => $node->numberOfExecutedBranches(), + 'numExecutableBranches' => $node->numberOfExecutableBranches(), + 'pathsExecutedPercent' => $node->percentageOfExecutedPaths()->asFloat(), + 'pathsExecutedPercentAsString' => $node->percentageOfExecutedPaths()->asString(), + 'numExecutedPaths' => $node->numberOfExecutedPaths(), + 'numExecutablePaths' => $node->numberOfExecutablePaths(), + 'testedMethodsPercent' => $node->percentageOfTestedFunctionsAndMethods()->asFloat(), + 'testedMethodsPercentAsString' => $node->percentageOfTestedFunctionsAndMethods()->asString(), + 'testedClassesPercent' => $node->percentageOfTestedClassesAndTraits()->asFloat(), + 'testedClassesPercentAsString' => $node->percentageOfTestedClassesAndTraits()->asString(), + 'crap' => 'CRAP', + ], ); $items .= $this->renderFunctionItems( - $node->getFunctions(), - $methodItemTemplate + $node->functions(), + $methodItemTemplate, ); $items .= $this->renderTraitOrClassItems( - $node->getTraits(), + $node->traits(), $template, - $methodItemTemplate + $methodItemTemplate, ); $items .= $this->renderTraitOrClassItems( - $node->getClasses(), + $node->classes(), $template, - $methodItemTemplate + $methodItemTemplate, ); return $items; } - protected function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string + /** + * @param array $items + */ + private function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string { $buffer = ''; - if (empty($items)) { + if ($items === []) { return $buffer; } @@ -116,61 +352,79 @@ protected function renderTraitOrClassItems(array $items, Template $template, Tem if ($item['executableLines'] > 0) { $numClasses = 1; - $numTestedClasses = $numTestedMethods == $numMethods ? 1 : 0; - $linesExecutedPercentAsString = Util::percent( + $numTestedClasses = $numTestedMethods === $numMethods ? 1 : 0; + $linesExecutedPercentAsString = Percentage::fromFractionAndTotal( $item['executedLines'], $item['executableLines'], - true - ); + )->asString(); + $branchesExecutedPercentAsString = Percentage::fromFractionAndTotal( + $item['executedBranches'], + $item['executableBranches'], + )->asString(); + $pathsExecutedPercentAsString = Percentage::fromFractionAndTotal( + $item['executedPaths'], + $item['executablePaths'], + )->asString(); } else { - $numClasses = 'n/a'; - $numTestedClasses = 'n/a'; - $linesExecutedPercentAsString = 'n/a'; + $numClasses = 0; + $numTestedClasses = 0; + $linesExecutedPercentAsString = 'n/a'; + $branchesExecutedPercentAsString = 'n/a'; + $pathsExecutedPercentAsString = 'n/a'; } + $testedMethodsPercentage = Percentage::fromFractionAndTotal( + $numTestedMethods, + $numMethods, + ); + + $testedClassesPercentage = Percentage::fromFractionAndTotal( + $numTestedMethods === $numMethods ? 1 : 0, + 1, + ); + $buffer .= $this->renderItemTemplate( $template, [ - 'name' => $this->abbreviateClassName($name), - 'numClasses' => $numClasses, - 'numTestedClasses' => $numTestedClasses, - 'numMethods' => $numMethods, - 'numTestedMethods' => $numTestedMethods, - 'linesExecutedPercent' => Util::percent( + 'name' => $this->abbreviateClassName($name), + 'numClasses' => $numClasses, + 'numTestedClasses' => $numTestedClasses, + 'numMethods' => $numMethods, + 'numTestedMethods' => $numTestedMethods, + 'linesExecutedPercent' => Percentage::fromFractionAndTotal( $item['executedLines'], $item['executableLines'], - false - ), + )->asFloat(), 'linesExecutedPercentAsString' => $linesExecutedPercentAsString, 'numExecutedLines' => $item['executedLines'], 'numExecutableLines' => $item['executableLines'], - 'testedMethodsPercent' => Util::percent( - $numTestedMethods, - $numMethods - ), - 'testedMethodsPercentAsString' => Util::percent( - $numTestedMethods, - $numMethods, - true - ), - 'testedClassesPercent' => Util::percent( - $numTestedMethods == $numMethods ? 1 : 0, - 1 - ), - 'testedClassesPercentAsString' => Util::percent( - $numTestedMethods == $numMethods ? 1 : 0, - 1, - true - ), + 'branchesExecutedPercent' => Percentage::fromFractionAndTotal( + $item['executedBranches'], + $item['executableBranches'], + )->asFloat(), + 'branchesExecutedPercentAsString' => $branchesExecutedPercentAsString, + 'numExecutedBranches' => $item['executedBranches'], + 'numExecutableBranches' => $item['executableBranches'], + 'pathsExecutedPercent' => Percentage::fromFractionAndTotal( + $item['executedPaths'], + $item['executablePaths'], + )->asFloat(), + 'pathsExecutedPercentAsString' => $pathsExecutedPercentAsString, + 'numExecutedPaths' => $item['executedPaths'], + 'numExecutablePaths' => $item['executablePaths'], + 'testedMethodsPercent' => $testedMethodsPercentage->asFloat(), + 'testedMethodsPercentAsString' => $testedMethodsPercentage->asString(), + 'testedClassesPercent' => $testedClassesPercentage->asFloat(), + 'testedClassesPercentAsString' => $testedClassesPercentage->asString(), 'crap' => $item['crap'], - ] + ], ); foreach ($item['methods'] as $method) { $buffer .= $this->renderFunctionOrMethodItem( $methodItemTemplate, $method, - ' ' + ' ', ); } } @@ -178,9 +432,12 @@ protected function renderTraitOrClassItems(array $items, Template $template, Tem return $buffer; } - protected function renderFunctionItems(array $functions, Template $template): string + /** + * @param array $functions + */ + private function renderFunctionItems(array $functions, Template $template): string { - if (empty($functions)) { + if ($functions === []) { return ''; } @@ -189,14 +446,17 @@ protected function renderFunctionItems(array $functions, Template $template): st foreach ($functions as $function) { $buffer .= $this->renderFunctionOrMethodItem( $template, - $function + $function, ); } return $buffer; } - protected function renderFunctionOrMethodItem(Template $template, array $item, string $indent = ''): string + /** + * @param ProcessedFunctionType|ProcessedMethodType $item + */ + private function renderFunctionOrMethodItem(Template $template, array $item, string $indent = ''): string { $numMethods = 0; $numTestedMethods = 0; @@ -209,48 +469,65 @@ protected function renderFunctionOrMethodItem(Template $template, array $item, s } } + $executedLinesPercentage = Percentage::fromFractionAndTotal( + $item['executedLines'], + $item['executableLines'], + ); + + $executedBranchesPercentage = Percentage::fromFractionAndTotal( + $item['executedBranches'], + $item['executableBranches'], + ); + + $executedPathsPercentage = Percentage::fromFractionAndTotal( + $item['executedPaths'], + $item['executablePaths'], + ); + + $testedMethodsPercentage = Percentage::fromFractionAndTotal( + $numTestedMethods, + 1, + ); + return $this->renderItemTemplate( $template, [ - 'name' => \sprintf( + 'name' => sprintf( '%s%s', $indent, $item['startLine'], - \htmlspecialchars($item['signature'], $this->htmlSpecialCharsFlags), - $item['functionName'] ?? $item['methodName'] - ), - 'numMethods' => $numMethods, - 'numTestedMethods' => $numTestedMethods, - 'linesExecutedPercent' => Util::percent( - $item['executedLines'], - $item['executableLines'] - ), - 'linesExecutedPercentAsString' => Util::percent( - $item['executedLines'], - $item['executableLines'], - true - ), - 'numExecutedLines' => $item['executedLines'], - 'numExecutableLines' => $item['executableLines'], - 'testedMethodsPercent' => Util::percent( - $numTestedMethods, - 1 + htmlspecialchars($item['signature'], self::HTML_SPECIAL_CHARS_FLAGS), + $item['functionName'] ?? $item['methodName'], ), - 'testedMethodsPercentAsString' => Util::percent( - $numTestedMethods, - 1, - true - ), - 'crap' => $item['crap'], - ] + 'numMethods' => $numMethods, + 'numTestedMethods' => $numTestedMethods, + 'linesExecutedPercent' => $executedLinesPercentage->asFloat(), + 'linesExecutedPercentAsString' => $executedLinesPercentage->asString(), + 'numExecutedLines' => $item['executedLines'], + 'numExecutableLines' => $item['executableLines'], + 'branchesExecutedPercent' => $executedBranchesPercentage->asFloat(), + 'branchesExecutedPercentAsString' => $executedBranchesPercentage->asString(), + 'numExecutedBranches' => $item['executedBranches'], + 'numExecutableBranches' => $item['executableBranches'], + 'pathsExecutedPercent' => $executedPathsPercentage->asFloat(), + 'pathsExecutedPercentAsString' => $executedPathsPercentage->asString(), + 'numExecutedPaths' => $item['executedPaths'], + 'numExecutablePaths' => $item['executablePaths'], + 'testedMethodsPercent' => $testedMethodsPercentage->asFloat(), + 'testedMethodsPercentAsString' => $testedMethodsPercentage->asString(), + 'crap' => $item['crap'], + ], ); } - protected function renderSource(FileNode $node): string + private function renderSourceWithLineCoverage(FileNode $node): string { - $coverageData = $node->getCoverageData(); - $testData = $node->getTestData(); - $codeLines = $this->loadFile($node->getPath()); + $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); + $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); + + $coverageData = $node->lineCoverageData(); + $testData = $node->testData(); + $codeLines = $this->loadFile($node->pathAsString()); $lines = ''; $i = 1; @@ -259,136 +536,502 @@ protected 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]); - break; + 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 = ''; - case 'medium': - $testCSS = ' class="covered-by-medium-tests"'; + if ($lineData[$i]['includedInBranches'] > 0) { + $lineCss = 'success'; - break; + if ($lineData[$i]['includedInHitBranches'] === 0) { + $lineCss = 'danger'; + } elseif ($lineData[$i]['includedInHitBranches'] !== $lineData[$i]['includedInBranches']) { + $lineCss = 'warning'; + } - default: - $testCSS = ' class="covered-by-large-tests"'; + $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.'; - break; + foreach ($lineData[$i]['tests'] as $test) { + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); + } - case 1: - case 2: - $testCSS = ' class="warning"'; + $popoverContent .= '
    '; + $trClass = $lineCss . ' popin'; - break; + $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), + ); + } - case 3: - $testCSS = ' class="danger"'; + $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover); - break; + $i++; + } + + $linesTemplate->setVar(['lines' => $lines]); + + return $linesTemplate->render(); + } + + private function renderSourceWithPathCoverage(FileNode $node): string + { + $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); + $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); - case 4: - $testCSS = ' class="danger"'; + $functionCoverageData = $node->functionCoverageData(); + $testData = $node->testData(); + $codeLines = $this->loadFile($node->pathAsString()); - break; + $lineData = []; - default: - $testCSS = ''; + foreach (array_keys($codeLines) as $line) { + $lineData[$line + 1] = [ + 'includedInPaths' => [], + 'includedInHitPaths' => [], + 'tests' => [], + ]; + } + + foreach ($functionCoverageData as $method) { + foreach ($method['paths'] as $pathId => $path) { + foreach ($path['path'] as $branchTaken) { + foreach (range($method['branches'][$branchTaken]['line_start'], $method['branches'][$branchTaken]['line_end']) as $line) { + if (!isset($lineData[$line])) { + continue; } + $lineData[$line]['includedInPaths'][] = $pathId; - $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 = '