diff --git a/.gitattributes b/.gitattributes index 461090b7e..e389d472f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,14 @@ +/.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 new file mode 100644 index 000000000..fedcef3bc --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,106 @@ +# 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 new file mode 100644 index 000000000..d40ffea35 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +github: sebastianbergmann +liberapay: sebastianbergmann +thanks_dev: u/gh/sebastianbergmann +tidelift: "packagist/phpunit/php-code-coverage" diff --git a/.github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..128d7d3f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md @@ -0,0 +1,25 @@ +--- +name: Generic issue +about: Use this template for all issues. +--- + +| Q | A +| --------------------------| --------------- +| php-code-coverage version | x.y.z +| PHP version | x.y.z +| 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 +| PHPUnit version (if used) | x.y.z + + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..ffd3929fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,177 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +on: + - pull_request + - push + +name: CI + +env: + COMPOSER_ROOT_VERSION: 12.4.x-dev + PHP_VERSION: 8.4 + +jobs: + coding-guidelines: + name: Coding Guidelines + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Use local branch + shell: bash + run: | + BRANCH=$([ "${{ github.event_name }}" == "pull_request" ] && echo "${{ github.head_ref }}" || echo "${{ github.ref_name }}") + git branch -D $BRANCH 2>/dev/null || true + git branch $BRANCH HEAD + git checkout $BRANCH + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: none, iconv, json, phar, tokenizer + coverage: none + tools: none + + - name: Run PHP-CS-Fixer + run: ./tools/php-cs-fixer fix --dry-run --show-progress=dots --using-cache=no --verbose + + static-analysis: + name: Static Analysis + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Use local branch + shell: bash + run: | + BRANCH=$([ "${{ github.event_name }}" == "pull_request" ] && echo "${{ github.head_ref }}" || echo "${{ github.ref_name }}") + git branch -D $BRANCH 2>/dev/null || true + git branch $BRANCH HEAD + git checkout $BRANCH + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: none, ctype, curl, dom, iconv, mbstring, opcache, simplexml, tokenizer, xml, xmlwriter + coverage: none + tools: none + + - name: Get Composer cache directory + id: composer-cache + shell: bash + run: | + echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer cache directory + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Update dependencies with composer + run: ./tools/composer update --no-interaction --no-ansi --no-progress + + - name: Run PHPStan + run: ./tools/phpstan analyse --no-progress --error-format=github + + tests: + name: Tests + + runs-on: ${{ matrix.os }} + + env: + PHP_EXTENSIONS: none, ctype, curl, dom, json, libxml, mbstring, openssl, pdo_sqlite, soap, tokenizer, xml, xmlwriter + PHP_INI_VALUES: memory_limit=-1, assert.exception=1, zend.assertions=1, error_reporting=-1, log_errors_max_len=0, display_errors=On + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + + php-version: + - 8.3 + - 8.4 + - 8.5 + + coverage-driver: + - pcov + - xdebug3 + + steps: + - name: Configure Git to avoid issues with line endings + if: matrix.os == 'windows-latest' + run: git config --global core.autocrlf false + + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Use local branch + shell: bash + run: | + BRANCH=$([ "${{ github.event_name }}" == "pull_request" ] && echo "${{ github.head_ref }}" || echo "${{ github.ref_name }}") + git branch -D $BRANCH 2>/dev/null || true + git branch $BRANCH HEAD + git checkout $BRANCH + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: ${{ matrix.coverage-driver }} + extensions: ${{ env.PHP_EXTENSIONS }} + ini-values: ${{ env.PHP_INI_VALUES }} + tools: none + + - name: Get Composer cache directory + id: composer-cache + shell: bash + run: | + echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer cache directory + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies with Composer + run: php ./tools/composer update --no-ansi --no-interaction --no-progress + + - name: Run tests with PHPUnit + run: vendor/bin/phpunit --log-junit test-results.xml --coverage-clover=code-coverage.xml + + - name: Upload test results to Codecov.io + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + disable_search: true + files: ./test-results.xml + + - name: Upload code coverage data to Codecov.io + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + disable_search: true + files: ./code-coverage.xml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..11111a6b7 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,43 @@ +# https://docs.github.com/en/actions + +on: + push: + tags: + - "**" + +name: Release + +jobs: + release: + name: Release + + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + extensions: none + tools: none + + - name: Determine tag + run: echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Parse ChangeLog + run: build/scripts/extract-release-notes.php ${{ env.RELEASE_TAG }} > release-notes.md + + - name: Create release + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ env.RELEASE_TAG }} + name: phpunit/php-code-coverage ${{ env.RELEASE_TAG }} + bodyFile: release-notes.md diff --git a/.gitignore b/.gitignore index 603bc9e86..5344939da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -/tests/_files/tmp -/vendor -/composer.lock /.idea -/.php_cs.cache - +/.php-cs-fixer.php +/.php-cs-fixer.cache +/.phpunit.cache +/composer.lock +/vendor diff --git a/.phive/phars.xml b/.phive/phars.xml new file mode 100644 index 000000000..07724ca0b --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 000000000..79e9f7edb --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,369 @@ + + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF; + +$finder = PhpCsFixer\Finder::create() + ->files() + ->in(__DIR__ . '/src') + ->in(__DIR__ . '/tests/src') + ->in(__DIR__ . '/tests/tests') +; + +$config = new PhpCsFixer\Config; +$config->setFinder($finder) + ->setUnsupportedPhpVersionAllowed(true) + ->setRiskyAllowed(true) + ->setRules([ + 'align_multiline_comment' => true, + 'array_indentation' => true, + 'array_push' => true, + 'array_syntax' => ['syntax' => 'short'], + 'attribute_empty_parentheses' => [ + 'use_parentheses' => false, + ], + 'backtick_to_shell_exec' => true, + 'binary_operator_spaces' => [ + 'operators' => [ + '*=' => 'align_single_space_minimal', + '+=' => 'align_single_space_minimal', + '-=' => 'align_single_space_minimal', + '/=' => 'align_single_space_minimal', + '=' => 'align_single_space_minimal', + '=>' => 'align_single_space_minimal', + ], + ], + 'blank_line_after_namespace' => true, + 'blank_line_before_statement' => [ + 'statements' => [ + 'break', + 'case', + 'continue', + 'declare', + 'default', + 'do', + 'exit', + 'for', + 'foreach', + 'goto', + 'if', + 'include', + 'include_once', + 'phpdoc', + 'require', + 'require_once', + 'return', + 'switch', + 'throw', + 'try', + 'while', + 'yield', + 'yield_from', + ], + ], + 'blank_lines_before_namespace' => [ + 'max_line_breaks' => 1, + 'min_line_breaks' => 0, + ], + 'braces_position' => [ + 'anonymous_classes_opening_brace' => 'next_line_unless_newline_at_signature_end', + 'anonymous_functions_opening_brace' => 'next_line_unless_newline_at_signature_end', + ], + 'cast_spaces' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'const' => 'none', + 'method' => 'one', + 'property' => 'only_if_meta' + ] + ], + 'class_definition' => true, + 'clean_namespace' => true, + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'combine_nested_dirname' => true, + 'compact_nullable_type_declaration' => true, + 'concat_space' => ['spacing' => 'one'], + 'constant_case' => true, + 'control_structure_braces' => true, + 'control_structure_continuation_position' => true, + 'declare_equal_normalize' => ['space' => 'none'], + 'declare_parentheses' => true, + 'declare_strict_types' => true, + 'dir_constant' => true, + 'echo_tag_syntax' => true, + 'elseif' => true, + 'encoding' => true, + 'ereg_to_preg' => true, + 'explicit_indirect_variable' => true, + 'explicit_string_variable' => true, + 'fopen_flag_order' => true, + 'full_opening_tag' => true, + 'fully_qualified_strict_types' => ['import_symbols' => true], + 'function_declaration' => true, + 'function_to_constant' => true, + 'get_class_to_class_keyword' => true, + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], + 'header_comment' => ['header' => $header, 'separate' => 'none'], + 'heredoc_to_nowdoc' => true, + 'implode_call' => true, + 'include' => true, + 'increment_style' => [ + 'style' => 'post', + ], + 'indentation_type' => true, + 'integer_literal_case' => true, + 'is_null' => true, + 'lambda_not_used_import' => true, + 'line_ending' => true, + 'list_syntax' => ['syntax' => 'short'], + 'logical_operators' => true, + 'lowercase_cast' => true, + 'lowercase_keywords' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'magic_method_casing' => true, + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + ], + 'method_chaining_indentation' => true, + 'modernize_strpos' => true, + 'modernize_types_casting' => true, + 'multiline_comment_opening_closing' => true, + 'multiline_whitespace_before_semicolons' => true, + 'native_constant_invocation' => true, + 'native_function_casing' => false, + 'native_function_invocation' => [ + 'include' => [ + '@internal', + ], + ], + 'native_type_declaration_casing' => true, + 'new_with_parentheses' => [ + 'anonymous_class' => false, + 'named_class' => false, + ], + 'no_alias_functions' => true, + 'no_alias_language_construct_call' => true, + 'no_alternative_syntax' => true, + 'no_binary_string' => true, + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_break_comment' => true, + 'no_closing_tag' => true, + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'attribute', + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use', + ], + ], + 'no_homoglyph_names' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_mixed_echo_print' => ['use' => 'print'], + 'no_multiline_whitespace_around_double_arrow' => true, + 'no_multiple_statements_per_line' => true, + 'no_null_property_initialization' => true, + 'no_php4_constructor' => true, + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_space_around_double_colon' => true, + 'no_spaces_after_function_name' => true, + 'no_spaces_around_offset' => true, + 'no_superfluous_elseif' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => true, + ], + 'no_trailing_comma_in_singleline' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'no_trailing_whitespace_in_string' => true, + 'no_unneeded_braces' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unneeded_final_method' => true, + 'no_unneeded_import_alias' => true, + 'no_unreachable_default_argument_value' => true, + 'no_unset_cast' => true, + 'no_unset_on_property' => true, + 'no_unused_imports' => true, + 'no_useless_concat_operator' => true, + 'no_useless_else' => true, + 'no_useless_nullsafe_operator' => true, + 'no_useless_return' => true, + 'no_useless_sprintf' => true, + 'no_whitespace_before_comma_in_array' => true, + 'no_whitespace_in_blank_line' => true, + 'non_printable_character' => true, + 'normalize_index_brace' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'object_operator_without_whitespace' => true, + 'octal_notation' => true, + 'operator_linebreak' => [ + 'only_booleans' => true, + 'position' => 'end', + ], + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public_static', + 'property_protected_static', + 'property_private_static', + 'property_public', + 'property_protected', + 'property_private', + 'method_public_static', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public', + 'method_protected', + 'method_private', + 'method_protected_static', + 'method_private_static', + ], + ], + 'ordered_imports' => [ + 'imports_order' => [ + 'const', + 'function', + 'class', + ] + ], + 'ordered_interfaces' => [ + 'direction' => 'ascend', + 'order' => 'alpha', + ], + 'ordered_traits' => true, + 'ordered_types' => true, + 'php_unit_set_up_tear_down_visibility' => true, + 'php_unit_test_case_static_method_calls' => [ + 'call_type' => 'this', + ], + 'phpdoc_add_missing_param_annotation' => false, + 'phpdoc_align' => true, + 'phpdoc_annotation_without_dot' => true, + 'phpdoc_indent' => true, + 'phpdoc_inline_tag_normalizer' => true, + 'phpdoc_no_access' => true, + 'phpdoc_no_alias_tag' => true, + 'phpdoc_no_empty_return' => true, + 'phpdoc_no_package' => true, + 'phpdoc_no_useless_inheritdoc' => true, + 'phpdoc_order' => true, + 'phpdoc_order_by_value' => [ + 'annotations' => [ + 'covers', + 'dataProvider', + 'throws', + 'uses', + ], + ], + 'phpdoc_param_order' => true, + 'phpdoc_return_self_reference' => true, + 'phpdoc_scalar' => true, + 'phpdoc_separation' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_summary' => true, + 'phpdoc_tag_casing' => true, + 'phpdoc_tag_type' => true, + 'phpdoc_to_comment' => false, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_types' => ['groups' => ['simple', 'meta']], + 'phpdoc_types_order' => true, + 'phpdoc_var_annotation_correct_order' => true, + 'phpdoc_var_without_name' => true, + 'pow_to_exponentiation' => true, + 'protected_to_private' => true, + 'return_assignment' => true, + 'return_type_declaration' => ['space_before' => 'none'], + 'self_accessor' => true, + 'self_static_accessor' => true, + 'semicolon_after_instruction' => true, + 'set_type_to_cast' => true, + 'short_scalar_cast' => true, + 'simple_to_complex_string_variable' => true, + 'simplified_null_return' => false, + 'single_blank_line_at_eof' => true, + 'single_class_element_per_statement' => true, + 'single_import_per_statement' => true, + 'single_line_after_imports' => true, + 'single_line_comment_spacing' => true, + 'single_quote' => true, + 'single_space_around_construct' => true, + 'single_trait_insert_per_statement' => true, + 'space_after_semicolon' => true, + 'spaces_inside_parentheses' => [ + 'space' => 'none', + ], + 'standardize_increment' => true, + 'standardize_not_equals' => true, + 'statement_indentation' => true, + 'static_lambda' => true, + 'strict_param' => true, + 'string_length_to_empty'=> true, + 'string_line_ending' => true, + 'switch_case_semicolon_to_colon' => true, + 'switch_case_space' => true, + 'switch_continue_to_break' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_elvis_operator' => true, + 'ternary_to_null_coalescing' => true, + 'trailing_comma_in_multiline' => [ + 'elements' => [ + 'arguments', + 'arrays', + 'match', + ] + ], + 'trim_array_spaces' => true, + 'type_declaration_spaces' => [ + 'elements' => [ + 'function', + ], + ], + 'types_spaces' => true, + 'unary_operator_spaces' => true, + 'modifier_keywords' => [ + 'elements' => [ + 'const', + 'method', + 'property', + ], + ], + 'void_return' => true, + 'whitespace_after_comma_in_array' => true, + ]); + +$config->setCacheFile(__DIR__ . '/.php-cs-fixer.cache/' . json_decode((string) @file_get_contents('composer.json'), true)["extra"]["branch-alias"]["dev-main"] ?? 'unknown'); + +$config->setParallelConfig(\PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()); + +return $config; diff --git a/.php_cs b/.php_cs deleted file mode 100644 index de5cde180..000000000 --- a/.php_cs +++ /dev/null @@ -1,69 +0,0 @@ -files() - ->in('src') - ->in('tests') - ->exclude('_files') - ->name('*.php'); - -return Symfony\CS\Config\Config::create() - ->setUsingCache(true) - ->level(\Symfony\CS\FixerInterface::NONE_LEVEL) - ->fixers( - array( - 'align_double_arrow', - 'align_equals', - 'braces', - 'concat_with_spaces', - 'duplicate_semicolon', - 'elseif', - 'empty_return', - 'encoding', - 'eof_ending', - 'extra_empty_lines', - 'function_call_space', - 'function_declaration', - 'indentation', - 'join_function', - 'line_after_namespace', - 'linefeed', - 'list_commas', - 'lowercase_constants', - 'lowercase_keywords', - 'method_argument_space', - 'multiple_use', - 'namespace_no_leading_whitespace', - 'no_blank_lines_after_class_opening', - 'no_empty_lines_after_phpdocs', - 'parenthesis', - 'php_closing_tag', - 'phpdoc_indent', - 'phpdoc_no_access', - 'phpdoc_no_empty_return', - 'phpdoc_no_package', - 'phpdoc_params', - 'phpdoc_scalar', - 'phpdoc_separation', - 'phpdoc_to_comment', - 'phpdoc_trim', - 'phpdoc_types', - 'phpdoc_var_without_name', - 'remove_lines_between_uses', - 'return', - 'self_accessor', - 'short_array_syntax', - 'short_tag', - 'single_line_after_imports', - 'single_quote', - 'spaces_before_semicolon', - 'spaces_cast', - 'ternary_spaces', - 'trailing_spaces', - 'trim_array_spaces', - 'unused_use', - 'visibility', - 'whitespacy_lines' - ) - ) - ->finder($finder); - diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7e607c82f..000000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: php - -php: - - 7.0 - - 7.1 - - nightly - -env: - matrix: - - DRIVER="xdebug" - - DRIVER="phpdbg" - -sudo: false - -before_install: - - composer self-update - - composer clear-cache - -install: - - travis_retry composer update --no-interaction --no-ansi --no-progress --no-suggest --optimize-autoloader --prefer-stable - -script: - - if [[ "$DRIVER" = 'phpdbg' ]]; then phpdbg -qrr vendor/bin/phpunit; fi - - if [[ "$DRIVER" = 'xdebug' ]]; then vendor/bin/phpunit; fi - -notifications: - email: false - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 76a434585..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to [https://github.com/sebastianbergmann/phpunit/blob/master/CONTRIBUTING.md](https://github.com/sebastianbergmann/phpunit/blob/master/CONTRIBUTING.md) for details on how to contribute to this project. diff --git a/ChangeLog-12.4.md b/ChangeLog-12.4.md new file mode 100644 index 000000000..8ff3a6e1e --- /dev/null +++ b/ChangeLog-12.4.md @@ -0,0 +1,11 @@ +# ChangeLog + +All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. + +## [12.4.0] - 2025-09-24 + +### Added + +* [#1095](https://github.com/sebastianbergmann/php-code-coverage/pull/1095): Support for light/dark mode in HTML report + +[12.4.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.8...main diff --git a/ChangeLog-2.2.md b/ChangeLog-2.2.md deleted file mode 100644 index 353b6f650..000000000 --- a/ChangeLog-2.2.md +++ /dev/null @@ -1,56 +0,0 @@ -# Changes in PHP_CodeCoverage 2.2 - -All notable changes of the PHP_CodeCoverage 2.2 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [2.2.4] - 2015-10-06 - -### Fixed - -* Fixed [#391](https://github.com/sebastianbergmann/php-code-coverage/pull/391): Missing `` tag - -## [2.2.3] - 2015-09-14 - -### Fixed - -* Fixed [#368](https://github.com/sebastianbergmann/php-code-coverage/pull/368): Blacklists and whitelists are not merged when merging data sets -* Fixed [#370](https://github.com/sebastianbergmann/php-code-coverage/issues/370): Confusing statistics for source file that declares a class without methods -* Fixed [#372](https://github.com/sebastianbergmann/php-code-coverage/pull/372): Nested classes and functions are not handled correctly -* Fixed [#382](https://github.com/sebastianbergmann/php-code-coverage/issues/382): Crap4J report generates incorrect XML logfile - -## [2.2.2] - 2015-08-04 - -### Added - -* Reintroduced the `PHP_CodeCoverage_Driver_HHVM` driver as an extension of `PHP_CodeCoverage_Driver_Xdebug` that does not use `xdebug_start_code_coverage()` with options not supported by HHVM - -### Changed - -* Bumped required version of `sebastian/environment` to 1.3.2 for [#365](https://github.com/sebastianbergmann/php-code-coverage/issues/365) - -## [2.2.1] - 2015-08-02 - -### Changed - -* Bumped required version of `sebastian/environment` to 1.3.1 for [#365](https://github.com/sebastianbergmann/php-code-coverage/issues/365) - -## [2.2.0] - 2015-08-01 - -### Added - -* Added a driver for PHPDBG (requires PHP 7) -* Added `PHP_CodeCoverage::setDisableIgnoredLines()` to disable the ignoring of lines using annotations such as `@codeCoverageIgnore` - -### Changed - -* Annotating a method with `@deprecated` now has the same effect as annotating it with `@codeCoverageIgnore` - -### Removed - -* The dedicated driver for HHVM, `PHP_CodeCoverage_Driver_HHVM` has been removed - -[2.2.4]: https://github.com/sebastianbergmann/php-code-coverage/compare/2.2.3...2.2.4 -[2.2.3]: https://github.com/sebastianbergmann/php-code-coverage/compare/2.2.2...2.2.3 -[2.2.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/2.2.1...2.2.2 -[2.2.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/2.2.0...2.2.1 -[2.2.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/2.1...2.2.0 - diff --git a/ChangeLog-3.0.md b/ChangeLog-3.0.md deleted file mode 100644 index a39fa8d47..000000000 --- a/ChangeLog-3.0.md +++ /dev/null @@ -1,31 +0,0 @@ -# Changes in PHP_CodeCoverage 3.0 - -All notable changes of the PHP_CodeCoverage 3.0 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [3.0.2] - 2015-11-12 - -### Changed - -* It is now optional that `@deprecated` code is ignored - -## [3.0.1] - 2015-10-06 - -### Fixed - -* Fixed [#391](https://github.com/sebastianbergmann/php-code-coverage/pull/391): Missing `` tag - -## [3.0.0] - 2015-10-02 - -### Changed - -* It is now mandatory to configure a whitelist - -### Removed - -* The blacklist functionality has been removed -* PHP_CodeCoverage is no longer supported on PHP 5.3, PHP 5.4, and PHP 5.5 - -[3.0.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.0.1...3.0.2 -[3.0.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.0.0...3.0.1 -[3.0.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/2.2...3.0.0 - diff --git a/ChangeLog-3.1.md b/ChangeLog-3.1.md deleted file mode 100644 index f7a0de904..000000000 --- a/ChangeLog-3.1.md +++ /dev/null @@ -1,30 +0,0 @@ -# Changes in PHP_CodeCoverage 3.1 - -All notable changes of the PHP_CodeCoverage 3.1 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [3.1.1] - 2016-02-04 - -### Changed - -* Allow version 2.0.x of `sebastian/version` dependency - -## [3.1.0] - 2016-01-11 - -### Added - -* Implemented [#234](https://github.com/sebastianbergmann/php-code-coverage/issues/234): Optionally raise an exception when a specified unit of code is not executed - -### Changed - -* The Clover XML report now contains cyclomatic complexity information -* The Clover XML report now contains method visibility information -* Cleanup and refactoring of various areas of code -* Added missing test cases - -### Removed - -* The functionality controlled by the `mapTestClassNameToCoveredClassName` setting has been removed - -[3.1.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.1.0...3.1.1 -[3.1.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.0...3.1.0 - diff --git a/ChangeLog-3.2.md b/ChangeLog-3.2.md deleted file mode 100644 index 34c65cf49..000000000 --- a/ChangeLog-3.2.md +++ /dev/null @@ -1,23 +0,0 @@ -# Changes in PHP_CodeCoverage 3.2 - -All notable changes of the PHP_CodeCoverage 3.2 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [3.2.1] - 2016-02-18 - -### Changed - -* Updated dependency information in `composer.json` - -## [3.2.0] - 2016-02-13 - -### Added - -* Added optional check for missing `@covers` annotation when the usage of `@covers` annotations is forced - -### Changed - -* Improved `PHP_CodeCoverage_UnintentionallyCoveredCodeException` message - -[3.2.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.2.0...3.2.1 -[3.2.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.1...3.2.0 - diff --git a/ChangeLog-3.3.md b/ChangeLog-3.3.md deleted file mode 100644 index 2cf15229e..000000000 --- a/ChangeLog-3.3.md +++ /dev/null @@ -1,33 +0,0 @@ -# Changes in PHP_CodeCoverage 3.3 - -All notable changes of the PHP_CodeCoverage 3.3 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [3.3.3] - 2016-MM-DD - -### Fixed - -* Fixed [#438](https://github.com/sebastianbergmann/php-code-coverage/issues/438): Wrong base directory for Clover reports - -## [3.3.2] - 2016-05-25 - -### Changed - -* The constructor of `PHP_CodeCoverage_Report_Text` now has default values for its parameters - -## [3.3.1] - 2016-04-08 - -### Fixed - -* Fixed handling of lines that contain `declare` statements - -## [3.3.0] - 2016-03-03 - -### Added - -* Added support for whitelisting classes for the unintentionally covered code unit check - -[3.3.3]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.3.2...3.3.3 -[3.3.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.3.1...3.3.2 -[3.3.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.3.0...3.3.1 -[3.3.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.2...3.3.0 - diff --git a/ChangeLog-4.0.md b/ChangeLog-4.0.md deleted file mode 100644 index 5a7157ec2..000000000 --- a/ChangeLog-4.0.md +++ /dev/null @@ -1,47 +0,0 @@ -# Changes in PHP_CodeCoverage 4.0 - -All notable changes of the PHP_CodeCoverage 4.0 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [4.0.5] - 2017-01-20 - -### Fixed - -* Fixed formatting of executed lines percentage for classes in file view - -## [4.0.4] - 2016-12-20 - -### Changed - -* Implemented [#432](https://github.com/sebastianbergmann/php-code-coverage/issues/432): Change how files with no executable lines are displayed in the HTML report - -## [4.0.3] - 2016-11-28 - -### Changed - -* The check for unintentionally covered code is no longer performed for `@medium` and `@large` tests - -## [4.0.2] - 2016-11-01 - -### Fixed - -* Fixed [#440](https://github.com/sebastianbergmann/php-code-coverage/pull/440): Dashboard charts not showing tooltips for data points - -## [4.0.1] - 2016-07-26 - -### Fixed - -* Fixed [#458](https://github.com/sebastianbergmann/php-code-coverage/pull/458): XML report does not know about warning status - -## [4.0.0] - 2016-06-03 - -### Changed - -* This component now uses namespaces - -[4.0.5]: https://github.com/sebastianbergmann/php-code-coverage/compare/4.0.4...4.0.5 -[4.0.4]: https://github.com/sebastianbergmann/php-code-coverage/compare/4.0.3...4.0.4 -[4.0.3]: https://github.com/sebastianbergmann/php-code-coverage/compare/4.0.2...4.0.3 -[4.0.2]: https://github.com/sebastianbergmann/php-code-coverage/compare/4.0.1...4.0.2 -[4.0.1]: https://github.com/sebastianbergmann/php-code-coverage/compare/4.0.0...4.0.1 -[4.0.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/3.3...4.0.0 - diff --git a/ChangeLog-5.0.md b/ChangeLog-5.0.md deleted file mode 100644 index 6654f6a89..000000000 --- a/ChangeLog-5.0.md +++ /dev/null @@ -1,13 +0,0 @@ -# Changes in PHP_CodeCoverage 5.0 - -All notable changes of the PHP_CodeCoverage 5.0 release series are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. - -## [5.0.0] - 2017-02-03 - -### Removed - -* This component is no longer supported on PHP 5 - - -[5.0.0]: https://github.com/sebastianbergmann/php-code-coverage/compare/4.0...5.0.0 - diff --git a/LICENSE b/LICENSE index fcfa37e80..017eb48b1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,33 +1,29 @@ -PHP_CodeCoverage +BSD 3-Clause License -Copyright (c) 2009-2015, 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 c01384b8b..f5d452724 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,54 @@ -[![Latest Stable Version](https://poser.pugx.org/phpunit/php-code-coverage/v/stable.png)](https://packagist.org/packages/phpunit/php-code-coverage) -[![Build Status](https://travis-ci.org/sebastianbergmann/php-code-coverage.svg?branch=master)](https://travis-ci.org/sebastianbergmann/php-code-coverage) +# phpunit/php-code-coverage -# PHP_CodeCoverage +[![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) +[![codecov](https://codecov.io/gh/sebastianbergmann/php-code-coverage/branch/main/graph/badge.svg)](https://codecov.io/gh/sebastianbergmann/php-code-coverage) -**PHP_CodeCoverage** is a library that provides collection, processing, and rendering functionality for PHP code coverage information. - -## Requirements - -PHP 5.6 is required but using the latest version of PHP is highly recommended. - -### PHP 5 - -[Xdebug](http://xdebug.org/) is the only source of raw code coverage data supported for PHP 5. Version 2.2.1 of Xdebug is required but using the latest version is highly recommended. - -### PHP 7 - -Version 2.4.0 (or later) of [Xdebug](http://xdebug.org/) as well as [phpdbg](http://phpdbg.com/docs) are supported sources of raw code coverage data for PHP 7. - -### HHVM - -A version of HHVM that implements the Xdebug API for code coverage (`xdebug_*_code_coverage()`) is required. +Provides collection, processing, and rendering functionality for PHP code coverage information. ## Installation You can add this library as a local, per-project dependency to your project using [Composer](https://getcomposer.org/): - composer require phpunit/php-code-coverage +``` +composer require phpunit/php-code-coverage +``` If you only need this library during development, for instance to run your project's test suite, then you should add it as a development-time dependency: - composer require --dev phpunit/php-code-coverage +``` +composer require --dev phpunit/php-code-coverage +``` -## Using the PHP_CodeCoverage API +## Usage ```php -includeFiles( + [ + '/path/to/file.php', + '/path/to/another_file.php', + ] +); + +$coverage = new CodeCoverage( + (new Selector)->forLineCoverage($filter), + $filter +); + $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 d8168c2d8..daf51c7b1 100644 --- a/build.xml +++ b/build.xml @@ -1,21 +1,59 @@ - + - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 f318c8255..744507de0 100644 --- a/composer.json +++ b/composer.json @@ -12,40 +12,56 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "irc": "irc://irc.freenode.net/phpunit" + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy" }, - "minimum-stability": "dev", + "config": { + "platform": { + "php": "8.3.0" + }, + "optimize-autoloader": true, + "sort-packages": true + }, + "prefer-stable": true, "require": { - "php": "^7.0", - "phpunit/php-file-iterator": "^1.3", - "phpunit/php-token-stream": "^1.4.2", - "phpunit/php-text-template": "^1.2", - "sebastian/code-unit-reverse-lookup": "^1.0", - "sebastian/environment": "^2.0", - "sebastian/version": "^2.0" + "php": ">=8.3", + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.6.1", + "phpunit/php-file-iterator": "^6.0", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^6.0", - "ext-xdebug": "^2.5" + "phpunit/phpunit": "^12.3.7" }, "suggest": { - "ext-dom": "*", - "ext-xmlwriter": "*" + "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": [ "src/" ] }, + "autoload-dev": { + "classmap": [ + "tests/" + ] + }, "extra": { "branch-alias": { - "dev-master": "5.0.x-dev" + "dev-main": "12.4.x-dev" } } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..4d1a69e1e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,60 @@ +parameters: + level: 5 + + paths: + - src + - tests/tests + - tests/bootstrap.php + + resultCachePath: %tmpDir%/php-code-coverage-12.3.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 d9473ec4e..c7f22893f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,28 @@ - - tests/tests - + cacheDirectory=".phpunit.cache" + executionOrder="depends,defects" + beStrictAboutOutputDuringTests="true" + displayDetailsOnPhpunitDeprecations="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true" + colors="true"> + + + tests/tests + + - - - src - - + + + src + + - diff --git a/src/CodeCoverage.php b/src/CodeCoverage.php index d2974531c..ab9936ce7 100644 --- a/src/CodeCoverage.php +++ b/src/CodeCoverage.php @@ -1,203 +1,151 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -use PHPUnit\Framework\TestCase; -use PHPUnit\Runner\PhptTestCase; +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\Xdebug; -use SebastianBergmann\CodeCoverage\Driver\HHVM; -use SebastianBergmann\CodeCoverage\Driver\PHPDBG; 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> */ -class CodeCoverage +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 mixed - */ - private $currentId; - - /** - * Code coverage data. - * - * @var array - */ - private $data = []; + private const string UNCOVERED_FILES = 'UNCOVERED_FILES'; + private readonly Driver $driver; + private readonly Filter $filter; + private ?FileAnalyser $analyser = null; + private ?Mapper $targetMapper = null; + private ?string $cacheDirectory = null; + private bool $checkForUnintentionallyCoveredCode = false; + private bool $collectBranchAndPathCoverage = false; + private bool $includeUncoveredFiles = true; + private bool $ignoreDeprecatedCode = false; + private bool $useAnnotationsForIgnoringCode = true; /** - * @var array + * @var list */ - private $ignoredLines = []; + private array $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = []; + private ?string $currentId = null; + private ?TestSize $currentSize = null; + private ProcessedCodeCoverageData $data; /** - * @var bool + * @var array */ - private $disableIgnoredLines = false; + private array $tests = []; + private ?Directory $cachedReport = null; - /** - * Test data. - * - * @var array - */ - private $tests = []; + public function __construct(Driver $driver, Filter $filter) + { + $this->driver = $driver; + $this->filter = $filter; + $this->data = new ProcessedCodeCoverageData; + } - /** - * @var string[] - */ - private $unintentionallyCoveredSubclassesWhitelist = []; + public function __serialize(): array + { + $prefix = "\x00" . self::class . "\x00"; - /** - * Determine if the data has been initialized or not - * - * @var bool - */ - private $isInitialized = false; + return [ + // Configuration + $prefix . 'cacheDirectory' => $this->cacheDirectory, + $prefix . 'checkForUnintentionallyCoveredCode' => $this->checkForUnintentionallyCoveredCode, + $prefix . 'includeUncoveredFiles' => $this->includeUncoveredFiles, + $prefix . 'ignoreDeprecatedCode' => $this->ignoreDeprecatedCode, + $prefix . 'parentClassesExcludedFromUnintentionallyCoveredCodeCheck' => $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck, + $prefix . 'useAnnotationsForIgnoringCode' => $this->useAnnotationsForIgnoringCode, + $prefix . 'filter' => $this->filter, - /** - * Determine whether we need to check for dead and unused code on each test - * - * @var bool - */ - private $shouldCheckForDeadAndUnused = true; + // Data + $prefix . 'data' => $this->data, + $prefix . 'tests' => $this->tests, + ]; + } /** - * Constructor. - * - * @param Driver $driver - * @param Filter $filter - * - * @throws RuntimeException + * Returns the code coverage information as a graph of node objects. */ - public function __construct(Driver $driver = null, Filter $filter = null) + public function getReport(): Directory { - if ($driver === null) { - $driver = $this->selectDriver(); - } - - if ($filter === null) { - $filter = new Filter; + if ($this->cachedReport === null) { + $this->cachedReport = (new Builder($this->analyser()))->build($this); } - $this->driver = $driver; - $this->filter = $filter; - - $this->wizard = new Wizard; + return $this->cachedReport; } /** - * Returns the code coverage information as a graph of node objects. - * - * @return Directory + * Clears collected code coverage data. */ - public function getReport() + public function clear(): void { - $builder = new Builder; - - return $builder->build($this); + $this->currentId = null; + $this->currentSize = null; + $this->data = new ProcessedCodeCoverageData; + $this->tests = []; + $this->cachedReport = null; } /** - * Clears collected code coverage data. + * @internal */ - public function clear() + public function clearCache(): void { - $this->isInitialized = false; - $this->currentId = null; - $this->data = []; - $this->tests = []; + $this->cachedReport = null; } /** * Returns the filter object used. - * - * @return Filter */ - public function filter() + public function filter(): Filter { return $this->filter; } /** * Returns the collected code coverage data. - * Set $raw = true to bypass all filters. - * - * @param bool $raw - * - * @return array */ - public function getData($raw = false) + public function getData(bool $raw = false): ProcessedCodeCoverageData { - if (!$raw && $this->addUncoveredFilesFromWhitelist) { - $this->addUncoveredFilesFromWhitelist(); + if (!$raw) { + if ($this->includeUncoveredFiles) { + $this->addUncoveredFilesFromFilter(); + } } return $this->data; @@ -205,782 +153,405 @@ public function getData($raw = false) /** * Sets the coverage data. - * - * @param array $data */ - public function setData(array $data) + public function setData(ProcessedCodeCoverageData $data): void { $this->data = $data; } /** - * Returns the test data. - * - * @return array + * @return array */ - public function getTests() + public function getTests(): array { return $this->tests; } /** - * Sets the test data. - * - * @param array $tests + * @param array $tests */ - public function setTests(array $tests) + public function setTests(array $tests): void { $this->tests = $tests; } - /** - * Start collection of code coverage information. - * - * @param mixed $id - * @param bool $clear - * - * @throws InvalidArgumentException - */ - public function start($id, $clear = false) + public function start(string $id, ?TestSize $size = null, bool $clear = false): void { - if (!is_bool($clear)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } - 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 bool $append - * @param mixed $linesToBeCovered - * @param array $linesToBeUsed - * - * @return array - * - * @throws InvalidArgumentException - */ - public function stop($append = true, $linesToBeCovered = [], array $linesToBeUsed = []) + public function stop(bool $append = true, ?TestStatus $status = null, null|false|TargetCollection $covers = null, ?TargetCollection $uses = null): RawCodeCoverageData { - if (!is_bool($append)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } - - if (!is_array($linesToBeCovered) && $linesToBeCovered !== false) { - throw InvalidArgumentException::create( - 2, - 'array or false' - ); - } - $data = $this->driver->stop(); - $this->append($data, null, $append, $linesToBeCovered, $linesToBeUsed); - $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 array $data - * @param mixed $id - * @param bool $append - * @param mixed $linesToBeCovered - * @param array $linesToBeUsed - * - * @throws RuntimeException + * @throws ReflectionException + * @throws TestIdMissingException + * @throws UnintentionallyCoveredCodeException */ - public function append(array $data, $id = null, $append = true, $linesToBeCovered = [], array $linesToBeUsed = []) + 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->applyListsFilter($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 - ); + if ($uses === null) { + $uses = TargetCollection::fromArray([]); } - if (empty($data)) { - return; + $size = $this->currentSize; + + if ($size === null) { + $size = TestSize::unknown(); } - $size = 'unknown'; - $status = null; + $this->cachedReport = null; - if ($id instanceof TestCase) { - $_size = $id->getSize(); + $this->applyFilter($rawData); - if ($_size == \PHPUnit\Util\Test::SMALL) { - $size = 'small'; - } elseif ($_size == \PHPUnit\Util\Test::MEDIUM) { - $size = 'medium'; - } elseif ($_size == \PHPUnit\Util\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->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(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; - } - - foreach ($lines as $line => $data) { - if ($data !== null) { - if (!isset($this->data[$file][$line])) { - $this->data[$file][$line] = $data; - } else { - $this->data[$file][$line] = array_unique( - array_merge($this->data[$file][$line], $data) - ); - } - } - } - } + $this->data->merge($that->data); $this->tests = array_merge($this->tests, $that->getTests()); + + $this->cachedReport = null; } - /** - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setCacheTokens($flag) + public function enableCheckForUnintentionallyCoveredCode(): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } - - $this->cacheTokens = $flag; + $this->checkForUnintentionallyCoveredCode = true; } - /** - * @return bool - */ - public function getCacheTokens() + public function disableCheckForUnintentionallyCoveredCode(): void { - return $this->cacheTokens; + $this->checkForUnintentionallyCoveredCode = false; } - /** - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setCheckForUnintentionallyCoveredCode($flag) + public function includeUncoveredFiles(): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } + $this->includeUncoveredFiles = true; + } - $this->checkForUnintentionallyCoveredCode = $flag; + public function excludeUncoveredFiles(): void + { + $this->includeUncoveredFiles = false; } - /** - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setForceCoversAnnotation($flag) + public function enableAnnotationsForIgnoringCode(): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } + $this->useAnnotationsForIgnoringCode = true; + } - $this->forceCoversAnnotation = $flag; + public function disableAnnotationsForIgnoringCode(): void + { + $this->useAnnotationsForIgnoringCode = false; } - /** - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setCheckForMissingCoversAnnotation($flag) + public function ignoreDeprecatedCode(): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } + $this->ignoreDeprecatedCode = true; + } - $this->checkForMissingCoversAnnotation = $flag; + public function doNotIgnoreDeprecatedCode(): void + { + $this->ignoreDeprecatedCode = false; } /** - * @param bool $flag - * - * @throws InvalidArgumentException + * @phpstan-assert-if-true !null $this->cacheDirectory */ - public function setCheckForUnexecutedCoveredCode($flag) + public function cachesStaticAnalysis(): bool { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } + return $this->cacheDirectory !== null; + } - $this->checkForUnexecutedCoveredCode = $flag; + public function cacheStaticAnalysis(string $directory): void + { + $this->cacheDirectory = $directory; } - /** - * @deprecated - * - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setMapTestClassNameToCoveredClassName($flag) + public function doNotCacheStaticAnalysis(): void { + $this->cacheDirectory = null; } /** - * @param bool $flag - * - * @throws InvalidArgumentException + * @throws StaticAnalysisCacheNotConfiguredException */ - public function setAddUncoveredFilesFromWhitelist($flag) + public function cacheDirectory(): string { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' + if (!$this->cachesStaticAnalysis()) { + throw new StaticAnalysisCacheNotConfiguredException( + 'The static analysis cache is not configured', ); } - $this->addUncoveredFilesFromWhitelist = $flag; + return $this->cacheDirectory; } /** - * @param bool $flag - * - * @throws InvalidArgumentException + * @param class-string $className */ - public function setProcessUncoveredFilesFromWhitelist($flag) + public function excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck(string $className): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } - - $this->processUncoveredFilesFromWhitelist = $flag; + $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck[] = $className; } - /** - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setDisableIgnoredLines($flag) + public function enableBranchAndPathCoverage(): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } + $this->driver->enableBranchAndPathCoverage(); - $this->disableIgnoredLines = $flag; + $this->collectBranchAndPathCoverage = true; } - /** - * @param bool $flag - * - * @throws InvalidArgumentException - */ - public function setIgnoreDeprecatedCode($flag) + public function disableBranchAndPathCoverage(): void { - if (!is_bool($flag)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } + $this->driver->disableBranchAndPathCoverage(); - $this->ignoreDeprecatedCode = $flag; + $this->collectBranchAndPathCoverage = false; } - /** - * @param array $whitelist - */ - public function setUnintentionallyCoveredSubclassesWhitelist(array $whitelist) + public function collectsBranchAndPathCoverage(): bool { - $this->unintentionallyCoveredSubclassesWhitelist = $whitelist; + return $this->collectBranchAndPathCoverage; + } + + public function validate(TargetCollection $targets): ValidationResult + { + return (new TargetCollectionValidator)->validate($this->targetMapper(), $targets); } /** - * Applies the @covers annotation filtering. - * - * @param array $data - * @param mixed $linesToBeCovered - * @param array $linesToBeUsed + * @param false|TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed * - * @throws MissingCoversAnnotationException + * @throws ReflectionException * @throws UnintentionallyCoveredCodeException */ - private function applyCoversAnnotationFilter(array &$data, $linesToBeCovered, array $linesToBeUsed) + private function applyCoversAndUsesFilter(RawCodeCoverageData $rawData, array|false $linesToBeCovered, array $linesToBeUsed, TestSize $size): void { - if ($linesToBeCovered === false || - ($this->forceCoversAnnotation && empty($linesToBeCovered))) { - if ($this->checkForMissingCoversAnnotation) { - throw new MissingCoversAnnotationException; - } - - $data = []; + if ($linesToBeCovered === false) { + $rawData->clear(); return; } - if (empty($linesToBeCovered)) { + if ($linesToBeCovered === []) { return; } - if ($this->checkForUnintentionallyCoveredCode && - (!$this->currentId instanceof TestCase || - (!$this->currentId->isMedium() && !$this->currentId->isLarge()))) { - $this->performUnintentionallyCoveredCodeCheck( - $data, - $linesToBeCovered, - $linesToBeUsed - ); - } - - if ($this->checkForUnexecutedCoveredCode) { - $this->performUnexecutedCoveredCodeCheck($data, $linesToBeCovered, $linesToBeUsed); + if ($this->checkForUnintentionallyCoveredCode && !$size->isMedium() && !$size->isLarge()) { + $this->performUnintentionallyCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed); } - $data = array_intersect_key($data, $linesToBeCovered); + $rawLineData = $rawData->lineCoverage(); + $filesWithNoCoverage = array_diff_key($rawLineData, $linesToBeCovered); - foreach (array_keys($data) as $filename) { - $_linesToBeCovered = array_flip($linesToBeCovered[$filename]); + foreach (array_keys($filesWithNoCoverage) as $fileWithNoCoverage) { + $rawData->removeCoverageDataForFile($fileWithNoCoverage); + } - $data[$filename] = array_intersect_key( - $data[$filename], - $_linesToBeCovered - ); + foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) { + $rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines); + $rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines); } } - /** - * Applies the whitelist filtering. - * - * @param array $data - */ - private function applyListsFilter(array &$data) + private function applyFilter(RawCodeCoverageData $data): void { - foreach (array_keys($data) as $filename) { - if ($this->filter->isFiltered($filename)) { - unset($data[$filename]); + if (!$this->filter->isEmpty()) { + foreach (array_keys($data->lineCoverage()) as $filename) { + if ($this->filter->isExcluded($filename)) { + $data->removeCoverageDataForFile($filename); + } } } + + $data->skipEmptyLines(); } - /** - * Applies the "ignored lines" filtering. - * - * @param array $data - */ - private function applyIgnoredLinesFilter(array &$data) + private function applyExecutableLinesFilter(RawCodeCoverageData $data): void { - foreach (array_keys($data) as $filename) { + foreach (array_keys($data->lineCoverage()) as $filename) { if (!$this->filter->isFile($filename)) { continue; } - foreach ($this->getLinesToBeIgnored($filename) as $line) { - unset($data[$filename][$line]); - } - } - } + $linesToBranchMap = $this->analyser()->analyse($filename)->executableLines(); - /** - * @param array $data - */ - private function initializeFilesThatAreSeenTheFirstTime(array $data) - { - foreach ($data as $file => $lines) { - if ($this->filter->isFile($file) && !isset($this->data[$file])) { - $this->data[$file] = []; + $data->keepLineCoverageDataOnlyForLines( + $filename, + array_keys($linesToBranchMap), + ); - foreach ($lines as $k => $v) { - $this->data[$file][$k] = $v == -2 ? null : []; - } - } + $data->markExecutableLineByBranch( + $filename, + $linesToBranchMap, + ); } } - /** - * Processes whitelisted files that are not covered. - */ - private function addUncoveredFilesFromWhitelist() + private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void { - $data = []; - $uncoveredFiles = array_diff( - $this->filter->getWhitelist(), - array_keys($this->data) - ); - - foreach ($uncoveredFiles as $uncoveredFile) { - if (!file_exists($uncoveredFile)) { + foreach (array_keys($data->lineCoverage()) as $filename) { + if (!$this->filter->isFile($filename)) { continue; } - if (!$this->processUncoveredFilesFromWhitelist) { - $data[$uncoveredFile] = []; - - $lines = count(file($uncoveredFile)); - - for ($i = 1; $i <= $lines; $i++) { - $data[$uncoveredFile][$i] = Driver::LINE_NOT_EXECUTED; - } - } + $data->removeCoverageDataForLines( + $filename, + $this->analyser()->analyse($filename)->ignoredLines(), + ); } - - $this->append($data, 'UNCOVERED_FILES_FROM_WHITELIST'); } /** - * Returns the lines of a source file that should be ignored. - * - * @param string $filename - * - * @return array - * - * @throws InvalidArgumentException + * @throws UnintentionallyCoveredCodeException */ - private function getLinesToBeIgnored($filename) + private function addUncoveredFilesFromFilter(): void { - if (!is_string($filename)) { - throw InvalidArgumentException::create( - 1, - 'string' - ); - } - - if (!isset($this->ignoredLines[$filename])) { - $this->ignoredLines[$filename] = []; - - if ($this->disableIgnoredLines) { - return $this->ignoredLines[$filename]; - } - - $ignore = false; - $stop = false; - $lines = file($filename); - $numLines = count($lines); - - 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); - } - - $classes = array_merge($tokens->getClasses(), $tokens->getTraits()); - $tokens = $tokens->tokens(); - - foreach ($tokens as $token) { - switch (get_class($token)) { - case 'PHP_Token_COMMENT': - case 'PHP_Token_DOC_COMMENT': - $_token = trim($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($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': - case 'PHP_Token_TRAIT': - case 'PHP_Token_CLASS': - case 'PHP_Token_FUNCTION': - /* @var \PHP_Token_Interface $token */ - - $docblock = $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; - } - } elseif ($token instanceof \PHP_Token_INTERFACE || - $token instanceof \PHP_Token_TRAIT || - $token instanceof \PHP_Token_CLASS) { - if (empty($classes[$token->getName()]['methods'])) { - for ($i = $token->getLine(); - $i <= $token->getEndLine(); - $i++) { - $this->ignoredLines[$filename][] = $i; - } - } else { - $firstMethod = array_shift( - $classes[$token->getName()]['methods'] - ); - - do { - $lastMethod = array_pop( - $classes[$token->getName()]['methods'] - ); - } while ($lastMethod !== null && - substr($lastMethod['signature'], 0, 18) == 'anonymous function'); - - if ($lastMethod === null) { - $lastMethod = $firstMethod; - } - - for ($i = $token->getLine(); - $i < $firstMethod['startLine']; - $i++) { - $this->ignoredLines[$filename][] = $i; - } - - for ($i = $token->getEndLine(); - $i > $lastMethod['endLine']; - $i--) { - $this->ignoredLines[$filename][] = $i; - } - } - } - break; - - case 'PHP_Token_NAMESPACE': - $this->ignoredLines[$filename][] = $token->getEndLine(); - - // Intentional fallthrough - case 'PHP_Token_DECLARE': - case 'PHP_Token_OPEN_TAG': - case 'PHP_Token_CLOSE_TAG': - case 'PHP_Token_USE': - $this->ignoredLines[$filename][] = $token->getLine(); - break; - } - - if ($ignore) { - $this->ignoredLines[$filename][] = $token->getLine(); + $uncoveredFiles = array_diff( + $this->filter->files(), + $this->data->coveredFiles(), + ); - if ($stop) { - $ignore = false; - $stop = false; - } - } + foreach ($uncoveredFiles as $uncoveredFile) { + if (is_file($uncoveredFile)) { + $this->append( + RawCodeCoverageData::fromUncoveredFile( + $uncoveredFile, + $this->analyser(), + ), + self::UNCOVERED_FILES, + ); } - - $this->ignoredLines[$filename][] = $numLines + 1; - - $this->ignoredLines[$filename] = array_unique( - $this->ignoredLines[$filename] - ); - - sort($this->ignoredLines[$filename]); } - - return $this->ignoredLines[$filename]; } /** - * @param array $data - * @param array $linesToBeCovered - * @param array $linesToBeUsed + * @param TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed * + * @throws ReflectionException * @throws UnintentionallyCoveredCodeException */ - private function performUnintentionallyCoveredCodeCheck(array &$data, array $linesToBeCovered, array $linesToBeUsed) + 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); + if ($flag === 1 && !isset($allowedLines[$file][$line])) { + $unintentionallyCoveredUnits[] = $this->targetMapper->lookup($file, $line); } } } $unintentionallyCoveredUnits = $this->processUnintentionallyCoveredUnits($unintentionallyCoveredUnits); - if (!empty($unintentionallyCoveredUnits)) { + if ($unintentionallyCoveredUnits !== []) { throw new UnintentionallyCoveredCodeException( - $unintentionallyCoveredUnits + $unintentionallyCoveredUnits, ); } } /** - * @param array $data - * @param array $linesToBeCovered - * @param array $linesToBeUsed - * - * @throws CoveredCodeNotExecutedException - */ - private function performUnexecutedCoveredCodeCheck(array &$data, array $linesToBeCovered, array $linesToBeUsed) - { - $expectedLines = $this->getAllowedLines( - $linesToBeCovered, - $linesToBeUsed - ); - - foreach ($data as $file => $_data) { - foreach (array_keys($_data) as $line) { - if (!isset($expectedLines[$file][$line])) { - continue; - } - - unset($expectedLines[$file][$line]); - } - } - - $message = ''; - - foreach ($expectedLines as $file => $lines) { - if (empty($lines)) { - continue; - } - - foreach (array_keys($lines) as $line) { - $message .= sprintf('- %s:%d' . PHP_EOL, $file, $line); - } - } - - if (!empty($message)) { - throw new CoveredCodeNotExecutedException($message); - } - } - - /** - * @param array $linesToBeCovered - * @param array $linesToBeUsed + * @param TargetedLines $linesToBeCovered + * @param TargetedLines $linesToBeUsed * - * @return array + * @return TargetedLines */ - private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed) + private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): array { $allowedLines = []; @@ -991,7 +562,7 @@ private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed) $allowedLines[$file] = array_merge( $allowedLines[$file], - $linesToBeCovered[$file] + $linesToBeCovered[$file], ); } @@ -1002,13 +573,13 @@ private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed) $allowedLines[$file] = array_merge( $allowedLines[$file], - $linesToBeUsed[$file] + $linesToBeUsed[$file], ); } foreach (array_keys($allowedLines) as $file) { $allowedLines[$file] = array_flip( - array_unique($allowedLines[$file]) + array_unique($allowedLines[$file]), ); } @@ -1016,94 +587,86 @@ private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed) } /** - * @return Driver + * @param list $unintentionallyCoveredUnits * - * @throws RuntimeException - */ - private function selectDriver() - { - $runtime = new Runtime; - - if (!$runtime->canCollectCodeCoverage()) { - throw new RuntimeException('No code coverage driver available'); - } - - if ($runtime->isHHVM()) { - return new HHVM; - } elseif ($runtime->isPHPDBG()) { - return new PHPDBG; - } else { - return new Xdebug; - } - } - - /** - * @param array $unintentionallyCoveredUnits + * @throws ReflectionException * - * @return array + * @return list */ - private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits) + private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array { $unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits); - sort($unintentionallyCoveredUnits); + $processed = []; + + foreach ($unintentionallyCoveredUnits as $unintentionallyCoveredUnit) { + $tmp = explode('::', $unintentionallyCoveredUnit); - foreach (array_keys($unintentionallyCoveredUnits) as $k => $v) { - $unit = explode('::', $unintentionallyCoveredUnits[$k]); + if (count($tmp) !== 2) { + $processed[] = $unintentionallyCoveredUnit; - if (count($unit) != 2) { continue; } - $class = new \ReflectionClass($unit[0]); + try { + $class = new ReflectionClass($tmp[0]); - foreach ($this->unintentionallyCoveredSubclassesWhitelist as $whitelisted) { - if ($class->isSubclassOf($whitelisted)) { - unset($unintentionallyCoveredUnits[$k]); - break; + foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) { + if ($class->isSubclassOf($parentClass)) { + continue 2; + } } + } catch (\ReflectionException $e) { + throw new ReflectionException( + $e->getMessage(), + $e->getCode(), + $e, + ); } + + $processed[] = $tmp[0]; } - return array_values($unintentionallyCoveredUnits); + $processed = array_unique($processed); + + sort($processed); + + return $processed; } - /** - * If we are processing uncovered files from whitelist, - * we can initialize the data before we start to speed up the tests - */ - protected function initializeData() + private function targetMapper(): Mapper { - $this->isInitialized = true; - - if ($this->processUncoveredFilesFromWhitelist) { - $this->shouldCheckForDeadAndUnused = false; + if ($this->targetMapper !== null) { + return $this->targetMapper; + } - $this->driver->start(true); + $this->targetMapper = new Mapper( + (new MapBuilder)->build($this->filter, $this->analyser()), + ); - foreach ($this->filter->getWhitelist() as $file) { - if ($this->filter->isFile($file)) { - include_once($file); - } - } + return $this->targetMapper; + } - $data = []; - $coverage = $this->driver->stop(); + private function analyser(): FileAnalyser + { + if ($this->analyser !== null) { + return $this->analyser; + } - foreach ($coverage as $file => $fileCoverage) { - if ($this->filter->isFiltered($file)) { - continue; - } + $sourceAnalyser = new ParsingSourceAnalyser; - foreach (array_keys($fileCoverage) as $key) { - if ($fileCoverage[$key] == Driver::LINE_EXECUTED) { - $fileCoverage[$key] = Driver::LINE_NOT_EXECUTED; - } - } + if ($this->cachesStaticAnalysis()) { + $sourceAnalyser = new CachingSourceAnalyser( + $this->cacheDirectory, + $sourceAnalyser, + ); + } - $data[$file] = $fileCoverage; - } + $this->analyser = new FileAnalyser( + $sourceAnalyser, + $this->useAnnotationsForIgnoringCode, + $this->ignoreDeprecatedCode, + ); - $this->append($data, 'UNCOVERED_FILES_FROM_WHITELIST'); - } + return $this->analyser; } } diff --git a/src/Data/ProcessedBranchCoverageData.php b/src/Data/ProcessedBranchCoverageData.php new file mode 100644 index 000000000..b988a2b2a --- /dev/null +++ b/src/Data/ProcessedBranchCoverageData.php @@ -0,0 +1,103 @@ + + * + * 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_merge; +use function array_unique; +use NoDiscard; +use SebastianBergmann\CodeCoverage\Driver\XdebugDriver; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type TestIdType from ProcessedCodeCoverageData + * @phpstan-import-type XdebugBranchCoverageType from XdebugDriver + */ +final class ProcessedBranchCoverageData +{ + public readonly int $op_start; + public readonly int $op_end; + public readonly int $line_start; + public readonly int $line_end; + + /** @var list */ + public array $hit; + + /** @var array */ + public readonly array $out; + + /** @var array */ + public readonly array $out_hit; + + /** + * @param XdebugBranchCoverageType $xdebugCoverageData + */ + public static function fromXdebugCoverage(array $xdebugCoverageData): self + { + return new self( + $xdebugCoverageData['op_start'], + $xdebugCoverageData['op_end'], + $xdebugCoverageData['line_start'], + $xdebugCoverageData['line_end'], + [], + $xdebugCoverageData['out'], + $xdebugCoverageData['out_hit'], + ); + } + + /** + * @param list $hit + * @param array $out + * @param array $out_hit + */ + public function __construct( + int $op_start, + int $op_end, + int $line_start, + int $line_end, + array $hit, + array $out, + array $out_hit, + ) { + $this->out_hit = $out_hit; + $this->out = $out; + $this->hit = $hit; + $this->line_end = $line_end; + $this->line_start = $line_start; + $this->op_end = $op_end; + $this->op_start = $op_start; + } + + #[NoDiscard] + public function merge(self $data): self + { + if ($data->hit === []) { + return $this; + } + + return new self( + $this->op_start, + $this->op_end, + $this->line_start, + $this->line_end, + array_unique(array_merge($this->hit, $data->hit)), + $this->out, + $this->out_hit, + ); + } + + /** + * @param TestIdType $testCaseId + */ + public function recordHit(string $testCaseId): void + { + $this->hit[] = $testCaseId; + } +} diff --git a/src/Data/ProcessedClassType.php b/src/Data/ProcessedClassType.php new file mode 100644 index 000000000..593e8de0f --- /dev/null +++ b/src/Data/ProcessedClassType.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Data; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class ProcessedClassType +{ + public readonly string $className; + public readonly string $namespace; + + /** + * @var array + */ + public array $methods; + public readonly int $startLine; + public int $executableLines; + public int $executedLines; + public int $executableBranches; + public int $executedBranches; + public int $executablePaths; + public int $executedPaths; + public int $ccn; + public float|int $coverage; + public int|string $crap; + public readonly string $link; + + public function __construct( + string $className, + string $namespace, + /** + * @var array + */ + array $methods, + int $startLine, + int $executableLines, + int $executedLines, + int $executableBranches, + int $executedBranches, + int $executablePaths, + int $executedPaths, + int $ccn, + float|int $coverage, + int|string $crap, + string $link, + ) { + $this->className = $className; + $this->namespace = $namespace; + $this->methods = $methods; + $this->startLine = $startLine; + $this->executableLines = $executableLines; + $this->executedLines = $executedLines; + $this->executableBranches = $executableBranches; + $this->executedBranches = $executedBranches; + $this->executablePaths = $executablePaths; + $this->executedPaths = $executedPaths; + $this->ccn = $ccn; + $this->coverage = $coverage; + $this->crap = $crap; + $this->link = $link; + } +} diff --git a/src/Data/ProcessedCodeCoverageData.php b/src/Data/ProcessedCodeCoverageData.php new file mode 100644 index 000000000..5745ccb08 --- /dev/null +++ b/src/Data/ProcessedCodeCoverageData.php @@ -0,0 +1,265 @@ + + * + * 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 FunctionCoverageType array> + * @phpstan-type LineCoverageType 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 LineCoverageType + */ + 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]->recordBranchHit($branchId, $testCaseId); + } + } + + foreach ($functionData['paths'] as $pathId => $pathData) { + if ($pathData['hit'] === Driver::BRANCH_HIT) { + $this->functionCoverage[$file][$functionName]->recordPathHit($pathId, $testCaseId); + } + } + } + } + } + + /** + * @param LineCoverageType $lineCoverage + */ + public function setLineCoverage(array $lineCoverage): void + { + $this->lineCoverage = $lineCoverage; + } + + /** + * @return LineCoverageType + */ + public function lineCoverage(): array + { + ksort($this->lineCoverage); + + return $this->lineCoverage; + } + + /** + * @param FunctionCoverageType $functionCoverage + */ + public function setFunctionCoverage(array $functionCoverage): void + { + $this->functionCoverage = $functionCoverage; + } + + /** + * @return FunctionCoverageType + */ + public function functionCoverage(): array + { + ksort($this->functionCoverage); + + return $this->functionCoverage; + } + + /** + * @return array + */ + 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); + } + } + } + } + + /** + * 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 ProcessedFunctionCoverageData|XdebugFunctionCoverageType $functionData + */ + private function initPreviouslyUnseenFunction(string $file, string $functionName, array|ProcessedFunctionCoverageData $functionData): void + { + if (is_array($functionData)) { + $functionData = ProcessedFunctionCoverageData::fromXdebugCoverage($functionData); + } + + $this->functionCoverage[$file][$functionName] = $functionData; + } + + /** + * 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 ProcessedFunctionCoverageData|XdebugFunctionCoverageType $functionData + */ + private function initPreviouslySeenFunction(string $file, string $functionName, array|ProcessedFunctionCoverageData $functionData): void + { + if (is_array($functionData)) { + $functionData = ProcessedFunctionCoverageData::fromXdebugCoverage($functionData); + } + + $this->functionCoverage[$file][$functionName] = $this->functionCoverage[$file][$functionName]->merge( + $functionData, + ); + } +} diff --git a/src/Data/ProcessedFunctionCoverageData.php b/src/Data/ProcessedFunctionCoverageData.php new file mode 100644 index 000000000..96baa262a --- /dev/null +++ b/src/Data/ProcessedFunctionCoverageData.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Data; + +use NoDiscard; +use SebastianBergmann\CodeCoverage\Driver\XdebugDriver; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type TestIdType from ProcessedCodeCoverageData + * @phpstan-import-type XdebugFunctionCoverageType from XdebugDriver + */ +final readonly class ProcessedFunctionCoverageData +{ + /** @var array */ + public array $branches; + + /** @var array */ + public array $paths; + + /** + * @param XdebugFunctionCoverageType $xdebugCoverageData + */ + public static function fromXdebugCoverage(array $xdebugCoverageData): self + { + $branches = []; + + foreach ($xdebugCoverageData['branches'] as $branchId => $branch) { + $branches[$branchId] = ProcessedBranchCoverageData::fromXdebugCoverage($branch); + } + $paths = []; + + foreach ($xdebugCoverageData['paths'] as $pathId => $path) { + $paths[$pathId] = ProcessedPathCoverageData::fromXdebugCoverage($path); + } + + return new self( + $branches, + $paths, + ); + } + + /** + * @param array $branches + * @param array $paths + */ + public function __construct( + array $branches, + array $paths, + ) { + $this->paths = $paths; + $this->branches = $branches; + } + + #[NoDiscard] + public function merge(self $data): self + { + $branches = null; + + if ($data->branches !== $this->branches) { + $branches = $this->branches; + + foreach ($data->branches as $branchId => $branch) { + if (!isset($branches[$branchId])) { + $branches[$branchId] = $branch; + } else { + $branches[$branchId] = $branches[$branchId]->merge($branch); + } + } + } + + $paths = null; + + if ($data->paths !== $this->paths) { + $paths = $this->paths; + + foreach ($data->paths as $pathId => $path) { + if (!isset($paths[$pathId])) { + $paths[$pathId] = $path; + } else { + $paths[$pathId] = $paths[$pathId]->merge($path); + } + } + } + + if ($branches === null && $paths === null) { + return $this; + } + + return new self( + $branches ?? $this->branches, + $paths ?? $this->paths, + ); + } + + /** + * @param TestIdType $testCaseId + */ + public function recordBranchHit(int $branchId, string $testCaseId): void + { + $this->branches[$branchId]->recordHit($testCaseId); + } + + /** + * @param TestIdType $testCaseId + */ + public function recordPathHit(int $pathId, string $testCaseId): void + { + $this->paths[$pathId]->recordHit($testCaseId); + } +} diff --git a/src/Data/ProcessedFunctionType.php b/src/Data/ProcessedFunctionType.php new file mode 100644 index 000000000..ff3a77f3c --- /dev/null +++ b/src/Data/ProcessedFunctionType.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\Data; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class ProcessedFunctionType +{ + public readonly string $functionName; + public readonly string $namespace; + public readonly string $signature; + public readonly int $startLine; + public readonly int $endLine; + public int $executableLines; + public int $executedLines; + public int $executableBranches; + public int $executedBranches; + public int $executablePaths; + public int $executedPaths; + public int $ccn; + public float|int $coverage; + public int|string $crap; + public readonly string $link; + + public function __construct( + string $functionName, + string $namespace, + string $signature, + int $startLine, + int $endLine, + int $executableLines, + int $executedLines, + int $executableBranches, + int $executedBranches, + int $executablePaths, + int $executedPaths, + int $ccn, + float|int $coverage, + int|string $crap, + string $link, + ) { + $this->link = $link; + $this->crap = $crap; + $this->coverage = $coverage; + $this->ccn = $ccn; + $this->executedPaths = $executedPaths; + $this->executablePaths = $executablePaths; + $this->executedBranches = $executedBranches; + $this->executableBranches = $executableBranches; + $this->executedLines = $executedLines; + $this->executableLines = $executableLines; + $this->endLine = $endLine; + $this->startLine = $startLine; + $this->signature = $signature; + $this->namespace = $namespace; + $this->functionName = $functionName; + } +} diff --git a/src/Data/ProcessedMethodType.php b/src/Data/ProcessedMethodType.php new file mode 100644 index 000000000..f1827633f --- /dev/null +++ b/src/Data/ProcessedMethodType.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\Data; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class ProcessedMethodType +{ + public readonly string $methodName; + public readonly string $visibility; + public readonly string $signature; + public readonly int $startLine; + public readonly int $endLine; + public int $executableLines; + public int $executedLines; + public int $executableBranches; + public int $executedBranches; + public int $executablePaths; + public int $executedPaths; + public int $ccn; + public float|int $coverage; + public int|string $crap; + public readonly string $link; + + public function __construct( + string $methodName, + string $visibility, + string $signature, + int $startLine, + int $endLine, + int $executableLines, + int $executedLines, + int $executableBranches, + int $executedBranches, + int $executablePaths, + int $executedPaths, + int $ccn, + float|int $coverage, + int|string $crap, + string $link, + ) { + $this->link = $link; + $this->crap = $crap; + $this->coverage = $coverage; + $this->ccn = $ccn; + $this->executedPaths = $executedPaths; + $this->executablePaths = $executablePaths; + $this->executedBranches = $executedBranches; + $this->executableBranches = $executableBranches; + $this->executedLines = $executedLines; + $this->executableLines = $executableLines; + $this->endLine = $endLine; + $this->startLine = $startLine; + $this->signature = $signature; + $this->visibility = $visibility; + $this->methodName = $methodName; + } +} diff --git a/src/Data/ProcessedPathCoverageData.php b/src/Data/ProcessedPathCoverageData.php new file mode 100644 index 000000000..e7283542d --- /dev/null +++ b/src/Data/ProcessedPathCoverageData.php @@ -0,0 +1,74 @@ + + * + * 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_merge; +use function array_unique; +use NoDiscard; +use SebastianBergmann\CodeCoverage\Driver\XdebugDriver; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @phpstan-import-type TestIdType from ProcessedCodeCoverageData + * @phpstan-import-type XdebugPathCoverageType from XdebugDriver + */ +final class ProcessedPathCoverageData +{ + /** @var array */ + public readonly array $path; + + /** @var list */ + public array $hit; + + /** + * @param XdebugPathCoverageType $xdebugCoverageData + */ + public static function fromXdebugCoverage(array $xdebugCoverageData): self + { + return new self( + $xdebugCoverageData['path'], + [], + ); + } + + /** + * @param array $path + * @param list $hit + */ + public function __construct( + array $path, + array $hit, + ) { + $this->hit = $hit; + $this->path = $path; + } + + #[NoDiscard] + public function merge(self $data): self + { + if ($data->hit === []) { + return $this; + } + + return new self( + $this->path, + array_unique(array_merge($this->hit, $data->hit)), + ); + } + + /** + * @param TestIdType $testCaseId + */ + public function recordHit(string $testCaseId): void + { + $this->hit[] = $testCaseId; + } +} diff --git a/src/Data/ProcessedTraitType.php b/src/Data/ProcessedTraitType.php new file mode 100644 index 000000000..86e7b4687 --- /dev/null +++ b/src/Data/ProcessedTraitType.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Data; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class ProcessedTraitType +{ + public readonly string $traitName; + public readonly string $namespace; + + /** + * @var array + */ + public array $methods; + public readonly int $startLine; + public int $executableLines; + public int $executedLines; + public int $executableBranches; + public int $executedBranches; + public int $executablePaths; + public int $executedPaths; + public int $ccn; + public float|int $coverage; + public int|string $crap; + public readonly string $link; + + public function __construct( + string $traitName, + string $namespace, + /** + * @var array + */ + array $methods, + int $startLine, + int $executableLines, + int $executedLines, + int $executableBranches, + int $executedBranches, + int $executablePaths, + int $executedPaths, + int $ccn, + float|int $coverage, + int|string $crap, + string $link, + ) { + $this->link = $link; + $this->crap = $crap; + $this->coverage = $coverage; + $this->ccn = $ccn; + $this->executedPaths = $executedPaths; + $this->executablePaths = $executablePaths; + $this->executedBranches = $executedBranches; + $this->executableBranches = $executableBranches; + $this->executedLines = $executedLines; + $this->executableLines = $executableLines; + $this->startLine = $startLine; + $this->methods = $methods; + $this->namespace = $namespace; + $this->traitName = $traitName; + } +} diff --git a/src/Data/RawCodeCoverageData.php b/src/Data/RawCodeCoverageData.php new file mode 100644 index 000000000..cbe7a9a3a --- /dev/null +++ b/src/Data/RawCodeCoverageData.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_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; + } + + 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 + */ + public 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 bdd1b9794..b839cca53 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -1,52 +1,84 @@ - * * 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 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 */ - const LINE_EXECUTED = 1; + public const int LINE_NOT_EXECUTABLE = -2; /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - const LINE_NOT_EXECUTED = -1; + public const int LINE_NOT_EXECUTED = -1; /** - * @var int - * * @see http://xdebug.org/docs/code_coverage */ - const LINE_NOT_EXECUTABLE = -2; + public const int LINE_EXECUTED = 1; /** - * Start collection of code coverage information. - * - * @param bool $determineUnusedAndDead + * @see http://xdebug.org/docs/code_coverage + */ + public const int BRANCH_NOT_HIT = 0; + + /** + * @see http://xdebug.org/docs/code_coverage */ - public function start($determineUnusedAndDead = true); + public const int BRANCH_HIT = 1; + private bool $collectBranchAndPathCoverage = false; + + public function canCollectBranchAndPathCoverage(): bool + { + return false; + } + + public function collectsBranchAndPathCoverage(): bool + { + return $this->collectBranchAndPathCoverage; + } /** - * Stop collection of code coverage information. - * - * @return array + * @throws BranchAndPathCoverageNotSupportedException */ - public function stop(); + 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/HHVM.php b/src/Driver/HHVM.php deleted file mode 100644 index b35ea81bb..000000000 --- a/src/Driver/HHVM.php +++ /dev/null @@ -1,29 +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 HHVM's code coverage functionality. - * - * @codeCoverageIgnore - */ -class HHVM extends Xdebug -{ - /** - * Start collection of code coverage information. - * - * @param bool $determineUnusedAndDead - */ - public function start($determineUnusedAndDead = true) - { - xdebug_start_code_coverage(); - } -} diff --git a/src/Driver/PHPDBG.php b/src/Driver/PHPDBG.php deleted file mode 100644 index 86cc8444c..000000000 --- a/src/Driver/PHPDBG.php +++ /dev/null @@ -1,111 +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 - */ -class PHPDBG implements Driver -{ - /** - * Constructor. - */ - 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. - * - * @param bool $determineUnusedAndDead - */ - public function start($determineUnusedAndDead = true) - { - phpdbg_start_oplog(); - } - - /** - * Stop collection of code coverage information. - * - * @return array - */ - public function stop() - { - static $fetchedLines = []; - - $dbgData = phpdbg_end_oplog(); - - if ($fetchedLines == []) { - $sourceLines = phpdbg_get_executable(); - } else { - $newFiles = array_diff( - get_included_files(), - array_keys($fetchedLines) - ); - - if ($newFiles) { - $sourceLines = phpdbg_get_executable( - ['files' => $newFiles] - ); - } else { - $sourceLines = []; - } - } - - 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 - * - * @param array $sourceLines - * @param array $dbgData - * - * @return array - */ - private function detectExecutedLines(array $sourceLines, array $dbgData) - { - 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 30099e057..000000000 --- a/src/Driver/Xdebug.php +++ /dev/null @@ -1,117 +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 Xdebug's code coverage functionality. - * - * @codeCoverageIgnore - */ -class Xdebug implements Driver -{ - /** - * Cache the number of lines for each file - * - * @var array - */ - private $cacheNumLines = []; - - /** - * Constructor. - */ - public function __construct() - { - if (!extension_loaded('xdebug')) { - throw new RuntimeException('This driver requires Xdebug'); - } - - if (version_compare(phpversion('xdebug'), '2.2.1', '>=') && - !ini_get('xdebug.coverage_enable')) { - throw new RuntimeException( - 'xdebug.coverage_enable=On has to be set in php.ini' - ); - } - } - - /** - * Start collection of code coverage information. - * - * @param bool $determineUnusedAndDead - */ - public function start($determineUnusedAndDead = true) - { - if ($determineUnusedAndDead) { - xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); - } else { - xdebug_start_code_coverage(); - } - } - - /** - * Stop collection of code coverage information. - * - * @return array - */ - public function stop() - { - $data = xdebug_get_code_coverage(); - xdebug_stop_code_coverage(); - - return $this->cleanup($data); - } - - /** - * @param array $data - * - * @return array - */ - private function cleanup(array $data) - { - foreach (array_keys($data) as $file) { - unset($data[$file][0]); - - if (strpos($file, 'xdebug://debug-eval') !== 0 && file_exists($file)) { - $numLines = $this->getNumberOfLinesInFile($file); - - foreach (array_keys($data[$file]) as $line) { - if ($line > $numLines) { - unset($data[$file][$line]); - } - } - } - } - - return $data; - } - - /** - * @param string $file - * - * @return int - */ - private function getNumberOfLinesInFile($file) - { - if (!isset($this->cacheNumLines[$file])) { - $buffer = file_get_contents($file); - $lines = substr_count($buffer, "\n"); - - if (substr($buffer, -1) !== "\n") { - $lines++; - } - - $this->cacheNumLines[$file] = $lines; - } - - return $this->cacheNumLines[$file]; - } -} 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/BranchAndPathCoverageNotSupportedException.php b/src/Exception/BranchAndPathCoverageNotSupportedException.php new file mode 100644 index 000000000..ab2089197 --- /dev/null +++ b/src/Exception/BranchAndPathCoverageNotSupportedException.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 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 a3ba4c4c6..28dc48b8a 100644 --- a/src/Exception/Exception.php +++ b/src/Exception/Exception.php @@ -1,18 +1,16 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -/** - * Exception interface for php-code-coverage component. - */ -interface Exception +use Throwable; + +interface Exception extends Throwable { } diff --git a/src/Exception/CoveredCodeNotExecutedException.php b/src/Exception/FileCouldNotBeWrittenException.php similarity index 54% rename from src/Exception/CoveredCodeNotExecutedException.php rename to src/Exception/FileCouldNotBeWrittenException.php index ca28a231b..db9cdac34 100644 --- a/src/Exception/CoveredCodeNotExecutedException.php +++ b/src/Exception/FileCouldNotBeWrittenException.php @@ -1,18 +1,16 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -/** - * Exception that is raised when covered code is not executed. - */ -class CoveredCodeNotExecutedException extends RuntimeException +use RuntimeException; + +final class FileCouldNotBeWrittenException extends RuntimeException implements Exception { } diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index 1733f6cb5..17e4b7076 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -1,37 +1,14 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -class InvalidArgumentException extends \InvalidArgumentException implements Exception +final class InvalidArgumentException extends \InvalidArgumentException implements Exception { - /** - * @param int $argument - * @param string $type - * @param mixed $value - * - * @return InvalidArgumentException - */ - public static function create($argument, $type, $value = null) - { - $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/MissingCoversAnnotationException.php b/src/Exception/ParserException.php similarity index 54% rename from src/Exception/MissingCoversAnnotationException.php rename to src/Exception/ParserException.php index 7bc5cf3e8..a907e34e8 100644 --- a/src/Exception/MissingCoversAnnotationException.php +++ b/src/Exception/ParserException.php @@ -1,18 +1,16 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -/** - * Exception that is raised when @covers must be used but is not. - */ -class MissingCoversAnnotationException extends RuntimeException +use RuntimeException; + +final class ParserException extends RuntimeException implements Exception { } diff --git a/src/Exception/PathExistsButIsNotDirectoryException.php b/src/Exception/PathExistsButIsNotDirectoryException.php new file mode 100644 index 000000000..fd6f80a70 --- /dev/null +++ b/src/Exception/PathExistsButIsNotDirectoryException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage; + +use function sprintf; +use RuntimeException; + +final class PathExistsButIsNotDirectoryException extends RuntimeException implements Exception +{ + public function __construct(string $path) + { + parent::__construct(sprintf('"%s" exists but is not a directory', $path)); + } +} diff --git a/src/Exception/PcovNotAvailableException.php b/src/Exception/PcovNotAvailableException.php new file mode 100644 index 000000000..2f0a66e5a --- /dev/null +++ b/src/Exception/PcovNotAvailableException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Driver; + +use RuntimeException; +use SebastianBergmann\CodeCoverage\Exception; + +final class PcovNotAvailableException extends RuntimeException implements Exception +{ + public function __construct() + { + parent::__construct('The PCOV extension is not available'); + } +} diff --git a/src/Exception/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 3ea542b1f..bb7d88c97 100644 --- a/src/Exception/UnintentionallyCoveredCodeException.php +++ b/src/Exception/UnintentionallyCoveredCodeException.php @@ -1,27 +1,25 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -/** - * Exception that is raised when code is unintentionally covered. - */ -class UnintentionallyCoveredCodeException extends RuntimeException +use RuntimeException; + +final class UnintentionallyCoveredCodeException extends RuntimeException implements Exception { /** - * @var array + * @var list */ - private $unintentionallyCoveredUnits = []; + private readonly array $unintentionallyCoveredUnits; /** - * @param array $unintentionallyCoveredUnits + * @param list $unintentionallyCoveredUnits */ public function __construct(array $unintentionallyCoveredUnits) { @@ -31,17 +29,14 @@ public function __construct(array $unintentionallyCoveredUnits) } /** - * @return array + * @return list */ - public function getUnintentionallyCoveredUnits() + public function getUnintentionallyCoveredUnits(): array { return $this->unintentionallyCoveredUnits; } - /** - * @return string - */ - private function toString() + private function toString(): string { $message = ''; 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 57% rename from src/Exception/RuntimeException.php rename to src/Exception/XmlException.php index c143db7da..31e4623df 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/XmlException.php @@ -1,15 +1,16 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - 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 771a657ae..f9086542b 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -1,173 +1,93 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage; -/** - * Filter for whitelisting of code coverage information. - */ -class Filter -{ - /** - * Source files that are whitelisted. - * - * @var array - */ - private $whitelistedFiles = []; - - /** - * Adds a directory to the whitelist (recursively). - * - * @param string $directory - * @param string $suffix - * @param string $prefix - */ - public function addDirectoryToWhitelist($directory, $suffix = '.php', $prefix = '') - { - $facade = new \File_Iterator_Facade; - $files = $facade->getFilesAsArray($directory, $suffix, $prefix); - - foreach ($files as $file) { - $this->addFileToWhitelist($file); - } - } +use function array_keys; +use function is_file; +use function realpath; +use function str_contains; +use function str_starts_with; +final class Filter +{ /** - * Adds a file to the whitelist. - * - * @param string $filename + * @var array */ - public function addFileToWhitelist($filename) - { - $this->whitelistedFiles[realpath($filename)] = true; - } + private array $files = []; /** - * Adds files to the whitelist. - * - * @param array $files + * @var array */ - public function addFilesToWhitelist(array $files) - { - foreach ($files as $file) { - $this->addFileToWhitelist($file); - } - } + private array $isFileCache = []; /** - * Removes a directory from the whitelist (recursively). - * - * @param string $directory - * @param string $suffix - * @param string $prefix + * @param list $filenames */ - public function removeDirectoryFromWhitelist($directory, $suffix = '.php', $prefix = '') + public function includeFiles(array $filenames): void { - $facade = new \File_Iterator_Facade; - $files = $facade->getFilesAsArray($directory, $suffix, $prefix); - - foreach ($files as $file) { - $this->removeFileFromWhitelist($file); + foreach ($filenames as $filename) { + $this->includeFile($filename); } } - /** - * Removes a file from the whitelist. - * - * @param string $filename - */ - public function removeFileFromWhitelist($filename) + public function includeFile(string $filename): void { $filename = realpath($filename); - unset($this->whitelistedFiles[$filename]); - } - - /** - * Checks whether a filename is a real filename. - * - * @param string $filename - * - * @return bool - */ - public function isFile($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) { - return false; + if (!$filename) { + return; } - return file_exists($filename); + $this->files[$filename] = true; } - /** - * Checks whether or not a file is filtered. - * - * @param string $filename - * - * @return bool - */ - public function isFiltered($filename) + public function isFile(string $filename): bool { - if (!$this->isFile($filename)) { - return true; + if (isset($this->isFileCache[$filename])) { + return $this->isFileCache[$filename]; } - $filename = realpath($filename); + if ($filename === '-' || + 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 = is_file($filename); + } - return !isset($this->whitelistedFiles[$filename]); - } + $this->isFileCache[$filename] = $isFile; - /** - * Returns the list of whitelisted files. - * - * @return array - */ - public function getWhitelist() - { - return array_keys($this->whitelistedFiles); + return $isFile; } - /** - * Returns whether this filter has a whitelist. - * - * @return bool - */ - public function hasWhitelist() + public function isExcluded(string $filename): bool { - return !empty($this->whitelistedFiles); + return !isset($this->files[$filename]) || !$this->isFile($filename); } /** - * Returns the whitelisted files. - * - * @return array + * @return list */ - public function getWhitelistedFiles() + public function files(): array { - return $this->whitelistedFiles; + return array_keys($this->files); } - /** - * Sets the whitelisted files. - * - * @param array $whitelistedFiles - */ - public function setWhitelistedFiles($whitelistedFiles) + public function isEmpty(): bool { - $this->whitelistedFiles = $whitelistedFiles; + return $this->files === []; } } diff --git a/src/Node/AbstractNode.php b/src/Node/AbstractNode.php index f3608058e..3b54d82b1 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -1,342 +1,274 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - 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\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +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 */ -abstract class AbstractNode implements \Countable +abstract class AbstractNode implements Countable { - /** - * @var string - */ - private $name; - - /** - * @var string - */ - private $path; - - /** - * @var array - */ - private $pathArray; + private readonly string $name; + private string $pathAsString; /** - * @var AbstractNode + * @var non-empty-list */ - private $parent; + private array $pathAsArray; + private readonly ?AbstractNode $parent; + private string $id; - /** - * @var string - */ - private $id; - - /** - * Constructor. - * - * @param string $name - * @param AbstractNode $parent - */ - public function __construct($name, AbstractNode $parent = null) + public function __construct(string $name, ?self $parent = null) { - if (substr($name, -1) == '/') { + if (str_ends_with($name, DIRECTORY_SEPARATOR)) { $name = substr($name, 0, -1); } $this->name = $name; $this->parent = $parent; + + $this->processId(); + $this->processPath(); } - /** - * @return string - */ - public function getName() + public function name(): string { return $this->name; } - /** - * @return string - */ - public function getId() + 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 pathAsString(): string + { + return $this->pathAsString; + } + /** - * @return string + * @return non-empty-list */ - public function getPath() + public function pathAsArray(): array { - 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() . '/' . $this->name; - } - } + return $this->pathAsArray; + } - return $this->path; + public function parent(): ?self + { + return $this->parent; } - /** - * @return array - */ - public function getPathAsArray() + public function percentageOfTestedClasses(): Percentage { - if ($this->pathArray === null) { - if ($this->parent === null) { - $this->pathArray = []; - } else { - $this->pathArray = $this->parent->getPathAsArray(); - } - - $this->pathArray[] = $this; - } + return Percentage::fromFractionAndTotal( + $this->numberOfTestedClasses(), + $this->numberOfClasses(), + ); + } - return $this->pathArray; + public function percentageOfTestedTraits(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfTestedTraits(), + $this->numberOfTraits(), + ); } - /** - * @return AbstractNode - */ - public function getParent() + public function percentageOfTestedClassesAndTraits(): Percentage { - return $this->parent; + return Percentage::fromFractionAndTotal( + $this->numberOfTestedClassesAndTraits(), + $this->numberOfClassesAndTraits(), + ); } - /** - * Returns the percentage of classes that has been tested. - * - * @param bool $asString - * - * @return int - */ - public function getTestedClassesPercent($asString = true) + public function percentageOfTestedFunctions(): Percentage { - return Util::percent( - $this->getNumTestedClasses(), - $this->getNumClasses(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedFunctions(), + $this->numberOfFunctions(), ); } - /** - * Returns the percentage of traits that has been tested. - * - * @param bool $asString - * - * @return int - */ - public function getTestedTraitsPercent($asString = true) + public function percentageOfTestedMethods(): Percentage { - return Util::percent( - $this->getNumTestedTraits(), - $this->getNumTraits(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedMethods(), + $this->numberOfMethods(), ); } - /** - * Returns the percentage of traits that has been tested. - * - * @param bool $asString - * - * @return int - */ - public function getTestedClassesAndTraitsPercent($asString = true) + public function percentageOfTestedFunctionsAndMethods(): Percentage { - return Util::percent( - $this->getNumTestedClassesAndTraits(), - $this->getNumClassesAndTraits(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfTestedFunctionsAndMethods(), + $this->numberOfFunctionsAndMethods(), ); } - /** - * Returns the percentage of methods that has been tested. - * - * @param bool $asString - * - * @return int - */ - public function getTestedMethodsPercent($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 executed lines. - * - * @param bool $asString - * - * @return int - */ - public function getLineExecutedPercent($asString = true) + public function percentageOfExecutedBranches(): Percentage { - return Util::percent( - $this->getNumExecutedLines(), - $this->getNumExecutableLines(), - $asString + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedBranches(), + $this->numberOfExecutableBranches(), ); } - /** - * Returns the number of classes and traits. - * - * @return int - */ - public function getNumClassesAndTraits() + public function percentageOfExecutedPaths(): Percentage { - return $this->getNumClasses() + $this->getNumTraits(); + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedPaths(), + $this->numberOfExecutablePaths(), + ); } - /** - * Returns the number of tested classes and traits. - * - * @return int - */ - public function getNumTestedClassesAndTraits() + public function numberOfClassesAndTraits(): int { - return $this->getNumTestedClasses() + $this->getNumTestedTraits(); + return $this->numberOfClasses() + $this->numberOfTraits(); } - /** - * Returns the classes and traits of this node. - * - * @return array - */ - public function getClassesAndTraits() + public function numberOfTestedClassesAndTraits(): int { - return array_merge($this->getClasses(), $this->getTraits()); + return $this->numberOfTestedClasses() + $this->numberOfTestedTraits(); } /** - * Returns the classes of this node. - * - * @return array + * @return array */ - abstract public function getClasses(); + public function classesAndTraits(): array + { + return array_merge($this->classes(), $this->traits()); + } - /** - * Returns the traits of this node. - * - * @return array - */ - abstract public function getTraits(); + public function numberOfFunctionsAndMethods(): int + { + return $this->numberOfFunctions() + $this->numberOfMethods(); + } - /** - * Returns the functions of this node. - * - * @return array - */ - abstract public function getFunctions(); + public function numberOfTestedFunctionsAndMethods(): int + { + return $this->numberOfTestedFunctions() + $this->numberOfTestedMethods(); + } /** - * Returns the LOC/CLOC/NCLOC of this node. - * - * @return array + * @return non-negative-int */ - abstract public function getLinesOfCode(); + public function cyclomaticComplexity(): int + { + $ccn = 0; - /** - * Returns the number of executable lines. - * - * @return int - */ - abstract public function getNumExecutableLines(); + foreach ($this->classesAndTraits() as $classLike) { + $ccn += $classLike->ccn; + } - /** - * Returns the number of executed lines. - * - * @return int - */ - abstract public function getNumExecutedLines(); + foreach ($this->functions() as $function) { + $ccn += $function->ccn; + } - /** - * Returns the number of classes. - * - * @return int - */ - abstract public function getNumClasses(); + return $ccn; + } /** - * Returns the number of tested classes. - * - * @return int + * @return array */ - abstract public function getNumTestedClasses(); + abstract public function classes(): array; /** - * Returns the number of traits. - * - * @return int + * @return array */ - abstract public function getNumTraits(); + abstract public function traits(): array; /** - * Returns the number of tested traits. - * - * @return int + * @return array */ - abstract public function getNumTestedTraits(); + abstract public function functions(): array; - /** - * Returns the number of methods. - * - * @return int - */ - abstract public function getNumMethods(); + abstract public function linesOfCode(): LinesOfCode; - /** - * Returns the number of tested methods. - * - * @return int - */ - abstract public function getNumTestedMethods(); + abstract public function numberOfExecutableLines(): int; - /** - * Returns the number of functions. - * - * @return int - */ - abstract public function getNumFunctions(); + abstract public function numberOfExecutedLines(): int; - /** - * Returns the number of tested functions. - * - * @return int - */ - abstract public function getNumTestedFunctions(); + abstract public function numberOfExecutableBranches(): int; + + abstract public function numberOfExecutedBranches(): int; + + abstract public function numberOfExecutablePaths(): int; + + abstract public function numberOfExecutedPaths(): int; + + abstract public function numberOfClasses(): int; + + abstract public function numberOfTestedClasses(): int; + + abstract public function numberOfTraits(): 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 8a6a65c1b..9a2efe145 100644 --- a/src/Node/Builder.php +++ b/src/Node/Builder.php @@ -1,61 +1,95 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - 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; -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 { - /** - * @param CodeCoverage $coverage - * - * @return Directory - */ - public function build(CodeCoverage $coverage) + 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; } /** - * @param Directory $root - * @param array $items - * @param array $tests - * @param bool $cacheTokens + * @param array $tests */ - private function addItems(Directory $root, array $items, array $tests, $cacheTokens) + private function addItems(Directory $root, array $items, array $tests): void { foreach ($items as $key => $value) { - if (substr($key, -2) == '/f') { - $key = substr($key, 0, -2); + $key = (string) $key; + + if (str_ends_with($key, '/f')) { + $key = substr($key, 0, -2); + $filename = $root->pathAsString() . DIRECTORY_SEPARATOR . $key; + + if (is_file($filename)) { + $analysisResult = $this->analyser->analyse($filename); - if (file_exists($root->getPath() . DIRECTORY_SEPARATOR . $key)) { - $root->addFile($key, $value, $tests, $cacheTokens); + $root->addFile( + new File( + $key, + $root, + $value['lineCoverage'], + $value['functionCoverage'], + $tests, + $analysisResult->classes(), + $analysisResult->traits(), + $analysisResult->functions(), + $analysisResult->linesOfCode(), + ), + ); } } else { $child = $root->addDirectory($key); - $this->addItems($child, $value, $tests, $cacheTokens); + + $this->addItems($child, $value, $tests); } } } @@ -100,30 +134,34 @@ private function addItems(Directory $root, array $items, array $tests, $cacheTok * ) * * - * @param array $files - * - * @return array + * @return array, functionCoverage: array>}>> */ - private function buildDirectoryStructure($files) + private function buildDirectoryStructure(ProcessedCodeCoverageData $data): array { $result = []; - foreach ($files as $path => $file) { - $path = explode('/', $path); + $lineCoverage = $data->lineCoverage(); + $functionCoverage = $data->functionCoverage(); + + foreach ($data->coveredFiles() as $originalPath) { + $path = explode(DIRECTORY_SEPARATOR, $originalPath); $pointer = &$result; $max = count($path); for ($i = 0; $i < $max; $i++) { - if ($i == ($max - 1)) { + $type = ''; + + if ($i === ($max - 1)) { $type = '/f'; - } else { - $type = ''; } $pointer = &$pointer[$path[$i] . $type]; } - $pointer = $file; + $pointer = [ + 'lineCoverage' => $lineCoverage[$originalPath] ?? [], + 'functionCoverage' => $functionCoverage[$originalPath] ?? [], + ]; } return $result; @@ -165,25 +203,21 @@ private function buildDirectoryStructure($files) * ) * ) * - * - * @param array $files - * - * @return string */ - private function reducePaths(&$files) + private function reducePaths(ProcessedCodeCoverageData $coverage): string { - if (empty($files)) { + $coveredFiles = $coverage->coveredFiles(); + + if ($coveredFiles === []) { return '.'; } $commonPath = ''; - $paths = array_keys($files); - - if (count($files) == 1) { - $commonPath = dirname($paths[0]) . '/'; - $files[basename($paths[0])] = $files[$paths[0]]; + $paths = $coveredFiles; - unset($files[$paths[0]]); + if (count($paths) === 1) { + $commonPath = dirname($paths[0]) . DIRECTORY_SEPARATOR; + $coverage->renameFile($paths[0], basename($paths[0])); return $commonPath; } @@ -192,13 +226,14 @@ private function reducePaths(&$files) for ($i = 0; $i < $max; $i++) { // strip phar:// prefixes - if (strpos($paths[$i], 'phar://') === 0) { + if (str_starts_with($paths[$i], 'phar://')) { $paths[$i] = substr($paths[$i], 7); - $paths[$i] = strtr($paths[$i], '/', DIRECTORY_SEPARATOR); + $paths[$i] = str_replace('/', DIRECTORY_SEPARATOR, $paths[$i]); } + $paths[$i] = explode(DIRECTORY_SEPARATOR, $paths[$i]); - if (empty($paths[$i][0])) { + if ($paths[$i][0] === '') { $paths[$i][0] = DIRECTORY_SEPARATOR; } } @@ -209,9 +244,10 @@ private function reducePaths(&$files) while (!$done) { for ($i = 0; $i < $max - 1; $i++) { if (!isset($paths[$i][0]) || - !isset($paths[$i+1][0]) || - $paths[$i][0] != $paths[$i+1][0]) { + !isset($paths[$i + 1][0]) || + $paths[$i][0] !== $paths[$i + 1][0]) { $done = true; + break; } } @@ -219,7 +255,7 @@ private function reducePaths(&$files) if (!$done) { $commonPath .= $paths[0][0]; - if ($paths[0][0] != DIRECTORY_SEPARATOR) { + if ($paths[0][0] !== DIRECTORY_SEPARATOR) { $commonPath .= DIRECTORY_SEPARATOR; } @@ -229,16 +265,13 @@ private function reducePaths(&$files) } } - $original = array_keys($files); + $original = $coveredFiles; $max = count($original); for ($i = 0; $i < $max; $i++) { - $files[implode('/', $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); } } 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 6a9f28db5..818d665dd 100644 --- a/src/Node/Directory.php +++ b/src/Node/Directory.php @@ -1,120 +1,80 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - 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\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; /** - * Represents a directory in the code coverage information tree. + * @template-implements IteratorAggregate + * + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ -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 = null; - - /** - * @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. - * - * @return int - */ - public function count() + public function count(): int { - if ($this->numFiles == -1) { + if ($this->numFiles === -1) { $this->numFiles = 0; foreach ($this->children as $child) { @@ -126,102 +86,65 @@ public function count() } /** - * Returns an iterator for this node. - * - * @return \RecursiveIteratorIterator + * @return RecursiveIteratorIterator> */ - public function getIterator() + public function getIterator(): RecursiveIteratorIterator { - return new \RecursiveIteratorIterator( + return new RecursiveIteratorIterator( new Iterator($this), - \RecursiveIteratorIterator::SELF_FIRST + RecursiveIteratorIterator::SELF_FIRST, ); } - /** - * Adds a new directory. - * - * @param string $name - * - * @return Directory - */ - public function addDirectory($name) + 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]; return $directory; } - /** - * Adds a new file. - * - * @param string $name - * @param array $coverageData - * @param array $testData - * @param bool $cacheTokens - * - * @return File - * - * @throws InvalidArgumentException - */ - public function addFile($name, array $coverageData, array $testData, $cacheTokens) + 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->numExecutableLines = -1; $this->numExecutedLines = -1; - - return $file; } /** - * Returns the directories in this directory. - * - * @return array + * @return list */ - public function getDirectories() + public function directories(): array { return $this->directories; } /** - * Returns the files in this directory. - * - * @return array + * @return list */ - public function getFiles() + public function files(): array { return $this->files; } /** - * Returns the child nodes of this node. - * - * @return array + * @return list */ - public function getChildNodes() + public function children(): array { return $this->children; } /** - * Returns the classes of this node. - * - * @return array + * @return array */ - public function getClasses() + public function classes(): array { if ($this->classes === null) { $this->classes = []; @@ -229,7 +152,7 @@ public function getClasses() foreach ($this->children as $child) { $this->classes = array_merge( $this->classes, - $child->getClasses() + $child->classes(), ); } } @@ -238,11 +161,9 @@ public function getClasses() } /** - * Returns the traits of this node. - * - * @return array + * @return array */ - public function getTraits() + public function traits(): array { if ($this->traits === null) { $this->traits = []; @@ -250,7 +171,7 @@ public function getTraits() foreach ($this->children as $child) { $this->traits = array_merge( $this->traits, - $child->getTraits() + $child->traits(), ); } } @@ -259,11 +180,9 @@ public function getTraits() } /** - * Returns the functions of this node. - * - * @return array + * @return array */ - public function getFunctions() + public function functions(): array { if ($this->functions === null) { $this->functions = []; @@ -271,7 +190,7 @@ public function getFunctions() foreach ($this->children as $child) { $this->functions = array_merge( $this->functions, - $child->getFunctions() + $child->functions(), ); } } @@ -279,202 +198,203 @@ public function getFunctions() return $this->functions; } - /** - * Returns the LOC/CLOC/NCLOC of this node. - * - * @return array - */ - public function getLinesOfCode() + 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. - * - * @return int - */ - public function getNumExecutableLines() + public function numberOfExecutableLines(): int { - if ($this->numExecutableLines == -1) { + 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. - * - * @return int - */ - public function getNumExecutedLines() + public function numberOfExecutedLines(): int { - if ($this->numExecutedLines == -1) { + 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. - * - * @return int - */ - public function getNumClasses() + 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->numClasses == -1) { + 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. - * - * @return int - */ - public function getNumTestedClasses() + public function numberOfTestedClasses(): int { - if ($this->numTestedClasses == -1) { + 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. - * - * @return int - */ - public function getNumTraits() + public function numberOfTraits(): int { - if ($this->numTraits == -1) { + 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. - * - * @return int - */ - public function getNumTestedTraits() + public function numberOfTestedTraits(): int { - if ($this->numTestedTraits == -1) { + 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. - * - * @return int - */ - public function getNumMethods() + public function numberOfMethods(): int { - if ($this->numMethods == -1) { + 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. - * - * @return int - */ - public function getNumTestedMethods() + public function numberOfTestedMethods(): int { - if ($this->numTestedMethods == -1) { + 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. - * - * @return int - */ - public function getNumFunctions() + public function numberOfFunctions(): int { - if ($this->numFunctions == -1) { + 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. - * - * @return int - */ - public function getNumTestedFunctions() + public function numberOfTestedFunctions(): int { - if ($this->numTestedFunctions == -1) { + 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 44856f075..3c27fde30 100644 --- a/src/Node/File.php +++ b/src/Node/File.php @@ -1,244 +1,196 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage\Node; -use SebastianBergmann\CodeCoverage\InvalidArgumentException; +use function array_filter; +use function count; +use function range; +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Data\ProcessedBranchCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Data\ProcessedPathCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +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 */ -class File extends AbstractNode +final class File extends AbstractNode { /** - * @var array - */ - private $coverageData; - - /** - * @var array - */ - private $testData; - - /** - * @var int - */ - private $numExecutableLines = 0; - - /** - * @var int + * @var array> */ - private $numExecutedLines = 0; + private array $lineCoverageData; + private array $functionCoverageData; /** - * @var array + * @var array */ - private $classes = []; + 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 $traits = []; + private array $classes = []; /** - * @var array + * @var array */ - private $functions = []; + private array $traits = []; /** - * @var array + * @var array */ - private $linesOfCode = []; + 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 $numClasses = null; + private array $codeUnitsByLine = []; /** - * @var int + * @param array> $lineCoverageData + * @param array $testData + * @param array $classes + * @param array $traits + * @param array $functions */ - private $numTestedClasses = 0; - - /** - * @var int - */ - private $numTraits = null; - - /** - * @var int - */ - private $numTestedTraits = 0; - - /** - * @var int - */ - private $numMethods = null; - - /** - * @var int - */ - private $numTestedMethods = null; - - /** - * @var int - */ - private $numTestedFunctions = null; - - /** - * @var array - */ - private $startLines = []; - - /** - * @var array - */ - private $endLines = []; - - /** - * @var bool - */ - private $cacheTokens; - - /** - * Constructor. - * - * @param string $name - * @param AbstractNode $parent - * @param array $coverageData - * @param array $testData - * @param bool $cacheTokens - * - * @throws InvalidArgumentException - */ - public function __construct($name, AbstractNode $parent, array $coverageData, array $testData, $cacheTokens) + public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData, array $classes, array $traits, array $functions, LinesOfCode $linesOfCode) { - if (!is_bool($cacheTokens)) { - throw InvalidArgumentException::create( - 1, - 'boolean' - ); - } - 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. - * - * @return int - */ - public function count() + public function count(): int { return 1; } /** - * Returns the code coverage data of this node. - * - * @return array + * @return array> */ - public function getCoverageData() + public function lineCoverageData(): array + { + return $this->lineCoverageData; + } + + public function functionCoverageData(): array { - return $this->coverageData; + return $this->functionCoverageData; } /** - * Returns the test data of this node. - * - * @return array + * @return array */ - public function getTestData() + public function testData(): array { return $this->testData; } /** - * Returns the classes of this node. - * - * @return array + * @return array */ - public function getClasses() + public function classes(): array { return $this->classes; } /** - * Returns the traits of this node. - * - * @return array + * @return array */ - public function getTraits() + public function traits(): array { return $this->traits; } /** - * Returns the functions of this node. - * - * @return array + * @return array */ - public function getFunctions() + public function functions(): array { return $this->functions; } - /** - * Returns the LOC/CLOC/NCLOC of this node. - * - * @return array - */ - public function getLinesOfCode() + public function linesOfCode(): LinesOfCode { return $this->linesOfCode; } - /** - * Returns the number of executable lines. - * - * @return int - */ - public function getNumExecutableLines() + public function numberOfExecutableLines(): int { return $this->numExecutableLines; } - /** - * Returns the number of executed lines. - * - * @return int - */ - public function getNumExecutedLines() + public function numberOfExecutedLines(): int { return $this->numExecutedLines; } - /** - * Returns the number of classes. - * - * @return int - */ - public function getNumClasses() + 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; foreach ($this->classes as $class) { - foreach ($class['methods'] as $method) { - if ($method['executableLines'] > 0) { + foreach ($class->methods as $method) { + if ($method->executableLines > 0) { $this->numClasses++; continue 2; @@ -250,29 +202,19 @@ public function getNumClasses() return $this->numClasses; } - /** - * Returns the number of tested classes. - * - * @return int - */ - public function getNumTestedClasses() + public function numberOfTestedClasses(): int { return $this->numTestedClasses; } - /** - * Returns the number of traits. - * - * @return int - */ - public function getNumTraits() + public function numberOfTraits(): int { if ($this->numTraits === null) { $this->numTraits = 0; foreach ($this->traits as $trait) { - foreach ($trait['methods'] as $method) { - if ($method['executableLines'] > 0) { + foreach ($trait->methods as $method) { + if ($method->executableLines > 0) { $this->numTraits++; continue 2; @@ -284,37 +226,27 @@ public function getNumTraits() return $this->numTraits; } - /** - * Returns the number of tested traits. - * - * @return int - */ - public function getNumTestedTraits() + public function numberOfTestedTraits(): int { return $this->numTestedTraits; } - /** - * Returns the number of methods. - * - * @return int - */ - public function getNumMethods() + public function numberOfMethods(): int { if ($this->numMethods === null) { $this->numMethods = 0; foreach ($this->classes as $class) { - foreach ($class['methods'] as $method) { - if ($method['executableLines'] > 0) { + foreach ($class->methods as $method) { + if ($method->executableLines > 0) { $this->numMethods++; } } } foreach ($this->traits as $trait) { - foreach ($trait['methods'] as $method) { - if ($method['executableLines'] > 0) { + foreach ($trait->methods as $method) { + if ($method->executableLines > 0) { $this->numMethods++; } } @@ -324,29 +256,24 @@ public function getNumMethods() return $this->numMethods; } - /** - * Returns the number of tested methods. - * - * @return int - */ - public function getNumTestedMethods() + public function numberOfTestedMethods(): int { if ($this->numTestedMethods === null) { $this->numTestedMethods = 0; foreach ($this->classes as $class) { - foreach ($class['methods'] as $method) { - if ($method['executableLines'] > 0 && - $method['coverage'] == 100) { + foreach ($class->methods as $method) { + if ($method->executableLines > 0 && + $method->coverage === 100) { $this->numTestedMethods++; } } } foreach ($this->traits as $trait) { - foreach ($trait['methods'] as $method) { - if ($method['executableLines'] > 0 && - $method['coverage'] == 100) { + foreach ($trait->methods as $method) { + if ($method->executableLines > 0 && + $method->coverage === 100) { $this->numTestedMethods++; } } @@ -356,29 +283,19 @@ public function getNumTestedMethods() return $this->numTestedMethods; } - /** - * Returns the number of functions. - * - * @return int - */ - public function getNumFunctions() + public function numberOfFunctions(): int { return count($this->functions); } - /** - * Returns the number of tested functions. - * - * @return int - */ - public function getNumTestedFunctions() + public function numberOfTestedFunctions(): int { if ($this->numTestedFunctions === null) { $this->numTestedFunctions = 0; foreach ($this->functions as $function) { - if ($function['executableLines'] > 0 && - $function['coverage'] == 100) { + if ($function->executableLines > 0 && + $function->coverage === 100) { $this->numTestedFunctions++; } } @@ -388,335 +305,337 @@ public function getNumTestedFunctions() } /** - * Calculates coverage statistics for the file. + * @param array $classes + * @param array $traits + * @param array $functions */ - protected function calculateStatistics() + private function calculateStatistics(array $classes, array $traits, array $functions): void { - $classStack = $functionStack = []; - - if ($this->cacheTokens) { - $tokens = \PHP_Token_Stream_CachingFactory::get($this->getPath()); - } else { - $tokens = new \PHP_Token_Stream($this->getPath()); + foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) { + $this->codeUnitsByLine[$lineNumber] = []; } - $this->processClasses($tokens); - $this->processTraits($tokens); - $this->processFunctions($tokens); - $this->linesOfCode = $tokens->getLinesOfCode(); - unset($tokens); - - for ($lineNumber = 1; $lineNumber <= $this->linesOfCode['loc']; $lineNumber++) { - if (isset($this->startLines[$lineNumber])) { - // Start line of a class. - if (isset($this->startLines[$lineNumber]['className'])) { - if (isset($currentClass)) { - $classStack[] = &$currentClass; - } - - $currentClass = &$this->startLines[$lineNumber]; - } // Start line of a trait. - elseif (isset($this->startLines[$lineNumber]['traitName'])) { - $currentTrait = &$this->startLines[$lineNumber]; - } // Start line of a method. - elseif (isset($this->startLines[$lineNumber]['methodName'])) { - $currentMethod = &$this->startLines[$lineNumber]; - } // Start line of a function. - elseif (isset($this->startLines[$lineNumber]['functionName'])) { - if (isset($currentFunction)) { - $functionStack[] = &$currentFunction; - } - - $currentFunction = &$this->startLines[$lineNumber]; - } - } - - if (isset($this->coverageData[$lineNumber])) { - if (isset($currentClass)) { - $currentClass['executableLines']++; - } - - if (isset($currentTrait)) { - $currentTrait['executableLines']++; - } + $this->processClasses($classes); + $this->processTraits($traits); + $this->processFunctions($functions); - if (isset($currentMethod)) { - $currentMethod['executableLines']++; + foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) { + if (isset($this->lineCoverageData[$lineNumber])) { + foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) { + $codeUnit->executableLines++; } - if (isset($currentFunction)) { - $currentFunction['executableLines']++; - } + unset($codeUnit); $this->numExecutableLines++; - if (count($this->coverageData[$lineNumber]) > 0) { - if (isset($currentClass)) { - $currentClass['executedLines']++; - } - - if (isset($currentTrait)) { - $currentTrait['executedLines']++; - } - - if (isset($currentMethod)) { - $currentMethod['executedLines']++; + if (count($this->lineCoverageData[$lineNumber]) > 0) { + foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) { + $codeUnit->executedLines++; } - if (isset($currentFunction)) { - $currentFunction['executedLines']++; - } + unset($codeUnit); $this->numExecutedLines++; } } - - if (isset($this->endLines[$lineNumber])) { - // End line of a class. - if (isset($this->endLines[$lineNumber]['className'])) { - unset($currentClass); - - if ($classStack) { - end($classStack); - $key = key($classStack); - $currentClass = &$classStack[$key]; - unset($classStack[$key]); - } - } // End line of a trait. - elseif (isset($this->endLines[$lineNumber]['traitName'])) { - unset($currentTrait); - } // End line of a method. - elseif (isset($this->endLines[$lineNumber]['methodName'])) { - unset($currentMethod); - } // End line of a function. - elseif (isset($this->endLines[$lineNumber]['functionName'])) { - unset($currentFunction); - - if ($functionStack) { - end($functionStack); - $key = key($functionStack); - $currentFunction = &$functionStack[$key]; - unset($functionStack[$key]); - } - } - } } 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; - } + foreach ($trait->methods as &$method) { + $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']; + $trait->ccn += $method->ccn; } - if ($trait['executableLines'] > 0) { - $trait['coverage'] = ($trait['executedLines'] / - $trait['executableLines']) * 100; + unset($method); - if ($trait['coverage'] == 100) { - $this->numTestedClasses++; - } - } else { - $trait['coverage'] = 100; - } + $traitBranchCoverage = $trait->executableBranches > 0 ? ($trait->executedBranches / $trait->executableBranches) * 100 : 0; + $traitLineCoverage = $trait->executableLines > 0 ? ($trait->executedLines / $trait->executableLines) * 100 : 100; + $traitPathCoverage = $trait->executablePaths > 0 ? ($trait->executedPaths / $trait->executablePaths) * 100 : 0; - $trait['crap'] = $this->crap( - $trait['ccn'], - $trait['coverage'] - ); + $trait->coverage = $traitBranchCoverage > 0 ? $traitBranchCoverage : $traitLineCoverage; + $trait->crap = (new CrapIndex($trait->ccn, $traitPathCoverage > 0 ? $traitPathCoverage : $traitLineCoverage))->asString(); + + if ($trait->executableLines > 0 && $trait->coverage === 100) { + $this->numTestedClasses++; + } } + 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; - } + foreach ($class->methods as &$method) { + $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']; + $class->ccn += $method->ccn; } - if ($class['executableLines'] > 0) { - $class['coverage'] = ($class['executedLines'] / - $class['executableLines']) * 100; + unset($method); - if ($class['coverage'] == 100) { - $this->numTestedClasses++; - } - } else { - $class['coverage'] = 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; + + $class->coverage = $classBranchCoverage > 0 ? $classBranchCoverage : $classLineCoverage; + $class->crap = (new CrapIndex($class->ccn, $classPathCoverage > 0 ? $classPathCoverage : $classLineCoverage))->asString(); + + if ($class->executableLines > 0 && $class->coverage === 100) { + $this->numTestedClasses++; } + } - $class['crap'] = $this->crap( - $class['ccn'], - $class['coverage'] - ); + unset($class); + + foreach ($this->functions as &$function) { + $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++; + } } } /** - * @param \PHP_Token_Stream $tokens + * @param array $classes */ - protected function processClasses(\PHP_Token_Stream $tokens) + private function processClasses(array $classes): void { - $classes = $tokens->getClasses(); - unset($tokens); - - $link = $this->getId() . '.html#'; + $link = $this->id() . '.html#'; foreach ($classes as $className => $class) { - $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'] - ]; - - $this->startLines[$class['startLine']] = &$this->classes[$className]; - $this->endLines[$class['endLine']] = &$this->classes[$className]; - - foreach ($class['methods'] as $methodName => $method) { - $this->classes[$className]['methods'][$methodName] = $this->newMethod($methodName, $method, $link); - - $this->startLines[$method['startLine']] = &$this->classes[$className]['methods'][$methodName]; - $this->endLines[$method['endLine']] = &$this->classes[$className]['methods'][$methodName]; + $this->classes[$className] = new ProcessedClassType( + $className, + $class->namespace(), + [], + $class->startLine(), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + $link . $class->startLine(), + ); + + 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->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->classes[$className], + &$this->classes[$className]->methods[$methodName], + ]; + } } } } /** - * @param \PHP_Token_Stream $tokens + * @param array $traits */ - protected function processTraits(\PHP_Token_Stream $tokens) + private function processTraits(array $traits): void { - $traits = $tokens->getTraits(); - unset($tokens); - - $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'] - ]; - - $this->startLines[$trait['startLine']] = &$this->traits[$traitName]; - $this->endLines[$trait['endLine']] = &$this->traits[$traitName]; - - foreach ($trait['methods'] as $methodName => $method) { - $this->traits[$traitName]['methods'][$methodName] = $this->newMethod($methodName, $method, $link); - - $this->startLines[$method['startLine']] = &$this->traits[$traitName]['methods'][$methodName]; - $this->endLines[$method['endLine']] = &$this->traits[$traitName]['methods'][$methodName]; + $this->traits[$traitName] = new ProcessedTraitType( + $traitName, + $trait->namespace(), + [], + $trait->startLine(), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + $link . $trait->startLine(), + ); + + foreach ($trait->methods() as $methodName => $method) { + $methodData = $this->newMethod($traitName, $method, $link); + $this->traits[$traitName]->methods[$methodName] = $methodData; + + $this->traits[$traitName]->executableBranches += $methodData->executableBranches; + $this->traits[$traitName]->executedBranches += $methodData->executedBranches; + $this->traits[$traitName]->executablePaths += $methodData->executablePaths; + $this->traits[$traitName]->executedPaths += $methodData->executedPaths; + + $this->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], + ]; + } } } } /** - * @param \PHP_Token_Stream $tokens + * @param array $functions */ - protected function processFunctions(\PHP_Token_Stream $tokens) + private function processFunctions(array $functions): void { - $functions = $tokens->getFunctions(); - unset($tokens); - - $link = $this->getId() . '.html#'; + $link = $this->id() . '.html#'; foreach ($functions as $functionName => $function) { - $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'] - ]; - - $this->startLines[$function['startLine']] = &$this->functions[$functionName]; - $this->endLines[$function['endLine']] = &$this->functions[$functionName]; + $this->functions[$functionName] = new ProcessedFunctionType( + $functionName, + $function->namespace(), + $function->signature(), + $function->startLine(), + $function->endLine(), + 0, + 0, + 0, + 0, + 0, + 0, + $function->cyclomaticComplexity(), + 0, + 0, + $link . $function->startLine(), + ); + + foreach (range($function->startLine(), $function->endLine()) as $lineNumber) { + $this->codeUnitsByLine[$lineNumber] = [&$this->functions[$functionName]]; + } + + if (isset($this->functionCoverageData[$functionName])) { + $this->functions[$functionName]->executableBranches = count( + $this->functionCoverageData[$functionName]->branches, + ); + + $this->functions[$functionName]->executedBranches = count( + array_filter( + $this->functionCoverageData[$functionName]->branches, + static function (ProcessedBranchCoverageData $branch) + { + return (bool) $branch->hit; + }, + ), + ); + } + + if (isset($this->functionCoverageData[$functionName])) { + $this->functions[$functionName]->executablePaths = count( + $this->functionCoverageData[$functionName]->paths, + ); + + $this->functions[$functionName]->executedPaths = count( + array_filter( + $this->functionCoverageData[$functionName]->paths, + static function (ProcessedPathCoverageData $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; } } - /** - * Calculates the Change Risk Anti-Patterns (CRAP) index for a unit of code - * based on its cyclomatic complexity and percentage of code coverage. - * - * @param int $ccn - * @param float $coverage - * - * @return string - */ - protected function crap($ccn, $coverage) + private function newMethod(string $className, Method $method, string $link): ProcessedMethodType { - if ($coverage == 0) { - return (string) (pow($ccn, 2) + $ccn); + $key = $className . '->' . $method->name(); + + $executableBranches = 0; + $executedBranches = 0; + + if (isset($this->functionCoverageData[$key])) { + $executableBranches = count( + $this->functionCoverageData[$key]->branches, + ); + + $executedBranches = count( + array_filter( + $this->functionCoverageData[$key]->branches, + static function (ProcessedBranchCoverageData $branch) + { + return (bool) $branch->hit; + }, + ), + ); } - if ($coverage >= 95) { - return (string) $ccn; + $executablePaths = 0; + $executedPaths = 0; + + if (isset($this->functionCoverageData[$key])) { + $executablePaths = count( + $this->functionCoverageData[$key]->paths, + ); + + $executedPaths = count( + array_filter( + $this->functionCoverageData[$key]->paths, + static function (ProcessedPathCoverageData $path) + { + return (bool) $path->hit; + }, + ), + ); } - return sprintf( - '%01.2F', - pow($ccn, 2) * pow(1 - $coverage/100, 3) + $ccn + return new ProcessedMethodType( + $method->name(), + $method->visibility()->value, + $method->signature(), + $method->startLine(), + $method->endLine(), + 0, + 0, + $executableBranches, + $executedBranches, + $executablePaths, + $executedPaths, + $method->cyclomaticComplexity(), + 0, + 0, + $link . $method->startLine(), ); } - - /** - * @param string $methodName - * @param array $method - * @param string $link - * - * @return array - */ - private function newMethod($methodName, array $method, $link) - { - 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'], - ]; - } } diff --git a/src/Node/Iterator.php b/src/Node/Iterator.php index e24638059..ab3c8eb98 100644 --- a/src/Node/Iterator.php +++ b/src/Node/Iterator.php @@ -1,102 +1,70 @@ - * * 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 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 */ -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; - /** - * @param Directory $node - */ public function __construct(Directory $node) { - $this->nodes = $node->getChildNodes(); + $this->nodes = $node->children(); } - /** - * Rewinds the Iterator to the first element. - */ - public function rewind() + public function rewind(): void { $this->position = 0; } - /** - * Checks if there is a current element after calls to rewind() or next(). - * - * @return bool - */ - public function valid() + public function valid(): bool { return $this->position < count($this->nodes); } - /** - * Returns the key of the current element. - * - * @return int - */ - public function key() + public function key(): int { return $this->position; } - /** - * Returns the current element. - * - * @return \PHPUnit_Framework_Test - */ - public function current() + public function current(): ?AbstractNode { return $this->valid() ? $this->nodes[$this->position] : null; } - /** - * Moves forward to next element. - */ - public function next() + public function next(): void { $this->position++; } - /** - * Returns the sub iterator for the current element. - * - * @return Iterator - */ - public function getChildren() + public function getChildren(): self { - return new self( - $this->nodes[$this->position] - ); + assert($this->nodes[$this->position] instanceof Directory); + + return new self($this->nodes[$this->position]); } - /** - * Checks whether the current element has children. - * - * @return bool - */ - public function hasChildren() + public function hasChildren(): bool { return $this->nodes[$this->position] instanceof Directory; } diff --git a/src/Report/Clover.php b/src/Report/Clover.php index 054b1dfde..8e59d4c65 100644 --- a/src/Report/Clover.php +++ b/src/Report/Clover.php @@ -1,41 +1,47 @@ - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - namespace SebastianBergmann\CodeCoverage\Report; +use function count; +use function is_string; +use function ksort; +use function max; +use function range; +use function time; +use DOMDocument; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; -/** - * Generates a Clover XML logfile from a code coverage object. - */ -class Clover +final class Clover { /** - * @param CodeCoverage $coverage - * @param string $target - * @param string $name + * @param null|non-empty-string $target + * @param null|non-empty-string $name * - * @return string + * @throws WriteOperationFailedException */ - public function process(CodeCoverage $coverage, $target = null, $name = null) + public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { - $xmlDocument = new \DOMDocument('1.0', 'UTF-8'); - $xmlDocument->formatOutput = true; + $time = (string) time(); + + $xmlDocument = new DOMDocument('1.0', 'UTF-8'); $xmlCoverage = $xmlDocument->createElement('coverage'); - $xmlCoverage->setAttribute('generated', (int) $_SERVER['REQUEST_TIME']); + $xmlCoverage->setAttribute('generated', $time); $xmlDocument->appendChild($xmlCoverage); $xmlProject = $xmlDocument->createElement('project'); - $xmlProject->setAttribute('timestamp', (int) $_SERVER['REQUEST_TIME']); + $xmlProject->setAttribute('timestamp', $time); if (is_string($name)) { $xmlProject->setAttribute('name', $name); @@ -45,7 +51,6 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) $packages = []; $report = $coverage->getReport(); - unset($coverage); foreach ($report as $item) { if (!$item instanceof File) { @@ -55,12 +60,12 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) /* @var File $item */ $xmlFile = $xmlDocument->createElement('file'); - $xmlFile->setAttribute('name', $item->getPath()); + $xmlFile->setAttribute('name', $item->pathAsString()); - $classes = $item->getClassesAndTraits(); - $coverage = $item->getCoverageData(); - $lines = []; - $namespace = 'global'; + $classes = $item->classesAndTraits(); + $coverageData = $item->lineCoverageData(); + $lines = []; + $namespace = 'global'; foreach ($classes as $className => $class) { $classStatements = 0; @@ -68,95 +73,70 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) $coveredMethods = 0; $classMethods = 0; - foreach ($class['methods'] as $methodName => $method) { - if ($method['executableLines'] == 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']; - $coveredClassStatements += $method['executedLines']; + $classStatements += $method->executableLines; + $coveredClassStatements += $method->executedLines; - if ($method['coverage'] == 100) { + /** @phpstan-ignore equal.notAllowed */ + if ($method->coverage == 100) { $coveredMethods++; } $methodCount = 0; - foreach (range($method['startLine'], $method['endLine']) as $line) { - if (isset($coverage[$line]) && ($coverage[$line] !== null)) { - $methodCount = max($methodCount, count($coverage[$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 + $lines[$method->startLine] = [ + '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', $class['ccn']); - $xmlMetrics->setAttribute('methods', $classMethods); - $xmlMetrics->setAttribute('coveredmethods', $coveredMethods); - $xmlMetrics->setAttribute('conditionals', 0); - $xmlMetrics->setAttribute('coveredconditionals', 0); - $xmlMetrics->setAttribute('statements', $classStatements); - $xmlMetrics->setAttribute('coveredstatements', $coveredClassStatements); - $xmlMetrics->setAttribute('elements', $classMethods + $classStatements /* + conditionals */); - $xmlMetrics->setAttribute('coveredelements', $coveredMethods + $coveredClassStatements /* + coveredconditionals */); + $xmlMetrics->setAttribute('complexity', (string) $class->ccn); + $xmlMetrics->setAttribute('methods', (string) $classMethods); + $xmlMetrics->setAttribute('coveredmethods', (string) $coveredMethods); + $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 + $class->executableBranches)); + $xmlMetrics->setAttribute('coveredelements', (string) ($coveredMethods + $coveredClassStatements + $class->executedBranches)); $xmlClass->appendChild($xmlMetrics); } - foreach ($coverage as $line => $data) { + foreach ($coverageData as $line => $data) { if ($data === null || isset($lines[$line])) { continue; } $lines[$line] = [ - 'count' => count($data), 'type' => 'stmt' + 'count' => count($data), 'type' => 'stmt', ]; } @@ -164,7 +144,7 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) foreach ($lines as $line => $data) { $xmlLine = $xmlDocument->createElement('line'); - $xmlLine->setAttribute('num', $line); + $xmlLine->setAttribute('num', (string) $line); $xmlLine->setAttribute('type', $data['type']); if (isset($data['name'])) { @@ -176,39 +156,39 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) } if (isset($data['ccn'])) { - $xmlLine->setAttribute('complexity', $data['ccn']); + $xmlLine->setAttribute('complexity', (string) $data['ccn']); } if (isset($data['crap'])) { - $xmlLine->setAttribute('crap', $data['crap']); + $xmlLine->setAttribute('crap', (string) $data['crap']); } - $xmlLine->setAttribute('count', $data['count']); + $xmlLine->setAttribute('count', (string) $data['count']); $xmlFile->appendChild($xmlLine); } - $linesOfCode = $item->getLinesOfCode(); + $linesOfCode = $item->linesOfCode(); $xmlMetrics = $xmlDocument->createElement('metrics'); - $xmlMetrics->setAttribute('loc', $linesOfCode['loc']); - $xmlMetrics->setAttribute('ncloc', $linesOfCode['ncloc']); - $xmlMetrics->setAttribute('classes', $item->getNumClassesAndTraits()); - $xmlMetrics->setAttribute('methods', $item->getNumMethods()); - $xmlMetrics->setAttribute('coveredmethods', $item->getNumTestedMethods()); - $xmlMetrics->setAttribute('conditionals', 0); - $xmlMetrics->setAttribute('coveredconditionals', 0); - $xmlMetrics->setAttribute('statements', $item->getNumExecutableLines()); - $xmlMetrics->setAttribute('coveredstatements', $item->getNumExecutedLines()); - $xmlMetrics->setAttribute('elements', $item->getNumMethods() + $item->getNumExecutableLines() /* + conditionals */); - $xmlMetrics->setAttribute('coveredelements', $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') { + if ($namespace === 'global') { $xmlProject->appendChild($xmlFile); } else { if (!isset($packages[$namespace])) { $packages[$namespace] = $xmlDocument->createElement( - 'package' + 'package', ); $packages[$namespace]->setAttribute('name', $namespace); @@ -219,31 +199,27 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) } } - $linesOfCode = $report->getLinesOfCode(); + $linesOfCode = $report->linesOfCode(); $xmlMetrics = $xmlDocument->createElement('metrics'); - $xmlMetrics->setAttribute('files', count($report)); - $xmlMetrics->setAttribute('loc', $linesOfCode['loc']); - $xmlMetrics->setAttribute('ncloc', $linesOfCode['ncloc']); - $xmlMetrics->setAttribute('classes', $report->getNumClassesAndTraits()); - $xmlMetrics->setAttribute('methods', $report->getNumMethods()); - $xmlMetrics->setAttribute('coveredmethods', $report->getNumTestedMethods()); - $xmlMetrics->setAttribute('conditionals', 0); - $xmlMetrics->setAttribute('coveredconditionals', 0); - $xmlMetrics->setAttribute('statements', $report->getNumExecutableLines()); - $xmlMetrics->setAttribute('coveredstatements', $report->getNumExecutedLines()); - $xmlMetrics->setAttribute('elements', $report->getNumMethods() + $report->getNumExecutableLines() /* + conditionals */); - $xmlMetrics->setAttribute('coveredelements', $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(); + $buffer = Xml::asString($xmlDocument); if ($target !== null) { - if (!is_dir(dirname($target))) { - mkdir(dirname($target), 0777, true); - } - - file_put_contents($target, $buffer); + Filesystem::write($target, $buffer); } return $buffer; diff --git a/src/Report/Cobertura.php b/src/Report/Cobertura.php new file mode 100644 index 000000000..38f0e79ee --- /dev/null +++ b/src/Report/Cobertura.php @@ -0,0 +1,299 @@ + + * + * 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 preg_match; +use function range; +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\Util\Xml; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; + +final class Cobertura +{ + /** + * @param null|non-empty-string $target + * + * @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'; + + $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 = Xml::asString($document); + + if ($target !== null) { + Filesystem::write($target, $buffer); + } + + return $buffer; + } +} diff --git a/src/Report/Crap4j.php b/src/Report/Crap4j.php index 7adf78fe3..b015908b4 100644 --- a/src/Report/Crap4j.php +++ b/src/Report/Crap4j.php @@ -1,59 +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; +use function date; +use function htmlspecialchars; +use function is_string; +use function round; +use DOMDocument; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\File; -use SebastianBergmann\CodeCoverage\InvalidArgumentException; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; -class Crap4j +final readonly class Crap4j { - /** - * @var int - */ - private $threshold; + private int $threshold; - /** - * @param int $threshold - */ - public function __construct($threshold = 30) + public function __construct(int $threshold = 30) { - if (!is_int($threshold)) { - throw InvalidArgumentException::create( - 1, - 'integer' - ); - } - $this->threshold = $threshold; } /** - * @param CodeCoverage $coverage - * @param string $target - * @param string $name + * @param null|non-empty-string $target + * @param null|non-empty-string $name * - * @return string + * @throws WriteOperationFailedException */ - public function process(CodeCoverage $coverage, $target = null, $name = null) + public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { - $document = new \DOMDocument('1.0', 'UTF-8'); - $document->formatOutput = true; + $document = new DOMDocument('1.0', 'UTF-8'); $root = $document->createElement('crap_result'); $document->appendChild($root); $project = $document->createElement('project', is_string($name) ? $name : ''); $root->appendChild($project); - $root->appendChild($document->createElement('timestamp', date('Y-m-d H:i:s', (int) $_SERVER['REQUEST_TIME']))); + $root->appendChild($document->createElement('timestamp', date('Y-m-d H:i:s'))); $stats = $document->createElement('stats'); $methodsNode = $document->createElement('methods'); @@ -74,37 +65,37 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) } $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($method['crap'], $method['ccn'], $method['coverage']); + foreach ($class->methods as $methodName => $method) { + $crapLoad = $this->crapLoad((float) $method->crap, $method->ccn, $method->coverage); - $fullCrap += $method['crap']; + $fullCrap += $method->crap; $fullCrapLoad += $crapLoad; $fullMethodCount++; - if ($method['crap'] >= $this->threshold) { + if ($method->crap >= $this->threshold) { $fullCrapMethodCount++; } $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('crap', $this->roundValue($method['crap']))); - $methodNode->appendChild($document->createElement('complexity', $method['ccn'])); - $methodNode->appendChild($document->createElement('coverage', $this->roundValue($method['coverage']))); - $methodNode->appendChild($document->createElement('crapLoad', round($crapLoad))); + $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))); $methodsNode->appendChild($methodNode); } @@ -112,43 +103,32 @@ public function process(CodeCoverage $coverage, $target = null, $name = null) } $stats->appendChild($document->createElement('name', 'Method Crap Stats')); - $stats->appendChild($document->createElement('methodCount', $fullMethodCount)); - $stats->appendChild($document->createElement('crapMethodCount', $fullCrapMethodCount)); - $stats->appendChild($document->createElement('crapLoad', round($fullCrapLoad))); - $stats->appendChild($document->createElement('totalCrap', $fullCrap)); + $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('totalCrap', (string) $fullCrap)); + + $crapMethodPercent = 0; if ($fullMethodCount > 0) { $crapMethodPercent = $this->roundValue((100 * $fullCrapMethodCount) / $fullMethodCount); - } else { - $crapMethodPercent = 0; } - $stats->appendChild($document->createElement('crapMethodPercent', $crapMethodPercent)); + $stats->appendChild($document->createElement('crapMethodPercent', (string) $crapMethodPercent)); $root->appendChild($stats); $root->appendChild($methodsNode); - $buffer = $document->saveXML(); + $buffer = Xml::asString($document); if ($target !== null) { - if (!is_dir(dirname($target))) { - mkdir(dirname($target), 0777, true); - } - - file_put_contents($target, $buffer); + Filesystem::write($target, $buffer); } return $buffer; } - /** - * @param float $crapValue - * @param int $cyclomaticComplexity - * @param float $coveragePercent - * - * @return float - */ - private function getCrapLoad($crapValue, $cyclomaticComplexity, $coveragePercent) + private function crapLoad(float $crapValue, int $cyclomaticComplexity, float $coveragePercent): float { $crapLoad = 0; @@ -160,12 +140,7 @@ private function getCrapLoad($crapValue, $cyclomaticComplexity, $coveragePercent return $crapLoad; } - /** - * @param float $value - * - * @return float - */ - private function roundValue($value) + private function roundValue(float $value): float { 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 adcfe4247..44c63c923 100644 --- a/src/Report/Html/Facade.php +++ b/src/Report/Html/Facade.php @@ -1,179 +1,154 @@ - * * 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 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. - */ -class Facade +final readonly class Facade { - /** - * @var string - */ - private $templatePath; - - /** - * @var string - */ - private $generator; - - /** - * @var int - */ - private $lowUpperBound; - - /** - * @var int - */ - private $highLowerBound; - - /** - * Constructor. - * - * @param int $lowUpperBound - * @param int $highLowerBound - * @param string $generator - */ - public function __construct($lowUpperBound = 50, $highLowerBound = 90, $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/'; } - /** - * @param CodeCoverage $coverage - * @param string $target - */ - public function process(CodeCoverage $coverage, $target) + public function process(CodeCoverage $coverage, string $target): void { - $target = $this->getDirectory($target); - $report = $coverage->getReport(); - unset($coverage); - - if (!isset($_SERVER['REQUEST_TIME'])) { - $_SERVER['REQUEST_TIME'] = time(); - } - - $date = date('D M j G:i:s T Y', $_SERVER['REQUEST_TIME']); + $target = $this->directory($target); + $report = $coverage->getReport(); + $date = date('D M j G:i:s T Y'); + $hasBranchCoverage = $coverage->getData(true)->functionCoverage() !== []; $dashboard = new Dashboard( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound + $this->thresholds, + $hasBranchCoverage, ); $directory = new Directory( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound + $this->thresholds, + $hasBranchCoverage, ); $file = new File( $this->templatePath, $this->generator, $date, - $this->lowUpperBound, - $this->highLowerBound + $this->thresholds, + $hasBranchCoverage, ); $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 (!file_exists($target . $id)) { - mkdir($target . $id, 0777, true); - } + Filesystem::createDirectory($target . $id); $directory->render($node, $target . $id . '/index.html'); $dashboard->render($node, $target . $id . '/dashboard.html'); } else { $dir = dirname($target . $id); - if (!file_exists($dir)) { - mkdir($dir, 0777, true); - } + Filesystem::createDirectory($dir); - $file->render($node, $target . $id . '.html'); + $file->render($node, $target . $id); } } $this->copyFiles($target); + $this->renderCss($target); } - /** - * @param string $target - */ - private function copyFiles($target) + private function copyFiles(string $target): void { - $dir = $this->getDirectory($target . 'css'); + $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->templatePath . 'css/nv.d3.min.css', $dir . 'nv.d3.min.css'); - copy($this->templatePath . 'css/style.css', $dir . 'style.css'); - - $dir = $this->getDirectory($target . 'fonts'); - copy($this->templatePath . 'fonts/glyphicons-halflings-regular.eot', $dir . 'glyphicons-halflings-regular.eot'); - copy($this->templatePath . 'fonts/glyphicons-halflings-regular.svg', $dir . 'glyphicons-halflings-regular.svg'); - copy($this->templatePath . 'fonts/glyphicons-halflings-regular.ttf', $dir . 'glyphicons-halflings-regular.ttf'); - copy($this->templatePath . 'fonts/glyphicons-halflings-regular.woff', $dir . 'glyphicons-halflings-regular.woff'); - copy($this->templatePath . 'fonts/glyphicons-halflings-regular.woff2', $dir . 'glyphicons-halflings-regular.woff2'); - - $dir = $this->getDirectory($target . 'js'); - copy($this->templatePath . 'js/bootstrap.min.js', $dir . 'bootstrap.min.js'); - copy($this->templatePath . 'js/d3.min.js', $dir . 'd3.min.js'); - copy($this->templatePath . 'js/holder.min.js', $dir . 'holder.min.js'); - copy($this->templatePath . 'js/html5shiv.min.js', $dir . 'html5shiv.min.js'); + 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/nv.d3.min.js', $dir . 'nv.d3.min.js'); - copy($this->templatePath . 'js/respond.min.js', $dir . 'respond.min.js'); + copy($this->templatePath . 'js/file.js', $dir . 'file.js'); } - /** - * @param string $directory - * - * @return string - * - * @throws RuntimeException - */ - private function getDirectory($directory) + 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 (is_dir($directory)) { - return $directory; + try { + $template->renderTo($this->directory($target . '_css') . 'style.css'); + // @codeCoverageIgnoreStart + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + // @codeCoverageIgnoreEnd } + } - if (@mkdir($directory, 0777, true)) { - return $directory; + private function directory(string $directory): string + { + if (!str_ends_with($directory, DIRECTORY_SEPARATOR)) { + $directory .= DIRECTORY_SEPARATOR; } - throw new RuntimeException( - sprintf( - 'Directory "%s" does not exist.', - $directory - ) - ); + Filesystem::createDirectory($directory); + + return $directory; } } diff --git a/src/Report/Html/Renderer.php b/src/Report/Html/Renderer.php index a6c5f1d07..49a03e5cf 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -1,95 +1,64 @@ - * * 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 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\File as FileNode; 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\Version; +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; - - /** - * Constructor. - * - * @param string $templatePath - * @param string $generator - * @param string $date - * @param int $lowUpperBound - * @param int $highLowerBound - */ - public function __construct($templatePath, $generator, $date, $lowUpperBound, $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) { - $version = new Version('5.0', dirname(dirname(dirname(dirname(__DIR__))))); - - $this->templatePath = $templatePath; - $this->generator = $generator; - $this->date = $date; - $this->lowUpperBound = $lowUpperBound; - $this->highLowerBound = $highLowerBound; - $this->version = $version->getVersion(); + $this->templatePath = $templatePath; + $this->generator = $generator; + $this->date = $date; + $this->thresholds = $thresholds; + $this->version = Version::id(); + $this->hasBranchCoverage = $hasBranchCoverage; } /** - * @param \Text_Template $template - * @param array $data - * - * @return string + * @param array $data */ - protected function renderItemTemplate(\Text_Template $template, 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 = ''; @@ -99,13 +68,13 @@ protected function renderItemTemplate(\Text_Template $template, array $data) } 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 = ''; @@ -115,13 +84,13 @@ protected function renderItemTemplate(\Text_Template $template, array $data) } 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 = ''; @@ -130,55 +99,91 @@ protected function renderItemTemplate(\Text_Template $template, array $data) $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' => isset($data['icon']) ? $data['icon'] : '', - 'crap' => isset($data['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' => isset($data['testedClassesPercentAsString']) ? $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(); } - /** - * @param \Text_Template $template - * @param AbstractNode $node - */ - protected function setCommonTemplateVariables(\Text_Template $template, AbstractNode $node) + protected function setCommonTemplateVariables(Template $template, AbstractNode $node): void { $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) + protected function breadcrumbs(AbstractNode $node): string { $breadcrumbs = ''; - $path = $node->getPathAsArray(); + $path = $node->pathAsArray(); $pathToRoot = []; $max = count($path); @@ -192,47 +197,47 @@ protected function getBreadcrumbs(AbstractNode $node) 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) + protected function activeBreadcrumb(AbstractNode $node): string { $buffer = sprintf( - '
  • %s
  • ' . "\n", - $node->getName() + ' ' . "\n", + $node->name(), ); if ($node instanceof DirectoryNode) { - $buffer .= '
  • (Dashboard)
  • ' . "\n"; + $buffer .= ' ' . "\n"; } return $buffer; } - protected function getInactiveBreadcrumb(AbstractNode $node, $pathToRoot) + protected function inactiveBreadcrumb(AbstractNode $node, string $pathToRoot): string { return sprintf( - '
  • %s
  • ' . "\n", + ' ' . "\n", $pathToRoot, - $node->getName() + $node->name(), ); } - protected function getPathToRoot(AbstractNode $node) + protected function pathToRoot(AbstractNode $node): string { - $id = $node->getId(); + $id = $node->id(); $depth = substr_count($id, '/'); - if ($id != 'index' && + if ($id !== 'index' && $node instanceof DirectoryNode) { $depth++; } @@ -240,14 +245,15 @@ protected function getPathToRoot(AbstractNode $node) return str_repeat('../', $depth); } - protected function getCoverageBar($percent) + protected function coverageBar(float $percent): string { - $level = $this->getColorLevel($percent); + $level = $this->colorLevel($percent); - $template = new \Text_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)]); @@ -255,44 +261,29 @@ protected function getCoverageBar($percent) return $template->render(); } - /** - * @param int $percent - * - * @return string - */ - protected function getColorLevel($percent) + protected function colorLevel(float $percent): string { - if ($percent <= $this->lowUpperBound) { + if ($percent <= $this->thresholds->lowUpperBound()) { return 'danger'; - } elseif ($percent > $this->lowUpperBound && - $percent < $this->highLowerBound) { + } + + if ($percent > $this->thresholds->lowUpperBound() && + $percent < $this->thresholds->highLowerBound()) { return 'warning'; - } else { - return 'success'; } + + return 'success'; } - /** - * @return string - */ - private function getRuntimeString() + private function runtimeString(): string { $runtime = new Runtime; - $buffer = sprintf( + return sprintf( '%s %s', $runtime->getVendorUrl(), $runtime->getName(), - $runtime->getVersion() + $runtime->getVersion(), ); - - if ($runtime->hasXdebug() && !$runtime->hasPHPDBGCodeCoverage()) { - $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 7cde17559..9df28c3f4 100644 --- a/src/Report/Html/Renderer/Dashboard.php +++ b/src/Report/Html/Renderer/Dashboard.php @@ -1,39 +1,52 @@ - * * 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 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\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\AbstractNode; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; +use SebastianBergmann\Template\Exception; +use SebastianBergmann\Template\Template; /** - * Renders the dashboard for a directory node. + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ -class Dashboard extends Renderer +final class Dashboard extends Renderer { - /** - * @param DirectoryNode $node - * @param string $file - */ - public function render(DirectoryNode $node, $file) + public function render(DirectoryNode $node, string $file): void { - $classes = $node->getClassesAndTraits(); - $template = new \Text_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); @@ -48,67 +61,83 @@ public function render(DirectoryNode $node, $file) 'complexity_class' => $complexity['class'], 'complexity_method' => $complexity['method'], 'class_coverage_distribution' => $coverageDistribution['class'], - 'method_coverage_distribution' => $coverageDistribution['method'] - ] + '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 - * @param string $baseLink + * @param array $classes * - * @return array + * @return array{class: non-empty-string, method: non-empty-string} */ - protected function complexity(array $classes, $baseLink) + private function complexity(array $classes, string $baseLink): array { $result = ['class' => [], 'method' => []]; foreach ($classes as $className => $class) { - foreach ($class['methods'] as $methodName => $method) { - if ($className != '*') { + foreach ($class->methods as $methodName => $method) { + if ($className !== '*') { $methodName = $className . '::' . $methodName; } $result['method'][] = [ - $method['coverage'], - $method['ccn'], - sprintf( - '%s', - str_replace($baseLink, '', $method['link']), - $methodName - ) + $method->coverage, + $method->ccn, + str_replace($baseLink, '', $method->link), + $methodName, + $method->crap, ]; } $result['class'][] = [ - $class['coverage'], - $class['ccn'], - sprintf( - '%s', - str_replace($baseLink, '', $class['link']), - $className - ) + $class->coverage, + $class->ccn, + 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 + * @param array $classes * - * @return array + * @return array{class: non-empty-string, method: non-empty-string} */ - protected function coverageDistribution(array $classes) + private function coverageDistribution(array $classes): array { $result = [ 'class' => [ @@ -123,7 +152,7 @@ protected function coverageDistribution(array $classes) '70-80%' => 0, '80-90%' => 0, '90-100%' => 0, - '100%' => 0 + '100%' => 0, ], 'method' => [ '0%' => 0, @@ -137,69 +166,71 @@ protected function coverageDistribution(array $classes) '70-80%' => 0, '80-90%' => 0, '90-100%' => 0, - '100%' => 0 - ] + '100%' => 0, + ], ]; foreach ($classes as $class) { - foreach ($class['methods'] as $methodName => $method) { - if ($method['coverage'] == 0) { + foreach ($class->methods as $method) { + if ($method->coverage === 0) { $result['method']['0%']++; - } elseif ($method['coverage'] == 100) { + } 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]++; } } - if ($class['coverage'] == 0) { + if ($class->coverage === 0) { $result['class']['0%']++; - } elseif ($class['coverage'] == 100) { + } 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 - * @param string $baseLink + * @param array $classes * - * @return array + * @return array{class: string, method: string} */ - protected function insufficientCoverage(array $classes, $baseLink) + private function insufficientCoverage(array $classes, string $baseLink): array { $leastTestedClasses = []; $leastTestedMethods = []; $result = ['class' => '', 'method' => '']; foreach ($classes as $className => $class) { - foreach ($class['methods'] as $methodName => $method) { - if ($method['coverage'] < $this->highLowerBound) { - if ($className != '*') { + foreach ($class->methods as $methodName => $method) { + if ($method->coverage < $this->thresholds->highLowerBound()) { + $key = $methodName; + + if ($className !== '*') { $key = $className . '::' . $methodName; - } else { - $key = $methodName; } - $leastTestedMethods[$key] = $method['coverage']; + $leastTestedMethods[$key] = $method->coverage; } } - if ($class['coverage'] < $this->highLowerBound) { - $leastTestedClasses[$className] = $class['coverage']; + if ($class->coverage < $this->thresholds->highLowerBound()) { + $leastTestedClasses[$className] = $class->coverage; } } @@ -209,21 +240,21 @@ protected function insufficientCoverage(array $classes, $baseLink) foreach ($leastTestedClasses as $className => $coverage) { $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) { - list($class, $method) = explode('::', $methodName); + [$class, $method] = explode('::', $methodName); $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, ); } @@ -231,72 +262,69 @@ protected function insufficientCoverage(array $classes, $baseLink) } /** - * Returns the project risks according to the CRAP index. - * - * @param array $classes - * @param string $baseLink + * @param array $classes * - * @return array + * @return array{class: string, method: string} */ - protected function projectRisks(array $classes, $baseLink) + private function projectRisks(array $classes, string $baseLink): array { $classRisks = []; $methodRisks = []; $result = ['class' => '', 'method' => '']; foreach ($classes as $className => $class) { - foreach ($class['methods'] as $methodName => $method) { - if ($method['coverage'] < $this->highLowerBound && - $method['ccn'] > 1) { - if ($className != '*') { + foreach ($class->methods as $methodName => $method) { + if ($method->coverage < $this->thresholds->highLowerBound() && $method->ccn > 1) { + $key = $methodName; + + if ($className !== '*') { $key = $className . '::' . $methodName; - } else { - $key = $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); + uasort($classRisks, static function (ProcessedClassType|ProcessedTraitType $a, ProcessedClassType|ProcessedTraitType $b) + { + return ((int) ($a->crap) <=> (int) ($b->crap)) * -1; + }); + uasort($methodRisks, static function (ProcessedMethodType $a, ProcessedMethodType $b) + { + return ((int) ($a->crap) <=> (int) ($b->crap)) * -1; + }); - foreach ($classRisks as $className => $crap) { + foreach ($classRisks as $className => $class) { $result['class'] .= sprintf( - ' %s%d' . "\n", - str_replace($baseLink, '', $classes[$className]['link']), + ' %s%.1f%%%d%d' . "\n", + str_replace($baseLink, '', $classes[$className]->link), $className, - $crap + $class->coverage, + $class->ccn, + $class->crap, ); } - foreach ($methodRisks as $methodName => $crap) { - list($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']), + ' %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) - { - return sprintf( - '
  • %s
  • ' . "\n" . - '
  • (Dashboard)
  • ' . "\n", - $node->getName() - ); - } } diff --git a/src/Report/Html/Renderer/Directory.php b/src/Report/Html/Renderer/Directory.php index a4b1b96f4..1d7334b3a 100644 --- a/src/Report/Html/Renderer/Directory.php +++ b/src/Report/Html/Renderer/Directory.php @@ -1,101 +1,123 @@ - * * 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 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 */ -class Directory extends Renderer +final class Directory extends Renderer { - /** - * @param DirectoryNode $node - * @param string $file - */ - public function render(DirectoryNode $node, $file) + public function render(DirectoryNode $node, string $file): void { - $template = new \Text_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(), - 'items' => $items - ] + 'id' => $node->id(), + 'items' => $items, + ], ); - $template->renderTo($file); + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } } - /** - * @param Node $node - * @param bool $total - * - * @return string - */ - protected function renderItem(Node $node, $total = false) + private function renderItem(Node $node, bool $total = false): string { $data = [ - 'numClasses' => $node->getNumClassesAndTraits(), - 'numTestedClasses' => $node->getNumTestedClassesAndTraits(), - 'numMethods' => $node->getNumMethods(), - 'numTestedMethods' => $node->getNumTestedMethods(), - 'linesExecutedPercent' => $node->getLineExecutedPercent(false), - 'linesExecutedPercentAsString' => $node->getLineExecutedPercent(), - 'numExecutedLines' => $node->getNumExecutedLines(), - 'numExecutableLines' => $node->getNumExecutableLines(), - 'testedMethodsPercent' => $node->getTestedMethodsPercent(false), - 'testedMethodsPercentAsString' => $node->getTestedMethodsPercent(), - '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( '%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(), ); - - $data['icon'] = ' '; } else { $data['name'] = sprintf( '%s', - $node->getName(), - $node->getName() + $node->name(), + $node->name(), ); - - $data['icon'] = ' '; } } + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'directory_item_branch.html' : 'directory_item.html'); + return $this->renderItemTemplate( - new \Text_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 5461c9e76..b2808fbd0 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -1,211 +1,432 @@ - * * 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 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\Data\ProcessedBranchCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Data\ProcessedPathCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +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. + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ -class File extends Renderer +final class File extends Renderer { /** - * @var int + * @var array */ - private $htmlspecialcharsFlags; + 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; /** - * Constructor. - * - * @param string $templatePath - * @param string $generator - * @param string $date - * @param int $lowUpperBound - * @param int $highLowerBound + * @var array> */ - public function __construct($templatePath, $generator, $date, $lowUpperBound, $highLowerBound) - { - parent::__construct( - $templatePath, - $generator, - $date, - $lowUpperBound, - $highLowerBound - ); - - $this->htmlspecialcharsFlags = ENT_COMPAT; + private static array $formattedSourceCache = []; - $this->htmlspecialcharsFlags = $this->htmlspecialcharsFlags | ENT_HTML401 | ENT_SUBSTITUTE; - } - - /** - * @param FileNode $node - * @param string $file - */ - public function render(FileNode $node, $file) + public function render(FileNode $node, string $file): void { - $template = new \Text_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, + ); + } + } } - /** - * @param FileNode $node - * - * @return string - */ - protected function renderItems(FileNode $node) + private function renderItems(FileNode $node): string { - $template = new \Text_Template($this->templatePath . 'file_item.html', '{{', '}}'); + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_item_branch.html' : 'file_item.html'); + $template = new Template($templateName, '{{', '}}'); - $methodItemTemplate = new \Text_Template( - $this->templatePath . 'method_item.html', + $methodTemplateName = $this->templatePath . ($this->hasBranchCoverage ? 'method_item_branch.html' : 'method_item.html'); + $methodItemTemplate = new Template( + $methodTemplateName, '{{', - '}}' + '}}', ); $items = $this->renderItemTemplate( $template, [ - 'name' => 'Total', - 'numClasses' => $node->getNumClassesAndTraits(), - 'numTestedClasses' => $node->getNumTestedClassesAndTraits(), - 'numMethods' => $node->getNumMethods(), - 'numTestedMethods' => $node->getNumTestedMethods(), - 'linesExecutedPercent' => $node->getLineExecutedPercent(false), - 'linesExecutedPercentAsString' => $node->getLineExecutedPercent(), - 'numExecutedLines' => $node->getNumExecutedLines(), - 'numExecutableLines' => $node->getNumExecutableLines(), - 'testedMethodsPercent' => $node->getTestedMethodsPercent(false), - 'testedMethodsPercentAsString' => $node->getTestedMethodsPercent(), - '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; } /** - * @param array $items - * @param \Text_Template $template - * @param \Text_Template $methodItemTemplate - * - * @return string + * @param array $items */ - protected function renderTraitOrClassItems(array $items, \Text_Template $template, \Text_Template $methodItemTemplate) + private function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string { - if (empty($items)) { - return ''; - } - $buffer = ''; + if ($items === []) { + return $buffer; + } + foreach ($items as $name => $item) { - $numMethods = count($item['methods']); + $numMethods = 0; $numTestedMethods = 0; - foreach ($item['methods'] as $method) { - if ($method['executedLines'] == $method['executableLines']) { - $numTestedMethods++; + foreach ($item->methods as $method) { + if ($method->executableLines > 0) { + $numMethods++; + + if ($method->executedLines === $method->executableLines) { + $numTestedMethods++; + } } } - if ($item['executableLines'] > 0) { + if ($item->executableLines > 0) { $numClasses = 1; - $numTestedClasses = $numTestedMethods == $numMethods ? 1 : 0; - $linesExecutedPercentAsString = Util::percent( - $item['executedLines'], - $item['executableLines'], - true - ); + $numTestedClasses = $numTestedMethods === $numMethods ? 1 : 0; + $linesExecutedPercentAsString = Percentage::fromFractionAndTotal( + $item->executedLines, + $item->executableLines, + )->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' => $name, - 'numClasses' => $numClasses, - 'numTestedClasses' => $numTestedClasses, - 'numMethods' => $numMethods, - 'numTestedMethods' => $numTestedMethods, - 'linesExecutedPercent' => Util::percent( - $item['executedLines'], - $item['executableLines'], - false - ), + 'name' => $this->abbreviateClassName($name), + 'numClasses' => $numClasses, + 'numTestedClasses' => $numTestedClasses, + 'numMethods' => $numMethods, + 'numTestedMethods' => $numTestedMethods, + 'linesExecutedPercent' => Percentage::fromFractionAndTotal( + $item->executedLines, + $item->executableLines, + )->asFloat(), 'linesExecutedPercentAsString' => $linesExecutedPercentAsString, - 'numExecutedLines' => $item['executedLines'], - 'numExecutableLines' => $item['executableLines'], - 'testedMethodsPercent' => Util::percent( - $numTestedMethods, - $numMethods, - false - ), - 'testedMethodsPercentAsString' => Util::percent( - $numTestedMethods, - $numMethods, - true - ), - 'testedClassesPercent' => Util::percent( - $numTestedMethods == $numMethods ? 1 : 0, - 1, - false - ), - 'testedClassesPercentAsString' => Util::percent( - $numTestedMethods == $numMethods ? 1 : 0, - 1, - true - ), - 'crap' => $item['crap'] - ] + 'numExecutedLines' => $item->executedLines, + 'numExecutableLines' => $item->executableLines, + '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) { + foreach ($item->methods as $method) { $buffer .= $this->renderFunctionOrMethodItem( $methodItemTemplate, $method, - ' ' + ' ', ); } } @@ -214,14 +435,11 @@ protected function renderTraitOrClassItems(array $items, \Text_Template $templat } /** - * @param array $functions - * @param \Text_Template $template - * - * @return string + * @param array $functions */ - protected function renderFunctionItems(array $functions, \Text_Template $template) + private function renderFunctionItems(array $functions, Template $template): string { - if (empty($functions)) { + if ($functions === []) { return ''; } @@ -230,71 +448,85 @@ protected function renderFunctionItems(array $functions, \Text_Template $templat foreach ($functions as $function) { $buffer .= $this->renderFunctionOrMethodItem( $template, - $function + $function, ); } return $buffer; } - /** - * @param \Text_Template $template - * - * @return string - */ - protected function renderFunctionOrMethodItem(\Text_Template $template, array $item, $indent = '') + private function renderFunctionOrMethodItem(Template $template, ProcessedFunctionType|ProcessedMethodType $item, string $indent = ''): string { - $numTestedItems = $item['executedLines'] == $item['executableLines'] ? 1 : 0; + $numMethods = 0; + $numTestedMethods = 0; + + if ($item->executableLines > 0) { + $numMethods = 1; + + if ($item->executedLines === $item->executableLines) { + $numTestedMethods = 1; + } + } + + $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']), - isset($item['functionName']) ? $item['functionName'] : $item['methodName'] - ), - 'numMethods' => 1, - 'numTestedMethods' => $numTestedItems, - 'linesExecutedPercent' => Util::percent( - $item['executedLines'], - $item['executableLines'], - false + $item->startLine, + htmlspecialchars($item->signature, self::HTML_SPECIAL_CHARS_FLAGS), + $item instanceof ProcessedFunctionType ? $item->functionName : $item->methodName, ), - 'linesExecutedPercentAsString' => Util::percent( - $item['executedLines'], - $item['executableLines'], - true - ), - 'numExecutedLines' => $item['executedLines'], - 'numExecutableLines' => $item['executableLines'], - 'testedMethodsPercent' => Util::percent( - $numTestedItems, - 1, - false - ), - 'testedMethodsPercentAsString' => Util::percent( - $numTestedItems, - 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, + ], ); } - /** - * @param FileNode $node - * - * @return string - */ - protected function renderSource(FileNode $node) + 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; @@ -304,114 +536,485 @@ protected function renderSource(FileNode $node) $popoverTitle = ''; if (array_key_exists($i, $coverageData)) { - $numTests = count($coverageData[$i]); + $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"'; - break; - - case 'medium': - $testCSS = ' class="covered-by-medium-tests"'; - break; - - default: - $testCSS = ' class="covered-by-large-tests"'; - break; - } - break; - - case 1: - case 2: - $testCSS = ' class="warning"'; - break; - - case 3: - $testCSS = ' class="danger"'; - break; - - case 4: - $testCSS = ' class="danger"'; - break; - - default: - $testCSS = ''; + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); + } + + $popoverContent .= '
      '; + $trClass = $lineCss . ' popin'; + } + } + + $popover = ''; + + if ($popoverTitle !== '') { + $popover = sprintf( + ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', + $popoverTitle, + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } + + $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover); + + $i++; + } + + $linesTemplate->setVar(['lines' => $lines]); + + return $linesTemplate->render(); + } + + private function renderSourceWithBranchCoverage(FileNode $node): string + { + $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); + $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); + + $functionCoverageData = $node->functionCoverageData(); + $testData = $node->testData(); + $codeLines = $this->loadFile($node->pathAsString()); + + $lineData = []; + + foreach (array_keys($codeLines) as $line) { + $lineData[$line + 1] = [ + 'includedInBranches' => 0, + 'includedInHitBranches' => 0, + 'tests' => [], + ]; + } + + /** @var ProcessedFunctionCoverageData $method */ + foreach ($functionCoverageData as $method) { + /** @var ProcessedBranchCoverageData $branch */ + foreach ($method->branches as $branch) { + foreach (range($branch->line_start, $branch->line_end) as $line) { + if (!isset($lineData[$line])) { // blank line at end of file is sometimes included here + continue; + } + + $lineData[$line]['includedInBranches']++; + + if ($branch->hit !== []) { + $lineData[$line]['includedInHitBranches']++; + $lineData[$line]['tests'] = array_unique(array_merge($lineData[$line]['tests'], $branch->hit)); + } + } + } + } + + $lines = ''; + $i = 1; + + /** @var string $line */ + foreach ($codeLines as $line) { + $trClass = ''; + $popover = ''; + + if ($lineData[$i]['includedInBranches'] > 0) { + $lineCss = 'success'; + + if ($lineData[$i]['includedInHitBranches'] === 0) { + $lineCss = 'danger'; + } elseif ($lineData[$i]['includedInHitBranches'] !== $lineData[$i]['includedInBranches']) { + $lineCss = 'warning'; + } + + $popoverContent = '
        '; + + 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.'; + + 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 renderSourceWithPathCoverage(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] = [ + 'includedInPaths' => [], + 'includedInHitPaths' => [], + 'tests' => [], + ]; + } + + /** @var ProcessedFunctionCoverageData $method */ + foreach ($functionCoverageData as $method) { + /** @var ProcessedPathCoverageData $path */ + 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) - ); + 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); + + /** @var ProcessedFunctionCoverageData $methodData */ + foreach ($coverageData as $methodName => $methodData) { + $branchStructure = ''; + + /** @var ProcessedBranchCoverageData $branch */ + 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(ProcessedBranchCoverageData $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 = '