├── CHANGELOG.md ├── LICENSE ├── README.md ├── Yoast ├── Docs │ ├── Commenting │ │ ├── CodeCoverageIgnoreDeprecatedStandard.xml │ │ ├── CoversTagStandard.xml │ │ ├── FileCommentStandard.xml │ │ └── TestsHaveCoversTagStandard.xml │ ├── Files │ │ ├── FileNameStandard.xml │ │ └── TestDoublesStandard.xml │ ├── NamingConventions │ │ ├── NamespaceNameStandard.xml │ │ ├── ObjectNameDepthStandard.xml │ │ └── ValidHookNameStandard.xml │ ├── Tools │ │ └── BrainMonkeyRaceConditionStandard.xml │ ├── WhiteSpace │ │ └── FunctionSpacingStandard.xml │ └── Yoast │ │ └── JsonEncodeAlternativeStandard.xml ├── Reports │ └── Threshold.php ├── Sniffs │ ├── Commenting │ │ ├── CodeCoverageIgnoreDeprecatedSniff.php │ │ ├── CoversTagSniff.php │ │ ├── FileCommentSniff.php │ │ └── TestsHaveCoversTagSniff.php │ ├── Files │ │ ├── FileNameSniff.php │ │ └── TestDoublesSniff.php │ ├── NamingConventions │ │ ├── NamespaceNameSniff.php │ │ ├── ObjectNameDepthSniff.php │ │ └── ValidHookNameSniff.php │ ├── Tools │ │ └── BrainMonkeyRaceConditionSniff.php │ ├── WhiteSpace │ │ └── FunctionSpacingSniff.php │ └── Yoast │ │ └── JsonEncodeAlternativeSniff.php ├── Utils │ ├── CustomPrefixesTrait.php │ ├── PSR4PathsTrait.php │ ├── PathHelper.php │ └── PathValidationHelper.php └── ruleset.xml ├── autoload-bootstrap.php ├── composer.json └── phpunit-bootstrap.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Yoast 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yoast Coding Standards 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/Yoast/yoastcs/badge.svg?branch=develop)](https://coveralls.io/github/Yoast/yoastcs?branch=develop) 4 | 5 | Yoast Coding Standards (YoastCS) is a project with rulesets for code style and quality tools to be used in Yoast projects. 6 | 7 | ## Installation 8 | 9 | ### Standalone 10 | 11 | Standards are provided as a [Composer](https://getcomposer.org/) package and can be installed with: 12 | 13 | ```bash 14 | composer global config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true 15 | composer global require --dev yoast/yoastcs:"^3.0" 16 | ``` 17 | 18 | ### As dependency 19 | 20 | To include standards as part of a project require them as development dependencies: 21 | 22 | ```bash 23 | composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true 24 | composer require --dev yoast/yoastcs:"^3.0" 25 | ``` 26 | 27 | Composer will automatically install dependencies and register YoastCS and other external standards with PHP_CodeSniffer. 28 | 29 | ## Tools provided via YoastCS 30 | 31 | * PHP Parallel Lint 32 | * PHP_CodeSniffer and select standards for PHP_CodeSniffer, including a number of Yoast native sniffs. 33 | 34 | 35 | ## PHP Parallel Lint 36 | 37 | [PHP Parallel Lint](https://github.com/php-parallel-lint/PHP-Parallel-Lint/) is a tool to lint PHP files against parse errors. 38 | 39 | PHP Parallel Lint does not use a configuration file, so [command-line options](https://github.com/php-parallel-lint/PHP-Parallel-Lint/#command-line-options) need to be passed to configure what files to scan. 40 | 41 | It is best practice within the Yoast projects, to add a script to the `composer.json` file which encapsules the command with the appropriate command-line options to ensure that running the tool will yield the same results each time. 42 | 43 | Typically, (a variation on) the following snippet would be added to the `composer.json` file for a project: 44 | ```json 45 | "scripts" : { 46 | "lint": [ 47 | "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude .git" 48 | ] 49 | } 50 | ``` 51 | 52 | 53 | ## PHP Code Sniffer 54 | 55 | Set of [PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) rules. 56 | 57 | Severity levels: 58 | 59 | - error level issues are considered mandatory to fix in Yoast projects and enforced in continuous integration 60 | - warning level issues are considered recommended to fix 61 | 62 | ### The YoastCS Standard 63 | 64 | The `Yoast` standard for PHP_CodeSniffer is comprised of the following: 65 | * The `WordPress` ruleset from the [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards) implementing the official [WordPress PHP Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/), with some [select exclusions](https://github.com/Yoast/yoastcs/blob/develop/Yoast/ruleset.xml#L29-L75). 66 | * The [`PHPCompatibilityWP`](https://github.com/PHPCompatibility/PHPCompatibilityWP) ruleset which checks code for PHP cross-version compatibility while preventing false positives for functionality polyfilled within WordPress. 67 | * The [`VariableAnalysis`](https://github.com/sirbrillig/phpcs-variable-analysis/) ruleset. 68 | * Select additional sniffs taken from [`PHP_CodeSniffer`](https://github.com/PHPCSStandards/PHP_CodeSniffer). 69 | * Select additional sniffs taken from [`PHPCSExtra`](https://github.com/PHPCSStandards/PHPCSExtra). 70 | * Select additional sniffs taken from [`SlevomatCodingStandard`](https://github.com/slevomat/coding-standard). 71 | * Select additional sniffs taken from [WordPress VIP Coding Standards](https://github.com/Automattic/VIP-Coding-Standards/). 72 | * A number of custom Yoast specific sniffs. 73 | 74 | Files within version management and dependency related directories, such as the Composer `vendor` directory, are excluded from the scans by default. 75 | 76 | #### Sniffs 77 | 78 | To obtain a list of all sniffs used within YoastCS: 79 | ```bash 80 | "vendor/bin/phpcs" -e --standard=Yoast 81 | ``` 82 | 83 | #### Sniff Documentation 84 | 85 | Not all sniffs have documentation available about what they sniff for, but for those which do, this documentation can be viewed from the command-line: 86 | ```bash 87 | "vendor/bin/phpcs" --standard=Yoast --generator=Text 88 | ``` 89 | 90 | ### Running the sniffs 91 | 92 | #### Command line 93 | 94 | ```bash 95 | "vendor/bin/phpcs" --extensions=php /path/to/folder/ 96 | ``` 97 | 98 | For more command-line options, please have a read through the [PHP_CodeSniffer documentation](https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki/Usage). 99 | 100 | #### Yoast plugin repositories 101 | 102 | All Yoast plugin repositories contain a `[.]phpcs.xml.dist` file which contains the repository specific configuration. 103 | 104 | From the root of these repositories, you can run PHPCS by using: 105 | ```bash 106 | composer check-cs-warnings 107 | ``` 108 | 109 | #### PhpStorm 110 | 111 | Refer to [Using PHP Code Sniffer Tool](https://www.jetbrains.com/phpstorm/help/using-php-code-sniffer-tool.html) in the PhpStorm documentation. 112 | 113 | After installation, the `Yoast` standard will be available as a choice in PHP Code Sniffer Validation inspection. 114 | 115 | ### The YoastCS "Threshold" report 116 | 117 | The YoastCS package includes a custom `YoastCS\Yoast\Reports\Threshold` report for PHP_CodeSniffer to compare the current PHPCS run results with predefined "threshold" settings. 118 | 119 | The report will look in the runtime environment for the following two environment variables and will take the values of those as the thresholds to compare the PHPCS run results against: 120 | * `YOASTCS_THRESHOLD_ERRORS` 121 | * `YOASTCS_THRESHOLD_WARNINGS` 122 | 123 | If the environment variables are not set, they will default to 0 for both, i.e. no errors or warnings allowed. 124 | 125 | The report will not print any details about the issues found, it just shows a summary based on the thresholds: 126 | ``` 127 | PHP CODE SNIFFER THRESHOLD COMPARISON 128 | ------------------------------------------------------------------------------------------------------------------------ 129 | Coding standards ERRORS: 148/130. 130 | Coding standards WARNINGS: 539/539. 131 | 132 | Please fix any errors introduced in your code and run PHPCS again to verify. 133 | Please fix any warnings introduced in your code and run PHPCS again to verify. 134 | ``` 135 | 136 | After the report has run, a global `YOASTCS_ABOVE_THRESHOLD` constant (boolean) will be available which can be used in calling scripts. 137 | 138 | To use this report, run PHPCS with the following command-line argument: `--report=YoastCS\Yoast\Reports\Threshold`. 139 | _Note: depending on the OS the command is run on, the backslashes in the report name may need to be escaped (doubled)._ 140 | 141 | For those Yoast plugin repositories which use thresholds, the status can be checked locally by running: 142 | ```bash 143 | composer check-cs-thresholds 144 | ``` 145 | 146 | ## Changelog 147 | 148 | The changelog for this package can be found in the [CHANGELOG.md](https://github.com/Yoast/yoastcs/blob/develop/CHANGELOG.md) file. 149 | -------------------------------------------------------------------------------- /Yoast/Docs/Commenting/CodeCoverageIgnoreDeprecatedStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | @deprecated x.x 16 | * @codeCoverageIgnore 17 | */ 18 | function deprecated_function() {} 19 | ]]> 20 | 21 | 22 | @deprecated x.x 25 | */ 26 | function deprecated_function() {} 27 | ]]> 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Yoast/Docs/Commenting/CoversTagStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 14 | 15 | 16 | 17 | Class_Name::method_name 23 | */ 24 | function test_something() {} 25 | } 26 | ]]> 27 | 28 | 29 | () 35 | */ 36 | function test_something() {} 37 | } 38 | ]]> 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 59 | 60 | 61 | Name\Space\function_name 69 | */ 70 | function test_something() {} 71 | } 72 | ]]> 73 | 74 | 75 | 76 | 79 | 80 | 81 | 82 | @covers ::globalFunction 88 | */ 89 | function test_something() {} 90 | 91 | /** 92 | * Testing... 93 | * 94 | * @coversNothing 95 | */ 96 | function test_something_else() {} 97 | } 98 | ]]> 99 | 100 | 101 | @coversNothing 107 | * @covers ::globalFunction 108 | */ 109 | function test_something() {} 110 | } 111 | ]]> 112 | 113 | 114 | 115 | tags is deprecated since PHPUnit 9.0 and support has been removed in PHPUnit 10.0. 117 | These type of annotations should not be used. 118 | ]]> 119 | 120 | 121 | 122 | ::globalFunction 128 | * @covers \ClassName::methodName 129 | */ 130 | function test_something() {} 131 | } 132 | ]]> 133 | 134 | 135 | \ClassName:: 141 | * @covers \ClassName:: 142 | * @covers \ClassName 143 | */ 144 | function test_something() {} 145 | } 146 | ]]> 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /Yoast/Docs/Commenting/FileCommentStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | namespace Yoast\A\B; 17 | 18 | /** 19 | * Class docblock. 20 | */ 21 | class { 22 | ... 23 | ]]> 24 | 25 | 26 | /** 29 | * File comment. 30 | */ 31 | 32 | namespace Yoast\A\B; 33 | 34 | /** 35 | * Class docblock. 36 | */ 37 | class { 38 | ... 39 | ]]> 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | /** 53 | * File comment. 54 | */ 55 | 56 | /** 57 | * Class docblock. 58 | */ 59 | class { 60 | ... 61 | ]]> 62 | 63 | 64 | 67 | /** 68 | * Class docblock. 69 | */ 70 | class { 71 | ... 72 | ]]> 73 | 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | /** 86 | * File comment. 87 | */ 88 | ]]> 89 | 90 | 91 | /* 94 | * File comment. 95 | */ 96 | ]]> 97 | 98 | 99 | 100 | 101 | 104 | 105 | 106 | 107 | 109 | /** 110 | * File comment. 111 | */ 112 | ]]> 113 | 114 | 115 | 117 | 118 | 119 | /** 120 | * File comment. 121 | */ 122 | ]]> 123 | 124 | 125 | 126 | 127 | 130 | 131 | 132 | 133 | 138 | 139 | echo $something; 140 | ]]> 141 | 142 | 143 | 148 | echo $something; 149 | ]]> 150 | 151 | 152 | 153 | 154 | 157 | 158 | 159 | 160 | @package Yoast\Package 166 | */ 167 | ]]> 168 | 169 | 170 | 176 | 177 | 178 | 179 | 180 | 183 | 184 | 185 | 186 | @package Yoast\Package 192 | */ 193 | ]]> 194 | 195 | 196 | @package 202 | */ 203 | ]]> 204 | 205 | 206 | 207 | 208 | -------------------------------------------------------------------------------- /Yoast/Docs/Commenting/TestsHaveCoversTagStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 33 | 34 | 35 | 36 | 37 | 50 | 51 | 52 | 60 | 61 | 62 | 63 | 64 | 76 | 77 | 78 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Yoast/Docs/Files/FileNameStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | file-name.php 15 | ]]> 16 | 17 | 18 | File_Name.php 20 | ]]> 21 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | utils.php --> 33 | Utils {} 35 | ]]> 36 | 37 | 38 | class-wpseo-utils.php --> 40 | 43 | 44 | 45 | 46 | 49 | 50 | 51 | 52 | output-thing-interface.php --> 54 | interface Yoast_Output_Thing {} 56 | ]]> 57 | 58 | 59 | yoast-outline-something.php --> 61 | trait Yoast_Outline_Something {} 63 | ]]> 64 | 65 | 66 | 67 | 76 | 77 | 78 | 79 | Yoast_Output_Thing.php --> 81 | Yoast_Output_Thing {} 83 | ]]> 84 | 85 | 86 | outline-something.php --> 88 | Yoast_Outline_Something {} 90 | ]]> 91 | 92 | 93 | 94 | 97 | 98 | 99 | 100 | functions.php --> 102 | 106 | 107 | 108 | 110 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /Yoast/Docs/Files/TestDoublesStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | Test_Double extends Original_Class { 22 | // Code. 23 | } 24 | ]]> 25 | 26 | 27 | Foo_Class extends Original_Foo_Class { 29 | // Code. 30 | } 31 | ]]> 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Yoast/Docs/NamingConventions/NamespaceNameStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 14 | 15 | Yoast\WP\Plugin\Admin\Forms; 17 | ]]> 18 | 19 | 20 | Admin\Forms; 22 | ]]> 23 | 24 | 25 | 26 | 33 | 34 | 35 | 36 | Admin\Forms; 38 | 39 | namespace Yoast\WP\Plugin\Tests\Admin\Forms; 40 | ]]> 41 | 42 | 43 | Admin\Forms\Type\Sub; 45 | 46 | namespace Yoast\WP\Plugin\Tests\Foo\Bar\Flo\Sub; 47 | ]]> 48 | 49 | 50 | 51 | 66 | 67 | 68 | 69 | admin/forms/file.php --> 71 | Admin\Forms; 73 | ]]> 74 | 75 | 76 | admin/forms/file.php --> 78 | Unrelated; 80 | ]]> 81 | 82 | 83 | 84 | 85 | User_Forms/file.php --> 87 | User_Forms; 89 | ]]> 90 | 91 | 92 | User_forms/file.php --> 94 | user_Forms; 96 | ]]> 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /Yoast/Docs/NamingConventions/ObjectNameDepthStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 14 | 15 | 16 | 17 | Non_Namespaced_Long_Class_Name {} 19 | ]]> 20 | 21 | 22 | Namespaced_Long_Class_Name {} 26 | ]]> 27 | 28 | 29 | 30 | 31 | Short_Class_Name {} 35 | ]]> 36 | 37 | 38 | Namespaced_Too_Long_Class_Name {} 42 | ]]> 43 | 44 | 45 | 46 | 47 | Short_Class_Name_Test 53 | extends TestCase {} 54 | ]]> 55 | 56 | 57 | Namespaced_Too_Long_Class_Name_Test 63 | extends TestCase {} 64 | ]]> 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Yoast/Docs/NamingConventions/ValidHookNameStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 14 | 15 | 'Yoast\WP\Plugin\hook_name', $var ); 17 | ]]> 18 | 19 | 20 | 'Yoast\WP\Plugin\Hook_NAME', $var ); 22 | ]]> 23 | 24 | 25 | 26 | 27 | 'Yoast\WP\Plugin\hook_name', 30 | $var 31 | ); 32 | ]]> 33 | 34 | 35 | 'Yoast\WP\Plugin\some/hook-name', 38 | $var 39 | ); 40 | ]]> 41 | 42 | 43 | 44 | 49 | 50 | 51 | 52 | 'Yoast\WP\Plugin\hook_name' 55 | ); 56 | ]]> 57 | 58 | 59 | 'Yoast\WP\Plugin\long_hook_name_too_long' 62 | ); 63 | ]]> 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Yoast/Docs/Tools/BrainMonkeyRaceConditionStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 14 | 15 | once(); 19 | Functions\expect( "apply_filters" ) 20 | ->once(); 21 | Monkey\Functions\expect( 'do_action' ) 22 | ->once(); 23 | 24 | // Do something to satisfy expectations. 25 | } 26 | 27 | public function test_expectApplied_Done() { 28 | Monkey\Filters\expectApplied( 'filter' ) 29 | ->with( true ); 30 | 31 | Actions\expectDone( "yoast\action_name" ) 32 | ->with( $param ); 33 | 34 | expectDone( 'yoast\action_name' ) 35 | ->with( $param ); 36 | 37 | // Do something to satisfy expectations. 38 | } 39 | } 40 | ]]> 41 | 42 | 43 | once(); 47 | 48 | Filters\ExpectApplied( 'filter_name' ) 49 | ->with( true ); 50 | 51 | // Do something to satisfy expectations. 52 | } 53 | 54 | public function test_mixing_action_expects() { 55 | Expect( 'do_action' /*comment*/)->once(); 56 | 57 | Actions\expectDone( 'yoast\action_name' ) 58 | ->with( $param ); 59 | 60 | // Do something to satisfy expectations. 61 | } 62 | } 63 | ]]> 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Yoast/Docs/WhiteSpace/FunctionSpacingStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | 15 | 16 | /** 17 | * Function docblock. 18 | */ 19 | function func1() { 20 | } 21 | } 22 | ]]> 23 | 24 | 25 | 27 | /** 28 | * Function docblock. 29 | */ 30 | function func1() { 31 | } 32 | } 33 | ]]> 34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 52 | 53 | /** 54 | * Function docblock. 55 | */ 56 | function func2() { 57 | } 58 | } 59 | ]]> 60 | 61 | 62 | 70 | 71 | 72 | /** 73 | * Function docblock. 74 | */ 75 | function func2() { 76 | } 77 | /** 78 | * Function docblock. 79 | */ 80 | function func3() { 81 | } 82 | } 83 | ]]> 84 | 85 | 86 | 87 | 88 | 91 | 92 | 93 | 94 | 102 | } 103 | ]]> 104 | 105 | 106 | 114 | 115 | } 116 | ]]> 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /Yoast/Docs/Yoast/JsonEncodeAlternativeStandard.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | WPSEO_Utils:format_json_encode( $in ); 15 | ]]> 16 | 17 | 18 | wp_json_encode( $in ); 20 | ]]> 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Yoast/Reports/Threshold.php: -------------------------------------------------------------------------------- 1 | >> $report Prepared report data. 71 | * @param File $phpcsFile The file being reported on. 72 | * @param bool $showSources Whether to show the source codes. 73 | * @param int $width Maximum allowed line width. 74 | * 75 | * @return bool 76 | */ 77 | public function generateFileReport( $report, File $phpcsFile, $showSources = false, $width = 80 ) { 78 | if ( \PHP_CODESNIFFER_VERBOSITY === 0 79 | && $report['errors'] === 0 80 | && $report['warnings'] === 0 81 | ) { 82 | // Nothing to do. 83 | return false; 84 | } 85 | 86 | return true; 87 | } 88 | 89 | /** 90 | * Generates a summary of all errors and warnings compares against preset thresholds. 91 | * 92 | * @param string $cachedData Any partial report data that was returned from 93 | * generateFileReport during the run. 94 | * @param int $totalFiles Total number of files processed during the run. 95 | * @param int $totalErrors Total number of errors found during the run. 96 | * @param int $totalWarnings Total number of warnings found during the run. 97 | * @param int $totalFixable Total number of problems that can be fixed. 98 | * @param bool $showSources Whether to show the source codes. 99 | * @param int $width Maximum allowed line width. 100 | * @param bool $interactive Whether PHPCS is being run in interactive mode. 101 | * @param bool $toScreen Whether the report is being printed to screen. 102 | * 103 | * @return void 104 | */ 105 | public function generate( 106 | $cachedData, 107 | $totalFiles, 108 | $totalErrors, 109 | $totalWarnings, 110 | $totalFixable, 111 | $showSources = false, 112 | $width = 80, 113 | $interactive = false, 114 | $toScreen = true 115 | ) { 116 | $error_threshold = (int) \getenv( 'YOASTCS_THRESHOLD_ERRORS' ); 117 | $warning_threshold = (int) \getenv( 'YOASTCS_THRESHOLD_WARNINGS' ); 118 | 119 | echo \PHP_EOL, self::WHITE, 'PHP CODE SNIFFER THRESHOLD COMPARISON', self::RESET, \PHP_EOL; 120 | echo \str_repeat( '-', $width ), \PHP_EOL; 121 | 122 | $color = self::GREEN; 123 | if ( $totalErrors > $error_threshold ) { 124 | $color = self::RED; 125 | } 126 | echo "{$color}Coding standards ERRORS: $totalErrors/$error_threshold.", self::RESET, \PHP_EOL; 127 | 128 | $color = self::GREEN; 129 | if ( $totalWarnings > $warning_threshold ) { 130 | $color = self::YELLOW; 131 | } 132 | echo "{$color}Coding standards WARNINGS: $totalWarnings/$warning_threshold.", self::RESET, \PHP_EOL; 133 | echo \PHP_EOL; 134 | 135 | $above_threshold = false; 136 | $below_threshold = false; 137 | 138 | if ( $totalErrors > $error_threshold ) { 139 | echo self::RED, 'Please fix any errors introduced in your code and run PHPCS again to verify.', self::RESET, \PHP_EOL; 140 | $above_threshold = true; 141 | } 142 | elseif ( $totalErrors < $error_threshold ) { 143 | echo self::GREEN, 'Found less errors than the threshold, great job!', self::RESET, \PHP_EOL; 144 | echo 'Please update the ERRORS threshold in the composer.json file to ', self::GREEN, $totalErrors, '.', self::RESET, \PHP_EOL; 145 | $below_threshold = true; 146 | } 147 | 148 | if ( $totalWarnings > $warning_threshold ) { 149 | echo self::YELLOW, 'Please fix any warnings introduced in your code and run PHPCS again to verify.', self::RESET, \PHP_EOL; 150 | $above_threshold = true; 151 | } 152 | elseif ( $totalWarnings < $warning_threshold ) { 153 | echo self::GREEN, 'Found less warnings than the threshold, great job!', self::RESET, \PHP_EOL; 154 | echo 'Please update the WARNINGS threshold in the composer.json file to ', self::GREEN, $totalWarnings, '.', self::RESET, \PHP_EOL; 155 | $below_threshold = true; 156 | } 157 | 158 | if ( $above_threshold === false ) { 159 | echo \PHP_EOL; 160 | echo 'Coding standards checks have passed!', \PHP_EOL; 161 | } 162 | 163 | // Make the threshold comparison outcomes available to the calling script. 164 | // The conditional define is only so as to make the method testable. 165 | $exact_match = ( $above_threshold === false && $below_threshold === false ); 166 | if ( \defined( 'PHP_CODESNIFFER_IN_TESTS' ) === false ) { 167 | \define( 'YOASTCS_ABOVE_THRESHOLD', $above_threshold ); 168 | \define( 'YOASTCS_THRESHOLD_EXACT_MATCH', $exact_match ); 169 | } 170 | else { 171 | // Only used in the tests to verify the above constants are being set correctly. 172 | echo \PHP_EOL; 173 | echo 'YOASTCS_ABOVE_THRESHOLD: ', ( $above_threshold === true ) ? 'true' : 'false', \PHP_EOL; 174 | echo 'YOASTCS_THRESHOLD_EXACT_MATCH: ', ( $exact_match === true ) ? 'true' : 'false', \PHP_EOL; 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Commenting/CodeCoverageIgnoreDeprecatedSniff.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function register() { 25 | $targets = Tokens::$ooScopeTokens; 26 | // Ignore interfaces as they can't contain code. Ignore anon classes as they are normally nested in another construct. 27 | unset( $targets[ \T_ANON_CLASS ], $targets[ \T_INTERFACE ] ); 28 | 29 | $targets[ \T_FUNCTION ] = \T_FUNCTION; 30 | 31 | return $targets; 32 | } 33 | 34 | /** 35 | * Processes this test, when one of its tokens is encountered. 36 | * 37 | * @param File $phpcsFile The file being scanned. 38 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 39 | * 40 | * @return void 41 | */ 42 | public function process( File $phpcsFile, $stackPtr ) { 43 | 44 | $tokens = $phpcsFile->getTokens(); 45 | 46 | if ( $tokens[ $stackPtr ]['code'] === \T_FUNCTION ) { 47 | $ignore = Tokens::$methodPrefixes; 48 | } 49 | else { 50 | $ignore = Collections::classModifierKeywords(); 51 | } 52 | $ignore[ \T_WHITESPACE ] = \T_WHITESPACE; 53 | 54 | for ( $commentEnd = ( $stackPtr - 1 ); $commentEnd >= 0; $commentEnd-- ) { 55 | if ( isset( $ignore[ $tokens[ $commentEnd ]['code'] ] ) === true ) { 56 | continue; 57 | } 58 | 59 | if ( isset( $tokens[ $commentEnd ]['attribute_opener'] ) === true ) { 60 | $commentEnd = $tokens[ $commentEnd ]['attribute_opener']; 61 | continue; 62 | } 63 | 64 | break; 65 | } 66 | 67 | if ( $tokens[ $commentEnd ]['code'] !== \T_DOC_COMMENT_CLOSE_TAG ) { 68 | // Function without (proper) docblock. Not our concern. 69 | return; 70 | } 71 | 72 | $commentStart = $tokens[ $commentEnd ]['comment_opener']; 73 | 74 | $deprecated = false; 75 | foreach ( $tokens[ $commentStart ]['comment_tags'] as $tag ) { 76 | if ( $tokens[ $tag ]['content'] === '@deprecated' ) { 77 | $deprecated = true; 78 | break; 79 | } 80 | } 81 | 82 | if ( $deprecated === false ) { 83 | // Not a deprecated function/OO structure. 84 | return; 85 | } 86 | 87 | $codeCoverageIgnore = false; 88 | foreach ( $tokens[ $commentStart ]['comment_tags'] as $tag ) { 89 | if ( $tokens[ $tag ]['content'] === '@codeCoverageIgnore' ) { 90 | $codeCoverageIgnore = true; 91 | break; 92 | } 93 | } 94 | 95 | if ( $codeCoverageIgnore === true ) { 96 | // Docblock contains the @codeCoverageIgnore tag. 97 | return; 98 | } 99 | 100 | $hasTagAsString = $phpcsFile->findNext( \T_DOC_COMMENT_STRING, ( $commentStart + 1 ), $commentEnd, false, 'codeCoverageIgnore' ); 101 | if ( $hasTagAsString !== false ) { 102 | $prev = $phpcsFile->findPrevious( \T_DOC_COMMENT_WHITESPACE, ( $hasTagAsString - 1 ), $commentStart, true ); 103 | if ( $tokens[ $prev ]['code'] === \T_DOC_COMMENT_STAR ) { 104 | $fix = $phpcsFile->addFixableError( 105 | 'The `codeCoverageIgnore` annotation in the function docblock needs to be prefixed with an `@`.', 106 | $hasTagAsString, 107 | 'NotTag' 108 | ); 109 | if ( $fix === true ) { 110 | $phpcsFile->fixer->addContentBefore( $hasTagAsString, '@' ); 111 | } 112 | 113 | return; 114 | } 115 | } 116 | 117 | // If we're still here, the tag is missing. 118 | $phpcsFile->addError( 119 | 'The function is marked as deprecated, but the docblock does not contain a `@codeCoverageIgnore` annotation.', 120 | $stackPtr, 121 | 'Missing' 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Commenting/CoversTagSniff.php: -------------------------------------------------------------------------------- 1 | [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\\\\)*(?P>OOName)(?:|::<[!]?(?:public|protected|private)>|::(?(?!public$|protected$|private$)(?P>OOName)))?|::(?P>functionName)|::<[!]?(?:public|protected|private)>|\\\\?(?:(?P>OOName)\\\\)+(?P>functionName))'; 29 | 30 | /** 31 | * Regex to check for deprecated `@covers ClassName` tag format. 32 | * 33 | * @link https://github.com/sebastianbergmann/phpunit/issues/3630 PHPUnit 9.0 deprecation. 34 | * @link https://github.com/sebastianbergmann/phpunit/issues/3631 PHPUnit 10.0 removal. 35 | * 36 | * @var string 37 | */ 38 | private const DEPRECATED_FORMAT_EXTENDED = '`^(?:\\\\)?(?:(?[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\\\\)*(?P>OOName)$`'; 39 | 40 | /** 41 | * Regex to check for deprecated `@covers *::<[!]public|protected|private>` tag format. 42 | * 43 | * @link https://github.com/sebastianbergmann/phpunit/issues/3630 PHPUnit 9.0 deprecation. 44 | * @link https://github.com/sebastianbergmann/phpunit/issues/3631 PHPUnit 10.0 removal. 45 | * 46 | * @var string 47 | */ 48 | private const DEPRECATED_FORMAT_VISIBILITY = '`::<[!]?(?:public|protected|private)>$`'; 49 | 50 | /** 51 | * Base error message. 52 | * 53 | * Will be enhanced during the run. 54 | * 55 | * @var string 56 | */ 57 | private const ERROR_MSG = 'Invalid @covers annotation found.'; 58 | 59 | /** 60 | * Returns an array of tokens this test wants to listen for. 61 | * 62 | * @return array 63 | */ 64 | public function register() { 65 | return [ 66 | \T_DOC_COMMENT_OPEN_TAG, 67 | ]; 68 | } 69 | 70 | /** 71 | * Processes this test, when one of its tokens is encountered. 72 | * 73 | * @param File $phpcsFile The file being scanned. 74 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 75 | * 76 | * @return void 77 | */ 78 | public function process( File $phpcsFile, $stackPtr ) { 79 | $tokens = $phpcsFile->getTokens(); 80 | 81 | /* 82 | * Find all relevant tags and check for common mistakes in the tag format. 83 | */ 84 | $firstCoversTag = false; 85 | $coversTags = []; 86 | $coversNothingTags = []; 87 | foreach ( $tokens[ $stackPtr ]['comment_tags'] as $tag ) { 88 | if ( $tokens[ $tag ]['content'] === '@coversNothing' ) { 89 | $coversNothingTags[] = $tag; 90 | continue; 91 | } 92 | 93 | if ( $tokens[ $tag ]['content'] !== '@covers' ) { 94 | continue; 95 | } 96 | 97 | if ( $firstCoversTag === false ) { 98 | $firstCoversTag = $tag; 99 | } 100 | 101 | // Found a @covers tag. 102 | $next = $phpcsFile->findNext( \T_DOC_COMMENT_WHITESPACE, ( $tag + 1 ), null, true ); 103 | if ( $next === false // Shouldn't be possible. 104 | || $tokens[ $next ]['code'] !== \T_DOC_COMMENT_STRING 105 | || $tokens[ $next ]['line'] !== $tokens[ $tag ]['line'] 106 | ) { 107 | $phpcsFile->addError( 108 | 'A @covers tag must indicate which class/function/method is being covered by the test', 109 | $tag, 110 | 'Empty' 111 | ); 112 | 113 | continue; 114 | } 115 | 116 | $annotation = $tokens[ $next ]['content']; 117 | $coversTags[ "$tag-$next" ] = $annotation; 118 | 119 | // Check for deprecated/removed @covers formats. 120 | if ( \preg_match( self::DEPRECATED_FORMAT_EXTENDED, $annotation ) === 1 121 | || \preg_match( self::DEPRECATED_FORMAT_VISIBILITY, $annotation ) === 1 122 | ) { 123 | $warning = 'Use of the "ClassName<*>" type values for @covers annotations has been deprecated in PHPUnit 9.0'; 124 | $warning .= ' and support has been removed in PHPUnit 10.0. Found: %s'; 125 | $data = [ $annotation ]; 126 | $phpcsFile->addWarning( $warning, $next, 'RemovedFormat', $data ); 127 | } 128 | 129 | if ( \preg_match( '`^' . self::VALID_CONTENT_REGEX . '$`', $annotation ) === 1 ) { 130 | continue; 131 | } 132 | 133 | /* 134 | * Account for a number of common "mistakes". 135 | */ 136 | 137 | $errorThrown = false; 138 | 139 | // Check for Union/Intersect types. 140 | if ( \strpos( $annotation, '&' ) !== false ) { 141 | if ( $this->fixAnnotationToSplit( $phpcsFile, $next, 'IntersectFound', '&' ) === true ) { 142 | continue; 143 | } 144 | 145 | $errorThrown = true; 146 | } 147 | 148 | if ( \strpos( $annotation, '|' ) !== false ) { 149 | if ( $this->fixAnnotationToSplit( $phpcsFile, $next, 'UnionFound', '|' ) === true ) { 150 | continue; 151 | } 152 | 153 | $errorThrown = true; 154 | } 155 | 156 | // Parentheses/Braces at the end of the annotation. 157 | $expected = \rtrim( $annotation, '(){} ' ); 158 | if ( $this->fixSimpleError( $phpcsFile, $next, $expected, 'InvalidBraces' ) === true ) { 159 | $errorThrown = true; 160 | } 161 | 162 | // Incorrect `` annotation. 163 | if ( \preg_match( '`::[{(\[]?(!)?(public|protected|private)[})\]]?`', $annotation, $matches ) === 1 ) { 164 | $replacement = '::<' . $matches[1] . $matches[2] . '>'; 165 | $expected = \str_replace( $matches[0], $replacement, $annotation ); 166 | 167 | if ( $this->fixSimpleError( $phpcsFile, $next, $expected, 'InvalidFunctionGroup' ) === true ) { 168 | $errorThrown = true; 169 | } 170 | } 171 | 172 | if ( $errorThrown === true ) { 173 | // We've already thrown an error. No need for duplicates. 174 | continue; 175 | } 176 | 177 | // Throw a generic error for all other invalid annotations. 178 | $error = self::ERROR_MSG; 179 | $error .= ' Check the PHPUnit documentation to see which annotations are supported. Found: %s'; 180 | $data = [ $annotation ]; 181 | $phpcsFile->addError( $error, $next, 'Invalid', $data ); 182 | } 183 | 184 | /* 185 | * Check that a docblock doesn't contain both `@covers` tags as well as `@coversNothing` tag(s). 186 | */ 187 | $coversNothingCount = \count( $coversNothingTags ); 188 | if ( $firstCoversTag !== false && $coversNothingCount > 0 ) { 189 | $error = 'A test can\'t both cover something as well as cover nothing. First @coversNothing tag encountered on line %d; first @covers tag encountered on line %d'; 190 | $data = [ 191 | $tokens[ $coversNothingTags[0] ]['line'], 192 | $tokens[ $firstCoversTag ]['line'], 193 | ]; 194 | 195 | $phpcsFile->addError( $error, $tokens[ $stackPtr ]['comment_closer'], 'Contradictory', $data ); 196 | } 197 | 198 | /* 199 | * Check for duplicate `@coversNothing` tags. 200 | */ 201 | if ( $coversNothingCount > 1 ) { 202 | $error = 'Only one @coversNothing tag allowed per test'; 203 | $code = 'DuplicateCoversNothing'; 204 | $removeTags = []; 205 | foreach ( $coversNothingTags as $ptr ) { 206 | $next = ( $ptr + 1 ); 207 | if ( $tokens[ $next ]['code'] === \T_DOC_COMMENT_WHITESPACE 208 | && $tokens[ $next ]['content'] === $phpcsFile->eolChar 209 | ) { 210 | // No comment, ok to remove. 211 | $removeTags[] = $ptr; 212 | } 213 | } 214 | 215 | $removalCount = \count( $removeTags ); 216 | if ( ( $coversNothingCount - $removalCount ) > 1 ) { 217 | // More than one tag had a comment. 218 | $phpcsFile->addError( $error, $tokens[ $stackPtr ]['comment_closer'], $code ); 219 | } 220 | else { 221 | $fix = $phpcsFile->addFixableError( $error, $tokens[ $stackPtr ]['comment_closer'], $code ); 222 | if ( $fix === true ) { 223 | $skipFirst = ( $coversNothingCount === $removalCount ); 224 | 225 | $phpcsFile->fixer->beginChangeset(); 226 | 227 | foreach ( $removeTags as $key => $ptr ) { 228 | if ( $skipFirst === true && $key === 0 ) { 229 | // Let the first one remain if none of the tags has a comment. 230 | continue; 231 | } 232 | 233 | // Remove the whole line. 234 | for ( $i = ( $ptr + 1 ); $i >= 0; $i-- ) { 235 | if ( $tokens[ $i ]['line'] !== $tokens[ $ptr ]['line'] ) { 236 | break; 237 | } 238 | 239 | $phpcsFile->fixer->replaceToken( $i, '' ); 240 | } 241 | } 242 | 243 | $phpcsFile->fixer->endChangeset(); 244 | } 245 | } 246 | } 247 | 248 | /* 249 | * Check for duplicate `@covers ...` tags. 250 | */ 251 | $coversCount = \count( $coversTags ); 252 | if ( $coversCount > 1 ) { 253 | $unique = \array_unique( $coversTags ); 254 | if ( \count( $unique ) !== $coversCount ) { 255 | $value_count = \array_count_values( $coversTags ); 256 | $error = 'Duplicate @covers tag found. First tag with the same annotation encountered on line %d'; 257 | $code = 'DuplicateCovers'; 258 | foreach ( $value_count as $annotation => $count ) { 259 | if ( $count < 2 ) { 260 | continue; 261 | } 262 | 263 | $first = null; 264 | $data = []; 265 | foreach ( $coversTags as $ptrs => $annot ) { 266 | if ( $annotation !== $annot ) { 267 | continue; 268 | } 269 | 270 | if ( $first === null ) { 271 | $first = \explode( '-', $ptrs ); 272 | $data = [ $tokens[ $first[0] ]['line'] ]; 273 | continue; 274 | } 275 | 276 | $ptrs = \explode( '-', $ptrs ); 277 | 278 | $fix = $phpcsFile->addFixableError( $error, (int) $ptrs[0], $code, $data ); 279 | if ( $fix === true ) { 280 | $phpcsFile->fixer->beginChangeset(); 281 | 282 | // Remove the whole line. 283 | for ( $i = (int) $ptrs[1]; $i >= 0; $i-- ) { 284 | if ( $tokens[ $i ]['line'] !== $tokens[ $ptrs[1] ]['line'] ) { 285 | if ( $tokens[ $i ]['code'] === \T_DOC_COMMENT_WHITESPACE 286 | && $tokens[ $i ]['content'] === $phpcsFile->eolChar 287 | ) { 288 | $phpcsFile->fixer->replaceToken( $i, '' ); 289 | } 290 | break; 291 | } 292 | 293 | $phpcsFile->fixer->replaceToken( $i, '' ); 294 | } 295 | 296 | $phpcsFile->fixer->endChangeset(); 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } 303 | 304 | /** 305 | * Add a fixable error if a suitable alternative is available. 306 | * 307 | * @param File $phpcsFile The file being scanned. 308 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 309 | * @param string $expected The expected alternative annotation. 310 | * This annotation might not be valid itself. 311 | * @param string $errorCode The error code. 312 | * 313 | * @return bool Whether an error has been thrown or not. 314 | */ 315 | private function fixSimpleError( File $phpcsFile, $stackPtr, $expected, $errorCode ) { 316 | $tokens = $phpcsFile->getTokens(); 317 | $annotation = $tokens[ $stackPtr ]['content']; 318 | 319 | if ( $expected === $annotation 320 | || \preg_match( '`^' . self::VALID_CONTENT_REGEX . '$`', $expected ) !== 1 321 | ) { 322 | return false; 323 | } 324 | 325 | $error = self::ERROR_MSG . "\nExpected: `%s`\nFound: `%s`"; 326 | $data = [ 327 | $expected, 328 | $annotation, 329 | ]; 330 | 331 | $fix = $phpcsFile->addFixableError( $error, $stackPtr, $errorCode, $data ); 332 | if ( $fix === true ) { 333 | $phpcsFile->fixer->replaceToken( $stackPtr, $expected ); 334 | } 335 | 336 | return true; 337 | } 338 | 339 | /** 340 | * Add a fixable error for a union/intersect @covers annotation. 341 | * 342 | * @param File $phpcsFile The file being scanned. 343 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 344 | * @param string $errorCode The error code. 345 | * @param string $separator The separator to split the annotation on. 346 | * 347 | * @return bool Whether to skip the rest of the annotation examination or not. 348 | */ 349 | private function fixAnnotationToSplit( File $phpcsFile, $stackPtr, $errorCode, $separator ) { 350 | $fix = $phpcsFile->addFixableError( 351 | 'Each @covers annotation should reference only one covered structure', 352 | $stackPtr, 353 | $errorCode 354 | ); 355 | 356 | if ( $fix === true ) { 357 | $tokens = $phpcsFile->getTokens(); 358 | $annotation = $tokens[ $stackPtr ]['content']; 359 | $annotations = \explode( $separator, $annotation ); 360 | // @phpstan-ignore argument.type (explode will never return `false` as it is never given an empty string separator.) 361 | $annotations = \array_map( 'trim', $annotations ); 362 | $annotations = \array_filter( $annotations ); // Remove empties. 363 | 364 | $phpcsFile->fixer->beginChangeset(); 365 | $phpcsFile->fixer->replaceToken( $stackPtr, '' ); 366 | 367 | for ( $i = ( $stackPtr - 1 ); $i >= 0; $i-- ) { 368 | if ( $tokens[ $i ]['line'] !== $tokens[ $stackPtr ]['line'] ) { 369 | break; 370 | } 371 | 372 | $phpcsFile->fixer->replaceToken( $i, '' ); 373 | } 374 | 375 | $stub = GetTokensAsString::origContent( $phpcsFile, $i, ( $stackPtr - 1 ) ); 376 | $replacement = ''; 377 | foreach ( $annotations as $annotation ) { 378 | $replacement .= $stub . $annotation; 379 | } 380 | 381 | $phpcsFile->fixer->replaceToken( $i, $replacement ); 382 | $phpcsFile->fixer->endChangeset(); 383 | 384 | return true; 385 | } 386 | 387 | return false; 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Commenting/FileCommentSniff.php: -------------------------------------------------------------------------------- 1 | getTokens(); 45 | 46 | /* 47 | * Check if the file is namespaced. If not, fall through to the parent sniff. 48 | */ 49 | $namespace_token = $stackPtr; 50 | do { 51 | $namespace_token = $phpcsFile->findNext( Tokens::$emptyTokens, ( $namespace_token + 1 ), null, true ); 52 | if ( $namespace_token === false ) { 53 | // No non-empty token found, fall through to parent sniff. 54 | return parent::process( $phpcsFile, $stackPtr ); 55 | } 56 | 57 | if ( $tokens[ $namespace_token ]['code'] === \T_DECLARE ) { 58 | // Declare statement found. Find the end of it and skip over it. 59 | $end = $phpcsFile->findNext( [ \T_SEMICOLON, \T_OPEN_CURLY_BRACKET ], ( $namespace_token + 1 ), null, false, null, true ); 60 | 61 | if ( $end !== false ) { 62 | $namespace_token = $end; 63 | } 64 | 65 | continue; 66 | } 67 | 68 | if ( $tokens[ $namespace_token ]['code'] !== \T_NAMESPACE ) { 69 | // No namespace found, fall through to parent sniff. 70 | return parent::process( $phpcsFile, $stackPtr ); 71 | } 72 | 73 | // Stop searching if the next non-empty token wasn't a namespace token. 74 | break; 75 | } while ( true ); 76 | 77 | $next_non_empty = $phpcsFile->findNext( Tokens::$emptyTokens, ( $namespace_token + 1 ), null, true ); 78 | if ( $next_non_empty === false 79 | || $tokens[ $next_non_empty ]['code'] === \T_SEMICOLON 80 | || $tokens[ $next_non_empty ]['code'] === \T_OPEN_CURLY_BRACKET 81 | || $tokens[ $next_non_empty ]['code'] === \T_NS_SEPARATOR 82 | ) { 83 | // Either live coding, global namespace (i.e. not really namespaced) or namespace operator. 84 | // Fall through to parent sniff. 85 | return parent::process( $phpcsFile, $stackPtr ); 86 | } 87 | 88 | /* 89 | * As of here, we know we are in a namespaced file. 90 | */ 91 | 92 | $comment_start = $phpcsFile->findNext( \T_WHITESPACE, ( $stackPtr + 1 ), $namespace_token, true ); 93 | 94 | if ( $comment_start === false || $tokens[ $comment_start ]['code'] !== \T_DOC_COMMENT_OPEN_TAG ) { 95 | // No file comment found, we're good. 96 | return $phpcsFile->numTokens; 97 | } 98 | 99 | // Respect phpcs:disable comments in the file docblock. 100 | if ( $phpcsFile->config->annotations === true && isset( $tokens[ $comment_start ]['comment_closer'] ) ) { 101 | for ( $i = ( $comment_start + 1 ); $i < $tokens[ $comment_start ]['comment_closer']; $i++ ) { 102 | if ( $tokens[ $i ]['code'] !== \T_PHPCS_DISABLE ) { 103 | continue; 104 | } 105 | 106 | if ( empty( $tokens[ $i ]['sniffCodes'] ) === true 107 | || isset( $tokens[ $i ]['sniffCodes']['Yoast'] ) === true 108 | || isset( $tokens[ $i ]['sniffCodes']['Yoast.Commenting'] ) === true 109 | || isset( $tokens[ $i ]['sniffCodes']['Yoast.Commenting.FileComment'] ) === true 110 | || isset( $tokens[ $i ]['sniffCodes']['Yoast.Commenting.FileComment.Unnecessary'] ) === true 111 | ) { 112 | // Applicable disable annotation found. 113 | return $phpcsFile->numTokens; 114 | } 115 | } 116 | } 117 | 118 | /* 119 | * Okay, so we have a file docblock in a namespaced file, now check if there is a named 120 | * OO structure declaration. 121 | */ 122 | $find = Tokens::$ooScopeTokens; 123 | unset( $find[ \T_ANON_CLASS ] ); 124 | $hasOODeclaration = $phpcsFile->findNext( $find, $next_non_empty ); 125 | if ( $hasOODeclaration === false ) { 126 | /* 127 | * This is a file which doesn't contain (just) an OO declaration. 128 | * If there is a file docblock, allow it and check it like any other file docblock. 129 | */ 130 | return parent::process( $phpcsFile, $stackPtr ); 131 | } 132 | 133 | $phpcsFile->addWarning( 134 | 'A file containing a (named) namespace declaration does not need a file docblock', 135 | $comment_start, 136 | 'Unnecessary' 137 | ); 138 | 139 | return $phpcsFile->numTokens; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Commenting/TestsHaveCoversTagSniff.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function register() { 26 | return [ 27 | \T_CLASS, 28 | \T_FUNCTION, 29 | ]; 30 | } 31 | 32 | /** 33 | * Processes this test, when one of its tokens is encountered. 34 | * 35 | * @param File $phpcsFile The file being scanned. 36 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 37 | * 38 | * @return int|void Optionally returns a stack pointer. This sniff will not be 39 | * called again on the current file until the returned stack 40 | * pointer is reached. 41 | */ 42 | public function process( File $phpcsFile, $stackPtr ) { 43 | 44 | $tokens = $phpcsFile->getTokens(); 45 | 46 | if ( $tokens[ $stackPtr ]['code'] === \T_CLASS ) { 47 | return $this->process_class( $phpcsFile, $stackPtr ); 48 | } 49 | 50 | // This must be a T_FUNCTION token. 51 | $this->process_function( $phpcsFile, $stackPtr ); 52 | } 53 | 54 | /** 55 | * Processes the docblock for a class token. 56 | * 57 | * @param File $phpcsFile The file being scanned. 58 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 59 | * 60 | * @return int|void If covers annotations were found (or this is not a test class), 61 | * will return the stack pointer to the end of the class. 62 | */ 63 | private function process_class( File $phpcsFile, $stackPtr ) { 64 | $tokens = $phpcsFile->getTokens(); 65 | $name = ObjectDeclarations::getName( $phpcsFile, $stackPtr ); 66 | 67 | if ( empty( $name ) 68 | || ( \substr( $name, -4 ) !== 'Test' 69 | && \substr( $name, -8 ) !== 'TestCase' 70 | && \substr( $name, 0, 4 ) !== 'Test' ) 71 | ) { 72 | // Not a test class. 73 | if ( isset( $tokens[ $stackPtr ]['scope_closer'] ) ) { 74 | // No need to examine the methods in this class. 75 | return $tokens[ $stackPtr ]['scope_closer']; 76 | } 77 | 78 | return; 79 | } 80 | 81 | // @todo: Once PHPCSUtils 1.2.0 (?) is out, replace with call to new findCommentAboveOOStructure() method. 82 | $ignore = Collections::classModifierKeywords(); 83 | $ignore[ \T_WHITESPACE ] = \T_WHITESPACE; 84 | 85 | $commentEnd = $stackPtr; 86 | for ( $commentEnd = ( $stackPtr - 1 ); $commentEnd >= 0; $commentEnd-- ) { 87 | if ( isset( $ignore[ $tokens[ $commentEnd ]['code'] ] ) === true ) { 88 | continue; 89 | } 90 | 91 | if ( $tokens[ $commentEnd ]['code'] === \T_ATTRIBUTE_END 92 | && isset( $tokens[ $commentEnd ]['attribute_opener'] ) === true 93 | ) { 94 | $commentEnd = $tokens[ $commentEnd ]['attribute_opener']; 95 | continue; 96 | } 97 | 98 | break; 99 | } 100 | 101 | if ( $tokens[ $commentEnd ]['code'] !== \T_DOC_COMMENT_CLOSE_TAG ) { 102 | // Class without (proper) docblock. Not our concern. 103 | return; 104 | } 105 | 106 | $commentStart = $tokens[ $commentEnd ]['comment_opener']; 107 | 108 | $foundCovers = false; 109 | $foundCoversNothing = false; 110 | foreach ( $tokens[ $commentStart ]['comment_tags'] as $tag ) { 111 | if ( $tokens[ $tag ]['content'] === '@covers' ) { 112 | $foundCovers = true; 113 | break; 114 | } 115 | 116 | if ( $tokens[ $tag ]['content'] === '@coversNothing' ) { 117 | $foundCoversNothing = true; 118 | break; 119 | } 120 | } 121 | 122 | if ( $foundCovers === true || $foundCoversNothing === true ) { 123 | // Class level tags found, valid for all methods. No need to examine the individual methods. 124 | if ( isset( $tokens[ $stackPtr ]['scope_closer'] ) ) { 125 | return $tokens[ $stackPtr ]['scope_closer']; 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * Processes the docblock for a function token. 132 | * 133 | * @param File $phpcsFile The file being scanned. 134 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 135 | * 136 | * @return void 137 | */ 138 | private function process_function( File $phpcsFile, $stackPtr ) { 139 | $tokens = $phpcsFile->getTokens(); 140 | 141 | if ( Scopes::isOOMethod( $phpcsFile, $stackPtr ) === false ) { 142 | // This is a global function, not a method in a test class. 143 | return; 144 | } 145 | 146 | // @todo: Once PHPCSUtils 1.2.0 (?) is out, replace with call to new findCommentAboveFunction() method. 147 | $ignore = Tokens::$methodPrefixes; 148 | $ignore[ \T_WHITESPACE ] = \T_WHITESPACE; 149 | 150 | $commentEnd = $stackPtr; 151 | for ( $commentEnd = ( $stackPtr - 1 ); $commentEnd >= 0; $commentEnd-- ) { 152 | if ( isset( $ignore[ $tokens[ $commentEnd ]['code'] ] ) === true ) { 153 | continue; 154 | } 155 | 156 | if ( $tokens[ $commentEnd ]['code'] === \T_ATTRIBUTE_END 157 | && isset( $tokens[ $commentEnd ]['attribute_opener'] ) === true 158 | ) { 159 | $commentEnd = $tokens[ $commentEnd ]['attribute_opener']; 160 | continue; 161 | } 162 | 163 | break; 164 | } 165 | 166 | $foundTest = false; 167 | $foundCovers = false; 168 | $foundCoversNothing = false; 169 | 170 | if ( $tokens[ $commentEnd ]['code'] === \T_DOC_COMMENT_CLOSE_TAG ) { 171 | $commentStart = $tokens[ $commentEnd ]['comment_opener']; 172 | 173 | foreach ( $tokens[ $commentStart ]['comment_tags'] as $tag ) { 174 | if ( $tokens[ $tag ]['content'] === '@test' ) { 175 | $foundTest = true; 176 | continue; 177 | } 178 | 179 | if ( $tokens[ $tag ]['content'] === '@covers' ) { 180 | $foundCovers = true; 181 | continue; 182 | } 183 | 184 | if ( $tokens[ $tag ]['content'] === '@coversNothing' ) { 185 | $foundCoversNothing = true; 186 | continue; 187 | } 188 | } 189 | } 190 | 191 | $name = FunctionDeclarations::getName( $phpcsFile, $stackPtr ); 192 | if ( empty( $name ) ) { 193 | // Parse error. Ignore this method as it will never be run as a test. 194 | return; 195 | } 196 | 197 | if ( \stripos( $name, 'test' ) !== 0 && $foundTest === false ) { 198 | // Not a test method. 199 | return; 200 | } 201 | 202 | $method_props = FunctionDeclarations::getProperties( $phpcsFile, $stackPtr ); 203 | if ( $method_props['is_abstract'] === true ) { 204 | // Abstract test method, not implemented. 205 | return; 206 | } 207 | 208 | if ( $foundCovers === true || $foundCoversNothing === true ) { 209 | // Docblock contains one or more @covers tags. 210 | return; 211 | } 212 | 213 | $msg = 'Each test function should have at least one @covers tag annotating which class/method/function is being tested.'; 214 | $data = [ $name ]; 215 | 216 | if ( $tokens[ $commentEnd ]['code'] === \T_DOC_COMMENT_CLOSE_TAG ) { 217 | $msg .= ' Tag missing for function %s().'; 218 | $code = 'Missing'; 219 | } 220 | else { 221 | $msg .= ' Test function %s() does not have a docblock with a @covers tag.'; 222 | $code = 'NoDocblock'; 223 | } 224 | 225 | $phpcsFile->addError( $msg, $stackPtr, $code, $data ); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Files/FileNameSniff.php: -------------------------------------------------------------------------------- 1 | 41 | */ 42 | private const NAMED_OO_TOKENS = [ 43 | \T_CLASS, 44 | \T_ENUM, 45 | \T_INTERFACE, 46 | \T_TRAIT, 47 | ]; 48 | 49 | /** 50 | * List of prefixes for object structures. 51 | * 52 | * These prefixes do not need to be reflected in the file name. 53 | * 54 | * Note: 55 | * - Prefixes are matched in a case-insensitive manner. 56 | * - When several overlapping prefixes match, the longest matching prefix 57 | * will be removed. 58 | * 59 | * @var array 60 | */ 61 | public $oo_prefixes = []; 62 | 63 | /** 64 | * List of files to exclude from the strict file name check. 65 | * 66 | * This is used primarily to prevent the sniff from recommending that the 67 | * name of the plugin "main" file should be changed as changing that would 68 | * deactivate the plugin on upgrade and is therefore not a good idea. 69 | * 70 | * File names should be provided including the path to the file relative 71 | * to the "basepath" known to PHPCS. 72 | * File names should not be prefixed with a directory separator. 73 | * The list should be provided as a PHPCS array list. 74 | * 75 | * For this functionality to work with relative file paths - i.e. file paths 76 | * from the root of the repository - , the PHPCS `--basepath` config variable 77 | * needs to be set. If it is not, a warning will be issued. 78 | * 79 | * @var array 80 | */ 81 | public $excluded_files_strict_check = []; 82 | 83 | /** 84 | * Cache of previously set OO prefixes. 85 | * 86 | * Prevents having to do the same prefix validation over and over again. 87 | * 88 | * @var array 89 | */ 90 | private $previous_oo_prefixes = []; 91 | 92 | /** 93 | * Validated & cleaned up OO set prefixes. 94 | * 95 | * @var array 96 | */ 97 | private $clean_oo_prefixes = []; 98 | 99 | /** 100 | * Cache of previously set list of excluded files. 101 | * 102 | * Prevents having to do the same file validation over and over again. 103 | * 104 | * @var array 105 | */ 106 | private $previous_excluded_files = []; 107 | 108 | /** 109 | * Validated & cleaned up list of absolute paths to the excluded files. 110 | * 111 | * @var array Both the key and the value will be the same absolute path. 112 | */ 113 | private $validated_excluded_files = []; 114 | 115 | /** 116 | * Track if the "missing basepath" warning has been thrown. 117 | * 118 | * This prevents this warning potentially being thrown for every single file in a PHPCS run. 119 | * 120 | * @var bool 121 | */ 122 | private $basepath_warning_thrown = false; 123 | 124 | /** 125 | * Returns an array of tokens this test wants to listen for. 126 | * 127 | * @return array 128 | */ 129 | public function register() { 130 | return Collections::phpOpenTags(); 131 | } 132 | 133 | /** 134 | * Processes this test, when one of its tokens is encountered. 135 | * 136 | * @param File $phpcsFile The file being scanned. 137 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 138 | * 139 | * @return int StackPtr to the end of the file, this sniff needs to only 140 | * check each file once. 141 | */ 142 | public function process( File $phpcsFile, $stackPtr ) { 143 | // Stripping potential quotes to ensure `stdin_path` passed by IDEs does not include quotes. 144 | $file = TextStrings::stripQuotes( $phpcsFile->getFileName() ); 145 | 146 | if ( $file === 'STDIN' ) { 147 | return $phpcsFile->numTokens; // @codeCoverageIgnore 148 | } 149 | 150 | // Respect phpcs:disable comments as long as they are not accompanied by an enable. 151 | $tokens = $phpcsFile->getTokens(); 152 | $i = -1; 153 | while ( $i = $phpcsFile->findNext( \T_PHPCS_DISABLE, ( $i + 1 ) ) ) { 154 | if ( empty( $tokens[ $i ]['sniffCodes'] ) 155 | || isset( $tokens[ $i ]['sniffCodes']['Yoast'] ) 156 | || isset( $tokens[ $i ]['sniffCodes']['Yoast.Files'] ) 157 | || isset( $tokens[ $i ]['sniffCodes']['Yoast.Files.FileName'] ) 158 | ) { 159 | do { 160 | $i = $phpcsFile->findNext( \T_PHPCS_ENABLE, ( $i + 1 ) ); 161 | } while ( $i !== false 162 | && ! empty( $tokens[ $i ]['sniffCodes'] ) 163 | && ! isset( $tokens[ $i ]['sniffCodes']['Yoast'] ) 164 | && ! isset( $tokens[ $i ]['sniffCodes']['Yoast.Files'] ) 165 | && ! isset( $tokens[ $i ]['sniffCodes']['Yoast.Files.FileName'] ) 166 | ); 167 | 168 | if ( $i === false ) { 169 | // The entire (rest of the) file is disabled. 170 | return $phpcsFile->numTokens; 171 | } 172 | } 173 | } 174 | 175 | $path_info = \pathinfo( $file ); 176 | 177 | // Basename = filename + extension. 178 | $basename = ''; 179 | if ( ! empty( $path_info['basename'] ) ) { 180 | $basename = $path_info['basename']; 181 | } 182 | 183 | $file_name = ''; 184 | if ( ! empty( $path_info['filename'] ) ) { 185 | $file_name = $path_info['filename']; 186 | } 187 | 188 | $extension = ''; 189 | if ( ! empty( $path_info['extension'] ) ) { 190 | $extension = $path_info['extension']; 191 | } 192 | 193 | $error = 'Filenames should be all lowercase with hyphens as word separators.'; 194 | $error_code = 'NotHyphenatedLowercase'; 195 | $expected = \strtolower( \preg_replace( '`[[:punct:]]`', '-', $file_name ) ); 196 | 197 | if ( ! isset( $phpcsFile->config->basepath ) ) { 198 | $this->add_missing_basepath_warning( $phpcsFile ); 199 | } 200 | 201 | $oo_structure = $phpcsFile->findNext( self::NAMED_OO_TOKENS, $stackPtr ); 202 | if ( $oo_structure !== false ) { 203 | 204 | $oo_name = ObjectDeclarations::getName( $phpcsFile, $oo_structure ); 205 | 206 | if ( \is_string( $oo_name ) && $oo_name !== '' ) { 207 | 208 | if ( $this->is_in_psr4_path( $phpcsFile, $file ) ) { 209 | $error = 'Directory marked as a PSR-4 path. File names should 100%% match the name of the OO structure contained in the file for PSR-4 compliance.'; 210 | $error_code = 'InvalidPSR4FileName'; 211 | $expected = $oo_name; 212 | } 213 | elseif ( $this->is_file_excluded( $phpcsFile, $file ) === false ) { 214 | $this->validate_oo_prefixes(); 215 | if ( ! empty( $this->clean_oo_prefixes ) ) { 216 | foreach ( $this->clean_oo_prefixes as $prefix ) { 217 | if ( $oo_name !== $prefix && \stripos( $oo_name, $prefix ) === 0 ) { 218 | $oo_name = \substr( $oo_name, \strlen( $prefix ) ); 219 | $oo_name = \ltrim( $oo_name, '_-' ); 220 | break; 221 | } 222 | } 223 | } 224 | 225 | $expected = \strtolower( \str_replace( '_', '-', $oo_name ) ); 226 | 227 | switch ( $tokens[ $oo_structure ]['code'] ) { 228 | case \T_CLASS: 229 | $error = 'Class file names should be based on the class name without the plugin prefix.'; 230 | $error_code = 'InvalidClassFileName'; 231 | break; 232 | 233 | // Interfaces, traits, enums. 234 | default: 235 | $oo_type = \strtolower( $tokens[ $oo_structure ]['content'] ); 236 | $oo_type_ucfirst = \ucfirst( $oo_type ); 237 | 238 | $error = \sprintf( 239 | '%1$s file names should be based on the %2$s name without the plugin prefix and should have "-%2$s" as a suffix.', 240 | $oo_type_ucfirst, 241 | $oo_type 242 | ); 243 | $error_code = \sprintf( 'Invalid%sFileName', $oo_type_ucfirst ); 244 | 245 | // Don't duplicate "interface/trait/enum" in the filename. 246 | $expected_suffix = '-' . $oo_type; 247 | if ( \substr( $expected, -\strlen( $expected_suffix ) ) !== $expected_suffix ) { 248 | $expected .= $expected_suffix; 249 | } 250 | break; 251 | } 252 | } 253 | } 254 | } 255 | elseif ( $this->is_file_excluded( $phpcsFile, $file ) === false ) { 256 | $has_function = $phpcsFile->findNext( \T_FUNCTION, $stackPtr ); 257 | if ( $has_function !== false && $file_name !== 'functions' ) { 258 | $error = 'Files containing function declarations should have "-functions" as a suffix.'; 259 | $error_code = 'InvalidFunctionsFileName'; 260 | 261 | if ( \substr( $expected, -10 ) !== '-functions' ) { 262 | $expected .= '-functions'; 263 | } 264 | } 265 | } 266 | 267 | // Throw the error. 268 | if ( $expected !== '' && $file_name !== $expected ) { 269 | $phpcsFile->addError( 270 | $error . ' Expected "%s", but found "%s".', 271 | 0, 272 | $error_code, 273 | [ 274 | $expected . '.' . $extension, 275 | $basename, 276 | ] 277 | ); 278 | } 279 | 280 | // Only run this sniff once per file, no need to run it again. 281 | return $phpcsFile->numTokens; 282 | } 283 | 284 | /** 285 | * Check if the file is on the exclude list. 286 | * 287 | * @param File $phpcsFile The file being scanned. 288 | * @param string $path_to_file The full path to the file currently being examined. 289 | * 290 | * @return bool 291 | */ 292 | private function is_file_excluded( File $phpcsFile, $path_to_file ) { 293 | $this->validate_excluded_files( $phpcsFile ); 294 | if ( empty( $this->validated_excluded_files ) ) { 295 | return false; 296 | } 297 | 298 | $path_to_file = PathHelper::normalize_absolute_path( $path_to_file ); 299 | 300 | return isset( $this->validated_excluded_files[ $path_to_file ] ); 301 | } 302 | 303 | /** 304 | * Clean a custom array property received from a ruleset. 305 | * 306 | * Deals with incorrectly passed custom array properties. 307 | * - Remove whitespace surrounding values. 308 | * - Remove empty array entries. 309 | * 310 | * @param array $property The current property value. 311 | * 312 | * @return array 313 | */ 314 | private function clean_custom_array_property( $property ) { 315 | return \array_filter( \array_map( 'trim', $property ) ); 316 | } 317 | 318 | /** 319 | * Validate and sort the OO prefixes passed from a custom ruleset. 320 | * 321 | * This will only need to be done once in a normal PHPCS run, though for 322 | * tests the function may be called multiple times. 323 | * 324 | * @return void 325 | */ 326 | private function validate_oo_prefixes() { 327 | if ( $this->previous_oo_prefixes === $this->oo_prefixes ) { 328 | return; 329 | } 330 | 331 | // Set the cache *before* validation so as to not break the above compare. 332 | $this->previous_oo_prefixes = $this->oo_prefixes; 333 | 334 | $this->clean_oo_prefixes = $this->clean_custom_array_property( $this->oo_prefixes ); 335 | 336 | if ( ! empty( $this->clean_oo_prefixes ) ) { 337 | // Use reverse natural sorting to get the longest of overlapping prefixes first. 338 | \rsort( $this->clean_oo_prefixes, ( \SORT_NATURAL | \SORT_FLAG_CASE ) ); 339 | } 340 | } 341 | 342 | /** 343 | * Validate the list of excluded files passed from a custom ruleset. 344 | * 345 | * This will only need to be done once in a normal PHPCS run, though for 346 | * tests the function may be called multiple times. 347 | * 348 | * @param File $phpcsFile The file being scanned. 349 | * 350 | * @return void 351 | */ 352 | private function validate_excluded_files( $phpcsFile ) { 353 | // The basepath check needs to be done first as otherwise the previous/current comparison would be broken. 354 | if ( ! isset( $phpcsFile->config->basepath ) ) { 355 | // Only relevant for the tests: make sure previously set validated paths are cleared out. 356 | $this->validated_excluded_files = []; 357 | 358 | // No use continuing as we can't turn relative paths into absolute paths. 359 | return; 360 | } 361 | 362 | if ( $this->previous_excluded_files === $this->excluded_files_strict_check ) { 363 | return; 364 | } 365 | 366 | // Set the cache *before* validation so as to not break the above compare. 367 | $this->previous_excluded_files = $this->excluded_files_strict_check; 368 | 369 | $absolute_paths = PathValidationHelper::relative_to_absolute( $phpcsFile, $this->excluded_files_strict_check ); 370 | $absolute_paths = \array_unique( $absolute_paths ); 371 | $absolute_paths = \array_values( $absolute_paths ); 372 | 373 | $this->validated_excluded_files = \array_combine( $absolute_paths, $absolute_paths ); 374 | } 375 | 376 | /** 377 | * Throw a warning if the basepath is missing (and this warning hasn't been thrown before). 378 | * 379 | * @param File $phpcsFile The file being scanned. 380 | * 381 | * @return void 382 | */ 383 | private function add_missing_basepath_warning( File $phpcsFile ) { 384 | if ( $this->basepath_warning_thrown === true ) { 385 | return; 386 | } 387 | 388 | $phpcsFile->addWarning( 389 | 'For the excluded files and the psr4 paths properties to work with relative file paths, the --basepath needs to be set.', 390 | 0, 391 | 'MissingBasePath' 392 | ); 393 | 394 | $this->basepath_warning_thrown = true; 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Files/TestDoublesSniff.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public $doubles_path = []; 39 | 40 | /** 41 | * Validated absolute target paths for test fixture directories or an empty array 42 | * if the intended target directory/directories don't exist. 43 | * 44 | * @var array 45 | */ 46 | private $target_paths; 47 | 48 | /** 49 | * Returns an array of tokens this test wants to listen for. 50 | * 51 | * @return array 52 | */ 53 | public function register() { 54 | $targets = Tokens::$ooScopeTokens; 55 | unset( $targets[ \T_ANON_CLASS ] ); 56 | 57 | return $targets; 58 | } 59 | 60 | /** 61 | * Processes this test, when one of its tokens is encountered. 62 | * 63 | * @param File $phpcsFile The file being scanned. 64 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 65 | * 66 | * @return int|void Void or $stackPtr to the end of the file if no basepath was set 67 | * or no valid doubles_path(s) were found. 68 | */ 69 | public function process( File $phpcsFile, $stackPtr ) { 70 | // Stripping potential quotes to ensure `stdin_path` passed by IDEs does not include quotes. 71 | $file = TextStrings::stripQuotes( $phpcsFile->getFileName() ); 72 | 73 | if ( $file === 'STDIN' ) { 74 | return $phpcsFile->numTokens; // @codeCoverageIgnore 75 | } 76 | 77 | if ( ! isset( $phpcsFile->config->basepath ) ) { 78 | $phpcsFile->addWarning( 79 | 'For the TestDoubles sniff to be able to function, the --basepath needs to be set.', 80 | 0, 81 | 'MissingBasePath' 82 | ); 83 | 84 | return $phpcsFile->numTokens; 85 | } 86 | 87 | if ( empty( $this->doubles_path ) ) { 88 | // Just in case someone would overrule the property with an empty value. 89 | $phpcsFile->addWarning( 90 | 'Required property "doubles_path" missing. Please edit your custom ruleset to add the property.', 91 | 0, 92 | 'NoDoublesPathProperty' 93 | ); 94 | 95 | return $phpcsFile->numTokens; 96 | } 97 | 98 | if ( ! isset( $this->target_paths ) || \defined( 'PHP_CODESNIFFER_IN_TESTS' ) ) { 99 | $this->target_paths = PathValidationHelper::relative_to_absolute( $phpcsFile, $this->doubles_path ); 100 | $this->target_paths = \array_filter( $this->target_paths, 'file_exists' ); 101 | $this->target_paths = \array_filter( $this->target_paths, 'is_dir' ); 102 | } 103 | 104 | $object_name = ObjectDeclarations::getName( $phpcsFile, $stackPtr ); 105 | if ( empty( $object_name ) ) { 106 | return; 107 | } 108 | 109 | $name_contains_double_or_mock = false; 110 | if ( \stripos( $object_name, 'mock' ) !== false || \stripos( $object_name, 'double' ) !== false ) { 111 | $name_contains_double_or_mock = true; 112 | } 113 | 114 | $tokens = $phpcsFile->getTokens(); 115 | if ( empty( $this->target_paths ) === true ) { 116 | if ( $name_contains_double_or_mock === false ) { 117 | return; 118 | } 119 | 120 | // Mock/Double class found, but no valid target paths found. 121 | $data = [ 122 | $tokens[ $stackPtr ]['content'], 123 | $phpcsFile->config->basepath, 124 | ]; 125 | 126 | if ( \count( $this->doubles_path ) === 1 ) { 127 | $data[] = 'directory'; 128 | $data[] = \implode( '', $this->doubles_path ); 129 | } 130 | else { 131 | $all_paths = \implode( '", "', $this->doubles_path ); 132 | $all_paths = \substr_replace( $all_paths, ' and', \strrpos( $all_paths, ',' ), 1 ); 133 | 134 | $data[] = 'directories'; 135 | $data[] = $all_paths; 136 | } 137 | 138 | $phpcsFile->addError( 139 | 'Double/Mock test helper %1$s detected, but no test fixtures sub-%3$s found in "%2$s". Expected: "%4$s". Please create the sub-%3$s.', 140 | $stackPtr, 141 | 'NoDoublesDirectory', 142 | $data 143 | ); 144 | 145 | return; 146 | } 147 | 148 | $path_to_file = PathHelper::normalize_absolute_path( $file ); 149 | $is_double_dir = false; 150 | 151 | foreach ( $this->target_paths as $target_path ) { 152 | if ( PathHelper::path_starts_with( $path_to_file, $target_path ) === true ) { 153 | $is_double_dir = true; 154 | break; 155 | } 156 | } 157 | 158 | $data = [ 159 | $tokens[ $stackPtr ]['content'], 160 | $object_name, 161 | ]; 162 | 163 | if ( $name_contains_double_or_mock === true && $is_double_dir === false ) { 164 | $phpcsFile->addError( 165 | 'Double/Mock test helpers should be placed in a dedicated test fixtures sub-directory. Found %s: %s', 166 | $stackPtr, 167 | 'WrongDirectory', 168 | $data 169 | ); 170 | return; 171 | } 172 | 173 | if ( $name_contains_double_or_mock === false && $is_double_dir === true ) { 174 | $phpcsFile->addError( 175 | 'Double/Mock test helpers should contain "Double" or "Mock" in the class name. Found %s: %s', 176 | $stackPtr, 177 | 'InvalidClassName', 178 | $data 179 | ); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Yoast/Sniffs/NamingConventions/NamespaceNameSniff.php: -------------------------------------------------------------------------------- 1 | Key is the subdirectory name, value the length of that name. 42 | */ 43 | private const DOUBLE_DIRS = [ 44 | '\Doubles\\' => 9, 45 | '\Mocks\\' => 7, 46 | '\Fixtures\\' => 10, 47 | ]; 48 | 49 | /** 50 | * Project root(s). 51 | * 52 | * When the _real_ project root as set in `$basepath` is not the 53 | * starting point for the translation between directories and levels, 54 | * one or more sub-directories of the project root can be indicated 55 | * as starting points for the translation. 56 | * 57 | * @var array 58 | */ 59 | public $src_directory = []; 60 | 61 | /** 62 | * Maximum number of levels. 63 | * 64 | * The maximum number of sub-levels a namespace name should consist of, each 65 | * separated by a namespace separator. 66 | * 67 | * If the name consists of more sub-levels, an ERROR will be thrown. 68 | * 69 | * @var int 70 | */ 71 | public $max_levels = 3; 72 | 73 | /** 74 | * Recommended maximum number of levels. 75 | * 76 | * The recommended maximum number of sub-levels a namespace name should consist of, each 77 | * separated by a namespace separator. 78 | * 79 | * If the name consists of more sub-levels, a WARNING will be thrown. 80 | * 81 | * @var int 82 | */ 83 | public $recommended_max_levels = 2; 84 | 85 | /** 86 | * Project roots after validation. 87 | * 88 | * Validated src_directories will look like "$basepath/src/", i.e.: 89 | * - absolute paths; 90 | * - with linux slashes; 91 | * - and a trailing slash. 92 | * 93 | * @var array 94 | */ 95 | private $validated_src_directory; 96 | 97 | /** 98 | * Cache of previously set project roots. 99 | * 100 | * Prevents having to do the same validation over and over again. 101 | * 102 | * @var array 103 | */ 104 | private $previous_src_directory; 105 | 106 | /** 107 | * Returns an array of tokens this test wants to listen for. 108 | * 109 | * @return array 110 | */ 111 | public function register() { 112 | return [ \T_NAMESPACE ]; 113 | } 114 | 115 | /** 116 | * Filter out all prefixes which don't have namespace separators. 117 | * 118 | * @param array $prefixes The unvalidated prefixes. 119 | * 120 | * @return array 121 | */ 122 | protected function filter_prefixes( $prefixes ) { 123 | return $this->filter_allow_only_namespace_prefixes( $prefixes ); 124 | } 125 | 126 | /** 127 | * Processes this test, when one of its tokens is encountered. 128 | * 129 | * @param File $phpcsFile The file being scanned. 130 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 131 | * 132 | * @return void 133 | */ 134 | public function process( File $phpcsFile, $stackPtr ) { 135 | 136 | $namespace_name = Namespaces::getDeclaredName( $phpcsFile, $stackPtr ); 137 | if ( empty( $namespace_name ) ) { 138 | // Either not a namespace declaration or global namespace. 139 | return; 140 | } 141 | 142 | // Stripping potential quotes to ensure `stdin_path` passed by IDEs does not include quotes. 143 | $file = TextStrings::stripQuotes( $phpcsFile->getFileName() ); 144 | if ( $file === 'STDIN' ) { 145 | $file = ''; // @codeCoverageIgnore 146 | } 147 | else { 148 | $file = PathHelper::normalize_absolute_path( $file ); 149 | } 150 | 151 | $valid_prefixes = []; 152 | $psr4_info = false; 153 | if ( $file !== '' ) { 154 | $psr4_info = $this->get_psr4_info( $phpcsFile, $file ); 155 | } 156 | 157 | if ( \is_array( $psr4_info ) ) { 158 | // If a PSR4 path matched, there will only ever be one valid prefix for the matched path. 159 | $valid_prefixes = [ $psr4_info['prefix'] . '\\' ]; 160 | } 161 | else { 162 | // Safeguard that the PSR-4 prefixes are always included. 163 | // Makes sure level depth check still happens even if there is no basepath or path doesn't match PSR-4 path. 164 | if ( empty( $this->prefixes ) && ! empty( $this->psr4_paths ) ) { 165 | $this->prefixes = \array_keys( $this->psr4_paths ); 166 | } 167 | 168 | $this->validate_prefixes(); 169 | $valid_prefixes = $this->validated_prefixes; 170 | } 171 | 172 | // Strip off the (longest) plugin prefix. 173 | $namespace_name_no_prefix = $namespace_name; 174 | $found_prefix = ''; 175 | if ( ! empty( $valid_prefixes ) ) { 176 | $name = $namespace_name . '\\'; // Validated prefixes always have a \ at the end. 177 | foreach ( $valid_prefixes as $prefix ) { 178 | if ( \strpos( $name, $prefix ) === 0 ) { 179 | $namespace_name_no_prefix = \rtrim( \substr( $name, \strlen( $prefix ) ), '\\' ); 180 | $found_prefix = \rtrim( $prefix, '\\' ); 181 | break; 182 | } 183 | } 184 | unset( $prefix, $name ); 185 | } 186 | 187 | // Check if a prefix is used. 188 | if ( ! empty( $valid_prefixes ) && $found_prefix === '' ) { 189 | $prefixes = $valid_prefixes; 190 | 191 | if ( $psr4_info !== false ) { 192 | $error = 'PSR-4 namespace name for this path is required to start with the "%1$s" prefix.'; 193 | $errorcode = 'MissingPSR4Prefix'; 194 | } 195 | else { 196 | $error = 'A namespace name is required to start with one of the following prefixes: "%s"'; 197 | $errorcode = 'MissingPrefix'; 198 | 199 | $prefixes = \array_merge( $prefixes, \array_keys( $this->psr4_paths ) ); 200 | $prefixes = \array_unique( $prefixes ); 201 | 202 | if ( \count( $prefixes ) === 1 ) { 203 | $error = 'A namespace name is required to start with the "%s" prefix.'; 204 | } 205 | else { 206 | \natcasesort( $prefixes ); 207 | } 208 | } 209 | 210 | $data = [ \implode( '", "', $prefixes ) ]; 211 | 212 | $phpcsFile->addError( $error, $stackPtr, $errorcode, $data ); 213 | } 214 | 215 | /* 216 | * Check the namespace level depth. 217 | */ 218 | if ( $namespace_name_no_prefix !== '' ) { 219 | $namespace_for_level_check = $namespace_name_no_prefix; 220 | 221 | // Allow for a variation of `Tests\` and `Tests\*\Doubles\` after the prefix. 222 | $starts_with_tests = ( \strpos( $namespace_for_level_check, 'Tests\\' ) === 0 ); 223 | $prefix_ends_with_tests = ( \substr( $found_prefix, -6 ) === '\Tests' ); 224 | if ( $starts_with_tests === true || $prefix_ends_with_tests === true ) { 225 | $stripped = false; 226 | foreach ( self::DOUBLE_DIRS as $dir => $length ) { 227 | if ( \strpos( $namespace_for_level_check, $dir ) !== false ) { 228 | $namespace_for_level_check = \substr( $namespace_for_level_check, ( \strpos( $namespace_for_level_check, $dir ) + $length ) ); 229 | $stripped = true; 230 | break; 231 | } 232 | } 233 | 234 | if ( $stripped === false ) { 235 | if ( $starts_with_tests === true ) { 236 | // No double dir found, now check/strip typical test dirs. 237 | if ( \strpos( $namespace_for_level_check, 'Tests\WP\\' ) === 0 ) { 238 | $namespace_for_level_check = \substr( $namespace_for_level_check, 9 ); 239 | } 240 | elseif ( \strpos( $namespace_for_level_check, 'Tests\Unit\\' ) === 0 ) { 241 | $namespace_for_level_check = \substr( $namespace_for_level_check, 11 ); 242 | } 243 | else { 244 | // Okay, so this only has the `Tests` prefix, just strip it. 245 | $namespace_for_level_check = \substr( $namespace_for_level_check, 6 ); 246 | } 247 | } 248 | elseif ( $prefix_ends_with_tests === true ) { 249 | // Prefix which already includes `Tests`. 250 | if ( \strpos( $namespace_for_level_check, 'WP\\' ) === 0 ) { 251 | $namespace_for_level_check = \substr( $namespace_for_level_check, 3 ); 252 | } 253 | elseif ( \strpos( $namespace_for_level_check, 'Unit\\' ) === 0 ) { 254 | $namespace_for_level_check = \substr( $namespace_for_level_check, 5 ); 255 | } 256 | } 257 | } 258 | } 259 | 260 | $parts = \explode( '\\', $namespace_for_level_check ); 261 | $part_count = \count( $parts ); 262 | 263 | $phpcsFile->recordMetric( $stackPtr, 'Nr of levels in namespace name', $part_count ); 264 | 265 | if ( $part_count > $this->max_levels ) { 266 | $error = 'A namespace name is not allowed to be more than %d levels deep (excluding the prefix). Level depth found: %d in %s'; 267 | $data = [ 268 | $this->max_levels, 269 | $part_count, 270 | $namespace_name, 271 | ]; 272 | 273 | $phpcsFile->addError( $error, $stackPtr, 'MaxExceeded', $data ); 274 | } 275 | elseif ( $part_count > $this->recommended_max_levels ) { 276 | $error = 'A namespace name should be no more than %d levels deep (excluding the prefix). Level depth found: %d in %s'; 277 | $data = [ 278 | $this->recommended_max_levels, 279 | $part_count, 280 | $namespace_name, 281 | ]; 282 | 283 | $phpcsFile->addWarning( $error, $stackPtr, 'TooLong', $data ); 284 | } 285 | } 286 | 287 | /* 288 | * Prepare to check the path to level translation. 289 | */ 290 | 291 | if ( ! isset( $phpcsFile->config->basepath ) ) { 292 | // If no basepath is set, we don't know the project root, so bow out. 293 | return; 294 | } 295 | 296 | if ( $file === '' ) { 297 | // STDIN. 298 | return; // @codeCoverageIgnore 299 | } 300 | 301 | $relative_directory = ''; 302 | if ( \is_array( $psr4_info ) ) { 303 | $relative_directory = $psr4_info['relative']; 304 | } 305 | else { 306 | $directory = PathHelper::normalize_absolute_path( \dirname( $file ) ); 307 | 308 | $this->validate_src_directory( $phpcsFile ); 309 | 310 | if ( empty( $this->validated_src_directory ) === false ) { 311 | foreach ( $this->validated_src_directory as $absolute_src_path ) { 312 | if ( PathHelper::path_starts_with( $directory, $absolute_src_path ) === false ) { 313 | continue; 314 | } 315 | 316 | $relative_directory = PathHelper::strip_basepath( $directory, $absolute_src_path ); 317 | break; 318 | } 319 | } 320 | } 321 | 322 | if ( $relative_directory === '.' ) { 323 | $relative_directory = ''; 324 | } 325 | 326 | // Now any potential src directory has been stripped, remove surrounding slashes. 327 | $relative_directory = \trim( $relative_directory, '/' ); 328 | 329 | // Directory to namespace translation. 330 | $expected = '[Plugin\Prefix]'; 331 | if ( $found_prefix !== '' ) { 332 | $expected = $found_prefix; 333 | } 334 | // Namespace name doesn't have the correct prefix, but we do know what the prefix should be. 335 | elseif ( \is_array( $psr4_info ) ) { 336 | $expected = $psr4_info['prefix']; 337 | } 338 | elseif ( \count( $valid_prefixes ) === 1 ) { 339 | $expected = \rtrim( $valid_prefixes[0], '\\' ); 340 | } 341 | 342 | $clean = []; 343 | $name_for_compare = ''; 344 | 345 | if ( $relative_directory !== '' ) { 346 | $levels = \explode( '/', $relative_directory ); 347 | $levels = \array_filter( $levels ); // Remove empties, just in case. 348 | 349 | foreach ( $levels as $level ) { 350 | $cleaned_level = $level; 351 | if ( $psr4_info === false ) { 352 | $cleaned_level = \preg_replace( '`[[:punct:]]`', '_', $cleaned_level ); 353 | $words = \explode( '_', $cleaned_level ); 354 | $words = \array_map( 'ucfirst', $words ); 355 | $cleaned_level = \implode( '_', $words ); 356 | } 357 | 358 | if ( NamingConventions::isValidIdentifierName( $cleaned_level ) === false ) { 359 | $phpcsFile->addError( 360 | 'Translating the directory name to a namespace name would not yield a valid namespace name. Rename the "%s" directory.', 361 | 0, 362 | 'DirectoryInvalid', 363 | [ $level ] 364 | ); 365 | 366 | // Continuing would be useless as the name would be invalid anyway. 367 | return; 368 | } 369 | 370 | $clean[] = $cleaned_level; 371 | } 372 | 373 | $name_for_compare = \implode( '\\', $clean ); 374 | } 375 | 376 | // Check whether the namespace name complies with the rules. 377 | if ( $psr4_info !== false ) { 378 | // Check for PSR-4 compliant namespace. 379 | if ( \strcmp( $name_for_compare, $namespace_name_no_prefix ) === 0 ) { 380 | return; 381 | } 382 | 383 | $error = 'Directory marked as a PSR-4 path. The namespace name should match the path exactly in a case-sensitive manner. Expected namespace name: "%s"; found: "%s"'; 384 | $code = 'NotPSR4Compliant'; 385 | } 386 | else { 387 | // Check for "old-style" namespace. 388 | if ( \strcasecmp( $name_for_compare, $namespace_name_no_prefix ) === 0 ) { 389 | return; 390 | } 391 | 392 | $error = 'The namespace (sub)level(s) should reflect the directory path to the file. Expected: "%s"; Found: "%s"'; 393 | $code = 'Invalid'; 394 | } 395 | 396 | if ( $name_for_compare !== '' ) { 397 | $expected .= '\\' . $name_for_compare; 398 | } 399 | 400 | $data = [ 401 | $expected, 402 | $namespace_name, 403 | ]; 404 | 405 | $phpcsFile->addError( $error, $stackPtr, $code, $data ); 406 | } 407 | 408 | /** 409 | * Validate a $src_directory property when set in a custom ruleset. 410 | * 411 | * @param File $phpcsFile The file being scanned. 412 | * 413 | * @return void 414 | */ 415 | private function validate_src_directory( File $phpcsFile ) { 416 | if ( $this->previous_src_directory === $this->src_directory ) { 417 | return; 418 | } 419 | 420 | // Set the cache *before* validation so as to not break the above compare. 421 | $this->previous_src_directory = $this->src_directory; 422 | 423 | // Clear out previously validated src directories. 424 | $this->validated_src_directory = []; 425 | 426 | // Note: the check whether a basepath is available is done in the main `process()` routine. 427 | $base_path = PathHelper::normalize_absolute_path( $phpcsFile->config->basepath ); 428 | 429 | // Add any src directories. 430 | $absolute_paths = PathValidationHelper::relative_to_absolute( $phpcsFile, $this->src_directory ); 431 | 432 | // The base path is always a valid src directory. 433 | if ( isset( $absolute_paths['.'] ) === false ) { 434 | $absolute_paths['.'] = $base_path; 435 | } 436 | 437 | $this->validated_src_directory = \array_unique( $absolute_paths ); 438 | 439 | // Use reverse natural sorting to get the longest directory first. 440 | \rsort( $this->validated_src_directory, ( \SORT_NATURAL | \SORT_FLAG_CASE ) ); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /Yoast/Sniffs/NamingConventions/ObjectNameDepthSniff.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | private const TEST_SUFFIXES = [ 31 | 'test' => true, 32 | 'testcase' => true, 33 | 'mock' => false, 34 | 'double' => false, 35 | ]; 36 | 37 | /** 38 | * Maximum number of words. 39 | * 40 | * The maximum number of words an object name should consist of, each 41 | * separated by an underscore. 42 | * 43 | * If the name consists of more words, an ERROR will be thrown. 44 | * 45 | * @var int 46 | */ 47 | public $max_words = 3; 48 | 49 | /** 50 | * Recommended maximum number of words. 51 | * 52 | * The recommended maximum number of words an object name should consist of, each 53 | * separated by an underscore. 54 | * 55 | * If the name consists of more words, a WARNING will be thrown. 56 | * 57 | * @var int 58 | */ 59 | public $recommended_max_words = 3; 60 | 61 | /** 62 | * Returns an array of tokens this test wants to listen for. 63 | * 64 | * @return array 65 | */ 66 | public function register() { 67 | $targets = Tokens::$ooScopeTokens; 68 | unset( $targets[ \T_ANON_CLASS ] ); 69 | 70 | return $targets; 71 | } 72 | 73 | /** 74 | * Processes this test, when one of its tokens is encountered. 75 | * 76 | * @param File $phpcsFile The file being scanned. 77 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 78 | * 79 | * @return void 80 | */ 81 | public function process( File $phpcsFile, $stackPtr ) { 82 | 83 | // Check whether we are in a namespace or not. 84 | if ( Namespaces::determineNamespace( $phpcsFile, $stackPtr ) === '' ) { 85 | return; 86 | } 87 | 88 | $object_name = ObjectDeclarations::getName( $phpcsFile, $stackPtr ); 89 | if ( empty( $object_name ) ) { 90 | return; 91 | } 92 | 93 | $snakecase_object_name = \ltrim( $object_name, '_' ); 94 | 95 | // Handle names which are potentially in CamelCaps. 96 | if ( \strpos( $snakecase_object_name, '_' ) === false ) { 97 | $snakecase_object_name = SnakeCaseHelper::get_suggestion( $snakecase_object_name ); 98 | 99 | // Always treat "TestCase" as one word. 100 | if ( \substr( $snakecase_object_name, -9 ) === 'test_case' ) { 101 | $snakecase_object_name = \substr( $snakecase_object_name, 0, -9 ) . 'testcase'; 102 | } 103 | } 104 | 105 | $parts = \explode( '_', $snakecase_object_name ); 106 | $part_count = \count( $parts ); 107 | 108 | /* 109 | * Allow the OO name to be one part longer for confirmed test/mock/double OO structures. 110 | */ 111 | $tokens = $phpcsFile->getTokens(); 112 | 113 | $last = \strtolower( \array_pop( $parts ) ); 114 | if ( isset( self::TEST_SUFFIXES[ $last ] ) ) { 115 | if ( $tokens[ $stackPtr ]['code'] === \T_ENUM ) { 116 | // Enums cannot extend, but a mock/double without direct link to the parent could be needed. 117 | --$part_count; 118 | } 119 | else { 120 | $extends = ObjectDeclarations::findExtendedClassName( $phpcsFile, $stackPtr ); 121 | if ( \is_string( $extends ) ) { 122 | --$part_count; 123 | } 124 | } 125 | } 126 | 127 | if ( $part_count <= $this->recommended_max_words && $part_count <= $this->max_words ) { 128 | $phpcsFile->recordMetric( $stackPtr, 'Nr of words in object name', $part_count ); 129 | return; 130 | } 131 | 132 | // Check if the OO structure is deprecated. 133 | $ignore = Collections::classModifierKeywords(); 134 | $ignore[ \T_WHITESPACE ] = \T_WHITESPACE; 135 | 136 | $comment_end = $stackPtr; 137 | for ( $comment_end = ( $stackPtr - 1 ); $comment_end >= 0; $comment_end-- ) { 138 | if ( isset( $ignore[ $tokens[ $comment_end ]['code'] ] ) === true ) { 139 | continue; 140 | } 141 | 142 | if ( $tokens[ $comment_end ]['code'] === \T_ATTRIBUTE_END 143 | && isset( $tokens[ $comment_end ]['attribute_opener'] ) === true 144 | ) { 145 | $comment_end = $tokens[ $comment_end ]['attribute_opener']; 146 | continue; 147 | } 148 | 149 | break; 150 | } 151 | 152 | if ( $tokens[ $comment_end ]['code'] === \T_DOC_COMMENT_CLOSE_TAG ) { 153 | // Only check if the OO structure has a docblock. 154 | $comment_start = $tokens[ $comment_end ]['comment_opener']; 155 | foreach ( $tokens[ $comment_start ]['comment_tags'] as $tag ) { 156 | if ( $tokens[ $tag ]['content'] === '@deprecated' ) { 157 | // Deprecated OO structure, ignore. 158 | return; 159 | } 160 | } 161 | } 162 | 163 | $phpcsFile->recordMetric( $stackPtr, 'Nr of words in object name', $part_count ); 164 | 165 | // Not a deprecated OO structure, this OO structure should comply with the rules. 166 | $object_type = 'a ' . $tokens[ $stackPtr ]['content']; 167 | if ( $tokens[ $stackPtr ]['code'] === \T_INTERFACE || $tokens[ $stackPtr ]['code'] === \T_ENUM ) { 168 | $object_type = 'an ' . $tokens[ $stackPtr ]['content']; 169 | } 170 | 171 | if ( $part_count > $this->max_words ) { 172 | $error = 'The name of %s is not allowed to consist of more than %d words. Words found: %d in %s'; 173 | $data = [ 174 | $object_type, 175 | $this->max_words, 176 | $part_count, 177 | $object_name, 178 | ]; 179 | 180 | $phpcsFile->addError( $error, $stackPtr, 'MaxExceeded', $data ); 181 | } 182 | elseif ( $part_count > $this->recommended_max_words ) { 183 | $error = 'The name of %s should not consist of more than %d words. Words found: %d in %s'; 184 | $data = [ 185 | $object_type, 186 | $this->recommended_max_words, 187 | $part_count, 188 | $object_name, 189 | ]; 190 | 191 | $phpcsFile->addWarning( $error, $stackPtr, 'TooLong', $data ); 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Yoast/Sniffs/NamingConventions/ValidHookNameSniff.php: -------------------------------------------------------------------------------- 1 | > $parameters Array with information about the parameters. 98 | * 99 | * @return void 100 | */ 101 | public function process_parameters( $stackPtr, $group_name, $matched_content, $parameters ) { 102 | $hook_name_param = WPHookHelper::get_hook_name_param( $matched_content, $parameters ); 103 | if ( $hook_name_param === false ) { 104 | // If we can't find the hook name parameter, there's nothing to do, so bow out. 105 | return; 106 | } 107 | 108 | /* 109 | * The custom prefix should be in the first text passed to `transform()` for each 110 | * matched function call. 111 | * 112 | * Reset the properties which help manage this each time a new function call 113 | * is encountered. 114 | */ 115 | $this->found_prefix = ''; 116 | $this->first_string = ''; 117 | $this->prefix_quote_style = ''; 118 | $this->validate_prefixes(); 119 | 120 | /* 121 | * If any prefixes were passed, check if this is a hook belonging to the plugin being checked. 122 | */ 123 | if ( empty( $this->validated_prefixes ) === false ) { 124 | $first_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, $hook_name_param['start'], ( $hook_name_param['end'] + 1 ), true ); 125 | $found_prefix = ''; 126 | 127 | if ( isset( Tokens::$stringTokens[ $this->tokens[ $first_non_empty ]['code'] ] ) ) { 128 | $this->prefix_quote_style = $this->tokens[ $first_non_empty ]['content'][0]; 129 | $content = \trim( TextStrings::stripQuotes( $this->tokens[ $first_non_empty ]['content'] ) ); 130 | 131 | foreach ( $this->validated_prefixes as $prefix ) { 132 | if ( \strpos( $prefix, '\\' ) === false 133 | && \strpos( $content, $prefix ) === 0 134 | ) { 135 | $found_prefix = $prefix; 136 | break; 137 | } 138 | 139 | $prefix = \str_replace( '\\\\', '[\\\\]+', \preg_quote( $prefix, '`' ) ); 140 | if ( \preg_match( '`^' . $prefix . '`', $content, $matches ) === 1 ) { 141 | $found_prefix = $matches[0]; 142 | break; 143 | } 144 | } 145 | } 146 | 147 | if ( $found_prefix === '' ) { 148 | /* 149 | * Not a hook name with a prefix indicating it belongs to the specific plugin 150 | * being checked. Ignore as it's probably a WP Core or external plugin hook name 151 | * which we cannot change. 152 | */ 153 | return; 154 | } 155 | 156 | $this->found_prefix = $found_prefix; 157 | } 158 | 159 | // Do the WPCS native hook name check. 160 | parent::process_parameters( $stackPtr, $group_name, $matched_content, $parameters ); 161 | 162 | if ( $this->found_prefix === '' ) { 163 | return; 164 | } 165 | 166 | // Do the YoastCS specific hook name length and prefix check. 167 | $this->verify_yoast_hook_name( $stackPtr, $hook_name_param ); 168 | } 169 | 170 | /** 171 | * Transform an arbitrary string to lowercase and replace punctuation and spaces with underscores. 172 | * 173 | * This overloads the parent to prevent errors being triggered on the Yoast specific 174 | * plugin prefix for hook names and remembers whether a prefix was found to allow 175 | * checking whether it was the correct one. 176 | * 177 | * @param string $text_string The target string. 178 | * @param string $regex The punctuation regular expression to use. 179 | * @param string $transform_type Whether to do a partial or complete transform. 180 | * Valid values are: 'full', 'case', 'punctuation'. 181 | * 182 | * @return string 183 | */ 184 | protected function transform( $text_string, $regex, $transform_type = 'full' ) { 185 | 186 | if ( empty( $this->validated_prefixes ) ) { 187 | return parent::transform( $text_string, $regex, $transform_type ); 188 | } 189 | 190 | if ( $this->first_string === '' ) { 191 | $this->first_string = $text_string; 192 | } 193 | 194 | // Not the first text string. 195 | if ( $text_string !== $this->first_string ) { 196 | return parent::transform( $text_string, $regex, $transform_type ); 197 | } 198 | 199 | // Repeated call for the first text string. 200 | if ( $this->found_prefix !== '' ) { 201 | $text_string = \substr( $text_string, \strlen( $this->found_prefix ) ); 202 | } 203 | 204 | return $this->found_prefix . parent::transform( $text_string, $regex, $transform_type ); 205 | } 206 | 207 | /** 208 | * Additional YoastCS specific hook name checks. 209 | * 210 | * @param int $stackPtr The position of the current token in the stack. 211 | * @param array $hook_name_param Array with information about the hook name parameter. 212 | * 213 | * @return void 214 | */ 215 | private function verify_yoast_hook_name( $stackPtr, $hook_name_param ) { 216 | // @phpstan-ignore binaryOp.invalid, argument.type (The passed value will only ever be an integer, PHPStan just doesn't know the shape of the array.) 217 | $first_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, $hook_name_param['start'], ( $hook_name_param['end'] + 1 ), true ); 218 | if ( $first_non_empty === false ) { 219 | // Shouldn't be possible as we've checked this before. 220 | return; // @codeCoverageIgnore 221 | } 222 | 223 | /* 224 | * Check that the namespace-like prefix is used for hooks. 225 | */ 226 | if ( \strpos( $this->found_prefix, '\\' ) === false ) { 227 | /* 228 | * Find which namespace-based prefix should have been used. 229 | * Loop till the end as the shortest prefix will be last. 230 | */ 231 | $namespace_prefix = ''; 232 | foreach ( $this->validated_prefixes as $prefix ) { 233 | if ( \strpos( $prefix, '\\' ) !== false ) { 234 | $namespace_prefix = $prefix; 235 | } 236 | } 237 | 238 | $this->phpcsFile->addWarning( 239 | 'Wrong prefix type used. Hook names should use the "%s" namespace-like prefix. Found prefix: %s', 240 | $first_non_empty, 241 | 'WrongPrefix', 242 | [ 243 | $namespace_prefix, 244 | $this->found_prefix, 245 | ] 246 | ); 247 | 248 | $this->phpcsFile->recordMetric( $stackPtr, 'Hook name prefix type', 'old_style' ); 249 | } 250 | else { 251 | $this->phpcsFile->recordMetric( $stackPtr, 'Hook name prefix type', 'new\style' ); 252 | 253 | /* 254 | * Check whether the namespace separator slashes are correctly escaped. 255 | */ 256 | if ( $this->prefix_quote_style === '"' ) { 257 | \preg_match_all( '`[\\\\]+`', $this->found_prefix, $matches ); 258 | if ( empty( $matches[0] ) === false ) { 259 | $counter = 0; 260 | foreach ( $matches[0] as $match ) { 261 | if ( $match === '\\' ) { 262 | ++$counter; 263 | } 264 | } 265 | 266 | if ( $counter > 0 ) { 267 | $this->phpcsFile->addWarning( 268 | 'When using a double quoted string for the hook name, it is strongly recommended to escape the backslashes in the hook name (prefix). Found %s unescaped backslashes.', 269 | $first_non_empty, 270 | 'EscapeSlashes', 271 | [ $counter ] 272 | ); 273 | } 274 | } 275 | } 276 | } 277 | 278 | /* 279 | * Check if the hook name is a single quoted string. 280 | */ 281 | $allow = [ \T_CONSTANT_ENCAPSED_STRING ]; 282 | $allow += Tokens::$emptyTokens; 283 | 284 | // @phpstan-ignore binaryOp.invalid, argument.type (The passed value will only ever be an integer, PHPStan just doesn't know the shape of the array.) 285 | $has_non_string = $this->phpcsFile->findNext( $allow, $hook_name_param['start'], ( $hook_name_param['end'] + 1 ), true ); 286 | if ( $has_non_string !== false ) { 287 | /* 288 | * Double quoted string or a hook name concatenated together, checking the word count for the 289 | * hook name can not be done in a reliable manner. 290 | * 291 | * Throwing a warning to allow for examining these if desired. 292 | * Severity 3 makes sure that this warning will normally be invisible and will only 293 | * be thrown when PHPCS is explicitly requested to check with a lower severity. 294 | */ 295 | $this->phpcsFile->addWarning( 296 | 'Hook name could not reliably be examined for maximum word count (max is %d words). Please verify this hook name manually. Found: %s', 297 | $first_non_empty, 298 | 'NonString', 299 | [ 300 | $this->max_words, 301 | $hook_name_param['raw'], 302 | ], 303 | 3 304 | ); 305 | 306 | $this->phpcsFile->recordMetric( $stackPtr, 'Nr of words in hook name', 'undetermined' ); 307 | 308 | return; 309 | } 310 | 311 | /* 312 | * Check the hook name depth. 313 | */ 314 | $hook_ptr = $first_non_empty; // If no other tokens were found, the first non empty will be the hook name. 315 | $hook_name = TextStrings::stripQuotes( $this->tokens[ $hook_ptr ]['content'] ); 316 | $hook_name = \substr( $hook_name, \strlen( $this->found_prefix ) ); 317 | 318 | $parts = \explode( '_', $hook_name ); 319 | $part_count = \count( $parts ); 320 | 321 | $this->phpcsFile->recordMetric( $stackPtr, 'Nr of words in hook name', $part_count ); 322 | 323 | if ( $part_count > $this->max_words ) { 324 | $error = 'A hook name is not allowed to consist of more than %d words after the plugin prefix. Words found: %d in %s'; 325 | $data = [ 326 | $this->max_words, 327 | $part_count, 328 | $this->tokens[ $hook_ptr ]['content'], 329 | ]; 330 | 331 | $this->phpcsFile->addError( $error, $hook_ptr, 'MaxExceeded', $data ); 332 | } 333 | elseif ( $part_count > $this->recommended_max_words ) { 334 | $error = 'A hook name should not consist of more than %d words after the plugin prefix. Words found: %d in %s'; 335 | $data = [ 336 | $this->recommended_max_words, 337 | $part_count, 338 | $this->tokens[ $hook_ptr ]['content'], 339 | ]; 340 | 341 | $this->phpcsFile->addWarning( $error, $hook_ptr, 'TooLong', $data ); 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /Yoast/Sniffs/Tools/BrainMonkeyRaceConditionSniff.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public function register() { 31 | return [ \T_STRING ]; 32 | } 33 | 34 | /** 35 | * Processes this test, when one of its tokens is encountered. 36 | * 37 | * @param File $phpcsFile The file being scanned. 38 | * @param int $stackPtr The position of the current token in the stack passed in $tokens. 39 | * 40 | * @return void 41 | */ 42 | public function process( File $phpcsFile, $stackPtr ) { 43 | $tokens = $phpcsFile->getTokens(); 44 | 45 | if ( \strtolower( $tokens[ $stackPtr ]['content'] ) !== 'expect' ) { 46 | return; 47 | } 48 | 49 | $nextNonEmpty = $phpcsFile->findNext( Tokens::$emptyTokens, ( $stackPtr + 1 ), null, true ); 50 | if ( $nextNonEmpty === false || $tokens[ $nextNonEmpty ]['code'] !== \T_OPEN_PARENTHESIS ) { 51 | // Definitely not a function call. 52 | return; 53 | } 54 | 55 | $prevNonEmpty = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true ); 56 | if ( $prevNonEmpty === false 57 | || isset( Collections::objectOperators()[ $tokens[ $prevNonEmpty ]['code'] ] ) 58 | || $tokens[ $prevNonEmpty ]['code'] === \T_FUNCTION 59 | ) { 60 | // Method call or function declaration, not a function call. 61 | return; 62 | } 63 | 64 | $functionToken = Conditions::getLastCondition( $phpcsFile, $stackPtr, [ \T_FUNCTION ] ); 65 | if ( $functionToken === false ) { 66 | return; 67 | } 68 | 69 | if ( Scopes::isOOMethod( $phpcsFile, $functionToken ) === false ) { 70 | return; 71 | } 72 | 73 | // Check that this is an expect() for one of the hook functions. 74 | $param = PassedParameters::getParameter( $phpcsFile, $stackPtr, 1, 'function_name' ); 75 | if ( empty( $param ) ) { 76 | return; 77 | } 78 | 79 | $expected = Tokens::$emptyTokens; 80 | $expected[] = \T_CONSTANT_ENCAPSED_STRING; 81 | 82 | // @phpstan-ignore binaryOp.invalid, argument.type (The passed value will only ever be an integer, PHPStan just doesn't know the shape of the array.) 83 | $hasUnexpected = $phpcsFile->findNext( $expected, $param['start'], ( $param['end'] + 1 ), true ); 84 | if ( $hasUnexpected !== false ) { 85 | return; 86 | } 87 | 88 | // @phpstan-ignore binaryOp.invalid, argument.type (The passed value will only ever be an integer, PHPStan just doesn't know the shape of the array.) 89 | $text = $phpcsFile->findNext( Tokens::$emptyTokens, $param['start'], ( $param['end'] + 1 ), true ); 90 | $textContent = TextStrings::stripQuotes( $tokens[ $text ]['content'] ); 91 | if ( $textContent !== 'apply_filters' && $textContent !== 'do_action' ) { 92 | return; 93 | } 94 | 95 | // Now walk the contents of the function declaration to see if we can find the other function call. 96 | if ( isset( $tokens[ $functionToken ]['scope_opener'], $tokens[ $functionToken ]['scope_closer'] ) === false ) { 97 | // We don't know the start or end of the function. Edge case which can't happen under normal circumstances. 98 | return; // @codeCoverageIgnore 99 | } 100 | 101 | $targetContent = 'expectdone'; 102 | if ( $textContent === 'apply_filters' ) { 103 | $targetContent = 'expectapplied'; 104 | } 105 | 106 | $start = $tokens[ $functionToken ]['scope_opener']; 107 | $end = $tokens[ $functionToken ]['scope_closer']; 108 | 109 | for ( $i = $start; $i < $end; $i++ ) { 110 | if ( $tokens[ $i ]['code'] !== \T_STRING ) { 111 | continue; 112 | } 113 | 114 | if ( \strtolower( $tokens[ $i ]['content'] ) !== $targetContent ) { 115 | continue; 116 | } 117 | 118 | // Make sure it is a function call. 119 | $next = $phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), null, true ); 120 | if ( $next === false || $tokens[ $next ]['code'] !== \T_OPEN_PARENTHESIS ) { 121 | continue; 122 | } 123 | 124 | // Okay, we have found the race condition. Throw error. 125 | $message = 'The %s() test method contains both a call to Monkey\Functions\expect( %s ), as well as a call to %s(). This causes a race condition which will cause the tests to fail. Only use one of these in a test.'; 126 | $data = [ 127 | FunctionDeclarations::getName( $phpcsFile, $functionToken ), 128 | $tokens[ $text ]['content'], 129 | ( $targetContent === 'expectdone' ) ? 'Monkey\Actions\expectDone' : 'Monkey\Filters\expectApplied', 130 | ]; 131 | 132 | $phpcsFile->addError( $message, $functionToken, 'Found', $data ); 133 | break; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Yoast/Sniffs/WhiteSpace/FunctionSpacingSniff.php: -------------------------------------------------------------------------------- 1 | > Function names as the keys and the name of the first declared parameter 33 | * as the value. 34 | * There can be multiple parameter names if the parameter 35 | * was renamed over time. 36 | */ 37 | private const PARAM_INFO = [ 38 | 'json_encode' => 'value', 39 | 40 | /* 41 | * The current parameter name is `$data`, but this is expected to be changed to `$value` in WP 6.5. 42 | * See: https://core.trac.wordpress.org/ticket/59630 43 | */ 44 | 'wp_json_encode' => [ 'data', 'value' ], 45 | ]; 46 | 47 | /** 48 | * Groups of functions to restrict. 49 | * 50 | * @return array>> 51 | */ 52 | public function getGroups() { 53 | return [ 54 | 'json_encode' => [ 55 | 'functions' => [ 56 | 'json_encode', 57 | 'wp_json_encode', 58 | ], 59 | ], 60 | ]; 61 | } 62 | 63 | /** 64 | * Process a matched token. 65 | * 66 | * @param int $stackPtr The position of the current token in the stack. 67 | * @param string $group_name The name of the group which was matched. 68 | * @param string $matched_content The token content (function name) which was matched 69 | * in lowercase. 70 | * 71 | * @return void 72 | */ 73 | public function process_matched_token( $stackPtr, $group_name, $matched_content ) { 74 | $error = 'Detected a call to %s(). Use %s() instead.'; 75 | $error_code = 'Found'; 76 | $data = [ 77 | $matched_content, 78 | self::REPLACEMENT, 79 | ]; 80 | 81 | $params = PassedParameters::getParameters( $this->phpcsFile, $stackPtr ); 82 | 83 | /* 84 | * If no parameters were passed, we can safely replace the function call, even though 85 | * the function call itself, as-is, is not correct/working (but that's not the concern of 86 | * this sniff). 87 | */ 88 | if ( empty( $params ) ) { 89 | /* 90 | * Make sure this is not a PHP 8.1+ first class callable. If it is, throw the error, but don't autofix. 91 | */ 92 | $ignore = Tokens::$emptyTokens; 93 | $ignore[ \T_OPEN_PARENTHESIS ] = \T_OPEN_PARENTHESIS; 94 | 95 | $first_in_call = $this->phpcsFile->findNext( $ignore, ( $stackPtr + 1 ), null, true ); 96 | if ( $first_in_call !== false && $this->tokens[ $first_in_call ]['code'] === \T_ELLIPSIS ) { 97 | $error_code .= 'InFirstClassCallable'; 98 | $this->phpcsFile->addError( $error, $stackPtr, $error_code, $data ); 99 | return; 100 | } 101 | 102 | $fix = $this->phpcsFile->addFixableError( $error, $stackPtr, $error_code, $data ); 103 | if ( $fix === true ) { 104 | $this->fix_it( $stackPtr ); 105 | } 106 | 107 | return; 108 | } 109 | 110 | /* 111 | * If there are function parameters, we need to verify that only the first ($value) parameter 112 | * was passed, taking PHP 8.0+ function calls with named parameters into account. 113 | * 114 | * We also need to check for parameter unpacking being used as in that case, the 115 | * parameter count will be unreliable. 116 | */ 117 | $value_param = PassedParameters::getParameterFromStack( $params, 1, self::PARAM_INFO[ $matched_content ] ); 118 | if ( \is_array( $value_param ) && \count( $params ) === 1 ) { 119 | // @phpstan-ignore binaryOp.invalid, argument.type (The passed value will only ever be an integer, PHPStan just doesn't know the shape of the array.) 120 | $first_token = $this->phpcsFile->findNext( Tokens::$emptyTokens, $value_param['start'], ( $value_param['end'] + 1 ), true ); 121 | if ( $first_token === false || $this->tokens[ $first_token ]['code'] !== \T_ELLIPSIS ) { 122 | /* 123 | * Okay, so this is a function call with only the first/$value parameter passed. 124 | * This can be safely replaced. 125 | */ 126 | $fix = $this->phpcsFile->addFixableError( $error, $stackPtr, $error_code, $data ); 127 | if ( $fix === true ) { 128 | $this->fix_it( $stackPtr, $value_param ); 129 | } 130 | 131 | return; 132 | } 133 | } 134 | 135 | /* 136 | * In all other cases, we cannot auto-fix, only flag. 137 | */ 138 | $error_code .= 'WithAdditionalParams'; 139 | 140 | $this->phpcsFile->addError( $error, $stackPtr, $error_code, $data ); 141 | } 142 | 143 | /** 144 | * Auto-fix the function call to use the replacement function. 145 | * 146 | * @since 3.0.0 147 | * 148 | * @param int $stackPtr The position of the current token in the stack. 149 | * @param array|false $value_param Optional. Parameter information for the first/$value 150 | * parameter if available, or false if not. 151 | * 152 | * @return void 153 | */ 154 | private function fix_it( $stackPtr, $value_param = false ) { 155 | $this->phpcsFile->fixer->beginChangeset(); 156 | 157 | // Remove potential leading namespace separator for fully qualified function call. 158 | $prev = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true ); 159 | if ( $prev !== false && $this->tokens[ $prev ]['code'] === \T_NS_SEPARATOR ) { 160 | $this->phpcsFile->fixer->replaceToken( $prev, '' ); 161 | } 162 | 163 | // Replace the function call with a, potentially fully qualified, call to the replacement. 164 | $namespaced = Namespaces::determineNamespace( $this->phpcsFile, $stackPtr ); 165 | if ( empty( $namespaced ) ) { 166 | $this->phpcsFile->fixer->replaceToken( $stackPtr, self::REPLACEMENT ); 167 | } 168 | else { 169 | $this->phpcsFile->fixer->replaceToken( $stackPtr, '\\' . self::REPLACEMENT ); 170 | } 171 | 172 | if ( \is_array( $value_param ) && isset( $value_param['name_token'] ) ) { 173 | // Update the parameter name when the function call uses named parameters. 174 | // `$data` is the parameter name used in the WPSEO_Utils::format_json_encode() function. 175 | 176 | // @phpstan-ignore argument.type (The passed value will only ever be an integer, PHPStan just doesn't know the shape of the array.) 177 | $this->phpcsFile->fixer->replaceToken( $value_param['name_token'], 'data' ); 178 | } 179 | 180 | $this->phpcsFile->fixer->endChangeset(); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Yoast/Utils/CustomPrefixesTrait.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public $prefixes = []; 29 | 30 | /** 31 | * Target prefixes after validation. 32 | * 33 | * @var array 34 | */ 35 | protected $validated_prefixes = []; 36 | 37 | /** 38 | * Cache of previously set prefixes. 39 | * 40 | * Prevents having to do the same prefix validation over and over again. 41 | * 42 | * @var array 43 | */ 44 | protected $previous_prefixes = []; 45 | 46 | /** 47 | * Prepare the prefixes array for use by a sniff. 48 | * 49 | * Checks and makes sure that: 50 | * - "Namespace"-like prefixes do not start with a `\` and end with a `\`. 51 | * - Non-namespace-like prefixes do not start with a `_` and end with a `_`. 52 | * 53 | * @return void 54 | */ 55 | final protected function validate_prefixes() { 56 | if ( $this->previous_prefixes === $this->prefixes ) { 57 | return; 58 | } 59 | 60 | // Set the cache *before* validation so as to not break the above compare. 61 | $this->previous_prefixes = $this->prefixes; 62 | 63 | $prefixes = (array) $this->prefixes; 64 | $prefixes = \array_filter( \array_map( 'trim', $prefixes ) ); 65 | 66 | if ( empty( $prefixes ) ) { 67 | $this->validated_prefixes = []; 68 | return; 69 | } 70 | 71 | // Allow sniffs to add extra rules. 72 | $prefixes = $this->filter_prefixes( $prefixes ); 73 | 74 | $validated = []; 75 | foreach ( $prefixes as $prefix ) { 76 | if ( \strpos( $prefix, '\\' ) !== false ) { 77 | $prefix = \trim( $prefix, '\\' ); 78 | $validated[] = $prefix . '\\'; 79 | } 80 | else { 81 | // Old-style prefix. 82 | $prefix = \trim( $prefix, '_' ); 83 | $validated[] = $prefix . '_'; 84 | } 85 | } 86 | 87 | // Use reverse natural sorting to get the longest prefix first. 88 | \rsort( $validated, ( \SORT_NATURAL | \SORT_FLAG_CASE ) ); 89 | 90 | // Set the validated prefixes cache. 91 | $this->validated_prefixes = $validated; 92 | } 93 | 94 | /** 95 | * Overloadable method to do custom prefix filtering prior to validation. 96 | * 97 | * @param array $prefixes The unvalidated prefixes. 98 | * 99 | * @return array 100 | */ 101 | protected function filter_prefixes( $prefixes ) { 102 | return $prefixes; 103 | } 104 | 105 | /** 106 | * Filter out all prefixes which don't contain a namespace separator. 107 | * 108 | * @param array $prefixes The unvalidated prefixes. 109 | * 110 | * @return array 111 | */ 112 | final protected function filter_allow_only_namespace_prefixes( $prefixes ) { 113 | $filtered = []; 114 | foreach ( $prefixes as $prefix ) { 115 | if ( \strpos( $prefix, '\\' ) === false ) { 116 | continue; 117 | } 118 | 119 | $filtered[] = $prefix; 120 | } 121 | 122 | return $filtered; 123 | } 124 | 125 | /** 126 | * Filter out all prefixes which only contain lowercase characters. 127 | * 128 | * @param array $prefixes The unvalidated prefixes. 129 | * 130 | * @return array 131 | */ 132 | final protected function filter_exclude_lowercase_prefixes( $prefixes ) { 133 | $filtered = []; 134 | foreach ( $prefixes as $prefix ) { 135 | if ( \strtolower( $prefix ) === $prefix ) { 136 | continue; 137 | } 138 | 139 | $filtered[] = $prefix; 140 | } 141 | 142 | return $filtered; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Yoast/Utils/PSR4PathsTrait.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | * 24 | * 25 | * 26 | * 27 | * 28 | * 29 | * 30 | * 31 | * ``` 32 | * 33 | * Note: paths are handled case-sensitively! 34 | * 35 | * @var array Key should be the prefix, value a comma-separated list of relative paths. 36 | */ 37 | public $psr4_paths = []; 38 | 39 | /** 40 | * Cache of previously set list of psr4 paths. 41 | * 42 | * Prevents having to do the same path validation over and over again. 43 | * 44 | * @var array 45 | */ 46 | private $previous_psr4_paths = []; 47 | 48 | /** 49 | * Validated & cleaned up list of absolute paths to the directories expecting PSR-4 file names 50 | * with their associated prefixes. 51 | * 52 | * @var array Key is the absolute path, value the applicable prefix without trailing slash. 53 | */ 54 | private $validated_psr4_paths = []; 55 | 56 | /** 57 | * Check if the file is in one of the PSR4 directories. 58 | * 59 | * @param File $phpcsFile The file being scanned. 60 | * @param string $path_to_file Optional The absolute path to the file currently being examined. 61 | * If not provided, the file name will be retrieved from the File object. 62 | * 63 | * @return bool 64 | */ 65 | final protected function is_in_psr4_path( File $phpcsFile, $path_to_file = '' ) { 66 | return \is_array( $this->get_psr4_info( $phpcsFile, $path_to_file ) ); 67 | } 68 | 69 | /** 70 | * Retrieve all applicable information for a PSR-4 path. 71 | * 72 | * @param File $phpcsFile The file being scanned. 73 | * @param string $path_to_file Optional The absolute path to the file currently being examined. 74 | * If not provided, the file name will be retrieved from the File object. 75 | * 76 | * @return array|false Array with information about the PSR-4 path. Otherwise FALSE. 77 | */ 78 | final protected function get_psr4_info( File $phpcsFile, $path_to_file = '' ) { 79 | if ( $path_to_file === '' ) { 80 | $path_to_file = TextStrings::stripQuotes( $phpcsFile->getFileName() ); 81 | if ( $path_to_file === 'STDIN' ) { 82 | return false; 83 | } 84 | } 85 | 86 | $this->validate_psr4_paths( $phpcsFile ); 87 | if ( empty( $this->validated_psr4_paths ) ) { 88 | return false; 89 | } 90 | 91 | $path_to_file = PathHelper::normalize_absolute_path( $path_to_file ); 92 | 93 | foreach ( $this->validated_psr4_paths as $psr4_path => $prefix ) { 94 | $remainder = PathHelper::strip_basepath( $path_to_file, $psr4_path ); 95 | if ( $remainder === $path_to_file ) { 96 | // Nothing was stripped, so this wasn't a match. 97 | continue; 98 | } 99 | 100 | return [ 101 | 'prefix' => $prefix, 102 | 'basepath' => $psr4_path, 103 | 'relative' => \dirname( $remainder ), 104 | ]; 105 | } 106 | 107 | return false; 108 | } 109 | 110 | /** 111 | * Validate the list of PSR-4 paths passed from a custom ruleset. 112 | * 113 | * This will only need to be done once in a normal PHPCS run, though for 114 | * tests the function may be called multiple times. 115 | * 116 | * @param File $phpcsFile The file being scanned. 117 | * 118 | * @return void 119 | * 120 | * @throws RuntimeException When the `psr4_paths` array is missing keys. 121 | * @throws RuntimeException When the `psr4_paths` array contains duplicate paths in multiple entries. 122 | */ 123 | private function validate_psr4_paths( File $phpcsFile ) { 124 | // The basepath check needs to be done first as otherwise the previous/current comparison would be broken. 125 | if ( ! isset( $phpcsFile->config->basepath ) ) { 126 | // Only relevant for the tests: make sure previously set validated paths are cleared out. 127 | $this->validated_psr4_paths = []; 128 | 129 | // No use continuing as we can't turn relative paths into absolute paths. 130 | return; 131 | } 132 | 133 | if ( $this->previous_psr4_paths === $this->psr4_paths ) { 134 | return; 135 | } 136 | 137 | // Set the cache *before* validation so as to not break the above compare. 138 | $this->previous_psr4_paths = $this->psr4_paths; 139 | 140 | $validated_paths = []; 141 | 142 | foreach ( $this->psr4_paths as $prefix => $paths ) { 143 | // @phpstan-ignore function.alreadyNarrowedType, identical.alwaysFalse (Defensive coding as the property value is user provided via the ruleset.) 144 | if ( \is_string( $prefix ) === false || $prefix === '' ) { 145 | throw new RuntimeException( 146 | 'Invalid value passed for `psr4_paths`. Path "' . $paths . '" is not associated with a namespace prefix' 147 | ); 148 | } 149 | 150 | $prefix = \rtrim( $prefix, '\\' ); 151 | 152 | $paths = \rtrim( \ltrim( $paths, '[' ), ']' ); // Trim off potential [] copied over from Composer.json. 153 | $paths = \explode( ',', $paths ); 154 | $paths = \array_map( 'trim', $paths ); 155 | $paths = \array_map( [ TextStrings::class, 'stripQuotes' ], $paths ); // Trim off potential quotes around the paths copied over. 156 | $paths = \array_map( 'trim', $paths ); 157 | $paths = PathValidationHelper::relative_to_absolute( $phpcsFile, $paths ); 158 | $paths = \array_unique( $paths ); // Filter out multiple of the same paths for the same prefix. 159 | 160 | foreach ( $paths as $path ) { 161 | if ( isset( $validated_paths[ $path ] ) ) { 162 | throw new RuntimeException( 163 | 'Invalid value passed for `psr4_paths`. Multiple prefixes include the same path. Problem path: ' . $path 164 | ); 165 | } 166 | 167 | $validated_paths[ $path ] = $prefix; 168 | } 169 | } 170 | 171 | // Set the validated value. 172 | $this->validated_psr4_paths = $validated_paths; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Yoast/Utils/PathHelper.php: -------------------------------------------------------------------------------- 1 | $relative_paths Array of relative paths which should become absolute paths. 28 | * Paths are expected to be relative to the "basepath" setting. 29 | * 30 | * @return array Array of absolute paths or an empty array if the conversion could not be executed. 31 | * The array will contain the original relative paths as the keys and the absolute paths 32 | * as the values. 33 | * Note: multiple relative paths may result in the same absolute path. 34 | * The values are not guaranteed to be unique! 35 | */ 36 | public static function relative_to_absolute( File $phpcsFile, array $relative_paths ) { 37 | $absolute = []; 38 | 39 | if ( ! isset( $phpcsFile->config->basepath ) ) { 40 | // No use continuing as we can't turn relative paths into absolute paths. 41 | return $absolute; 42 | } 43 | 44 | $base_path = PathHelper::normalize_absolute_path( $phpcsFile->config->basepath ); 45 | 46 | foreach ( $relative_paths as $path ) { 47 | $result_path = \trim( $path ); 48 | $result_path = PathHelper::normalize_relative_path( $result_path ); 49 | 50 | if ( $result_path === '' ) { 51 | continue; 52 | } 53 | 54 | if ( \strpos( $result_path, '..' ) !== false ) { 55 | // Ignore paths containing path walking. 56 | continue; 57 | } 58 | 59 | if ( $result_path === './' ) { 60 | $absolute[ $path ] = $base_path; 61 | continue; 62 | } 63 | 64 | if ( \strpos( $result_path, './' ) === 0 ) { 65 | $result_path = \substr( $result_path, 2 ); 66 | } 67 | 68 | $absolute[ $path ] = $base_path . $result_path; 69 | } 70 | 71 | return $absolute; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Yoast/ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Yoast Coding Standards 5 | 6 | 12 | 13 | 14 | */.git/* 15 | */.wordpress-svn/* 16 | 17 | 18 | */node_modules/* 19 | */vendor(_prefixed)?/* 20 | 21 | 22 | */wp-content/plugins/* 23 | 24 | 25 | 34 | 35 | ./../autoload-bootstrap.php 36 | 37 | 38 | 45 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | error 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | error 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 153 | 154 | 5 155 | 156 | 157 | 5 158 | 159 | 160 | 5 161 | 162 | 163 | 164 | 169 | 170 | 171 | *\.php$ 172 | 173 | 174 | 175 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 332 | 333 | 334 | 5 335 | 336 | 337 | 5 338 | 339 | 340 | 341 | 342 | 5 343 | 344 | 345 | 5 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | */tests/*\.php$ 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | */index\.php$ 415 | 416 | 417 | */index\.php$ 418 | 419 | 420 | 421 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 5 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 474 | 476 | 477 | */tests/*\.php$ 478 | 479 | 480 | 482 | 483 | */tests/*\.php$ 484 | 485 | 486 | 487 | 488 | */tests(/*)?/Doubles/*\.php$ 489 | 490 | 491 | 492 | 493 | */tests/*\.php$ 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | */tests/*\.php$ 502 | 503 | */tests(/*)?/Doubles/*\.php$ 504 | 505 | 506 | 507 | -------------------------------------------------------------------------------- /autoload-bootstrap.php: -------------------------------------------------------------------------------- 1 | =7.2", 28 | "ext-tokenizer": "*", 29 | "automattic/vipwpcs": "^3.0.1", 30 | "php-parallel-lint/php-console-highlighter": "^1.0.0", 31 | "php-parallel-lint/php-parallel-lint": "^1.4.0", 32 | "phpcompatibility/phpcompatibility-wp": "^2.1.6", 33 | "phpcsstandards/phpcsextra": "^1.2.1", 34 | "phpcsstandards/phpcsutils": "^1.0.12", 35 | "sirbrillig/phpcs-variable-analysis": "^2.12.0", 36 | "slevomat/coding-standard": "^8.15.0", 37 | "squizlabs/php_codesniffer": "^3.12.0", 38 | "wp-coding-standards/wpcs": "^3.1.0" 39 | }, 40 | "require-dev": { 41 | "phpcompatibility/php-compatibility": "^9.3.5", 42 | "phpcsstandards/phpcsdevtools": "^1.2.2", 43 | "phpunit/phpunit": "^8.0 || ^9.0", 44 | "roave/security-advisories": "dev-master" 45 | }, 46 | "minimum-stability": "dev", 47 | "prefer-stable": true, 48 | "config": { 49 | "allow-plugins": { 50 | "dealerdirect/phpcodesniffer-composer-installer": true 51 | }, 52 | "lock": false 53 | }, 54 | "scripts": { 55 | "lint": [ 56 | "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude .git" 57 | ], 58 | "check-cs": [ 59 | "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs" 60 | ], 61 | "fix-cs": [ 62 | "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf" 63 | ], 64 | "test": [ 65 | "@php ./vendor/phpunit/phpunit/phpunit --filter Yoast ./vendor/squizlabs/php_codesniffer/tests/AllTests.php --no-coverage" 66 | ], 67 | "coverage": [ 68 | "@php ./vendor/phpunit/phpunit/phpunit --filter Yoast ./vendor/squizlabs/php_codesniffer/tests/AllTests.php" 69 | ], 70 | "check-complete": [ 71 | "@php ./vendor/phpcsstandards/phpcsdevtools/bin/phpcs-check-feature-completeness ./Yoast" 72 | ] 73 | }, 74 | "scripts-descriptions": { 75 | "lint": "Check the PHP files for parse errors.", 76 | "check-cs": "Check the PHP files for code style violations and best practices.", 77 | "fix-cs": "Auto-fix code style violations in the PHP files.", 78 | "test": "Run the unit tests without code coverage.", 79 | "coverage": "Run the unit tests with code coverage.", 80 | "check-complete": "Check if all the sniffs have tests and XML documentation." 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /phpunit-bootstrap.php: -------------------------------------------------------------------------------- 1 | true, 64 | ]; 65 | 66 | $all_standards = Standards::getInstalledStandards(); 67 | $all_standards[] = 'Generic'; 68 | 69 | $standards_to_ignore = []; 70 | foreach ( $all_standards as $standard ) { 71 | if ( isset( $yoast_standards[ $standard ] ) === true ) { 72 | continue; 73 | } 74 | 75 | $standards_to_ignore[] = $standard; 76 | } 77 | 78 | $standards_to_ignore_string = implode( ',', $standards_to_ignore ); 79 | 80 | // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_putenv -- This is not production, but test code. 81 | putenv( "PHPCS_IGNORE_TESTS={$standards_to_ignore_string}" ); 82 | 83 | // Clean up. 84 | unset( $phpcs_dir, $composer_phpcs_path, $all_standards, $standards_to_ignore, $standard, $standards_to_ignore_string ); 85 | --------------------------------------------------------------------------------