├── .gitignore ├── tests ├── Sniffs │ └── Imports │ │ ├── Fixtures │ │ ├── FileKeywordFixture.php │ │ ├── MultipleFilesFixtures │ │ │ ├── MultipleFilesFixtures2.php │ │ │ └── MultipleFilesFixtures1.php │ │ ├── ClassUsedAsNamespaceFixture.php │ │ ├── ConstantsFixure.php │ │ ├── RequireImportsMethodNameFixture.php │ │ ├── UsedTraitFixture.php │ │ ├── ClassFixtures.php │ │ ├── GlobalNamespaceFixture.php │ │ ├── RequireImportsAllowedPatternFixture.php │ │ ├── InterfaceFixture.php │ │ ├── TraitFixture.php │ │ ├── NestedFunctionsFixture.php │ │ ├── WordPressFixture.php │ │ └── RequireImportsFixture.php │ │ └── RequireImportsSniffTest.php ├── bootstrap.php └── SniffTestHelper.php ├── ImportDetection ├── ruleset.xml ├── FileSymbolRecord.php ├── Symbol.php ├── SniffHelpers.php ├── Sniffs │ └── Imports │ │ └── RequireImportsSniff.php └── WordPressSymbols.php ├── phpunit.xml ├── .circleci └── config.yml ├── composer-php7.json ├── composer.json ├── phpcs.xml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/FileKeywordFixture.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | A set of phpcs sniffs to look for unused or unimported symbols. 4 | 5 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/MultipleFilesFixtures/MultipleFilesFixtures2.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/MultipleFilesFixtures/MultipleFilesFixtures1.php: -------------------------------------------------------------------------------- 1 | energize(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/ClassFixtures.php: -------------------------------------------------------------------------------- 1 | vars[$name] = $var; 19 | } 20 | 21 | public function getHtml($template) 22 | { 23 | foreach($this->vars as $name => $value) { 24 | $template = str_replace('{' . $name . '}', $value, $template); 25 | } 26 | 27 | return $template; 28 | } 29 | } 30 | 31 | class BadClass implements BadTemplate { 32 | } 33 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/TraitFixture.php: -------------------------------------------------------------------------------- 1 | traitVar; 11 | } 12 | } 13 | 14 | // Use the trait 15 | // This will work 16 | class UseTrait 17 | { 18 | use GoodTrait; 19 | private $vars = array(); 20 | 21 | public function setVariable($name, $var) 22 | { 23 | $this->vars[$name] = $var; 24 | } 25 | 26 | public function getHtml($template) 27 | { 28 | foreach($this->vars as $name => $value) { 29 | $template = str_replace('{' . $name . '}', $value, $template); 30 | } 31 | 32 | return $template; 33 | } 34 | } 35 | 36 | class BadClass { 37 | use BadTrait; 38 | } 39 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build_php7.0: 4 | docker: 5 | - image: circleci/php:7.0-apache-stretch-browsers 6 | steps: 7 | - checkout 8 | - run: COMPOSER=composer-php7.json composer install 9 | - run: COMPOSER=composer-php7.json composer test 10 | build_php8.0: 11 | docker: 12 | - image: circleci/php:8.0 13 | steps: 14 | - checkout 15 | - run: COMPOSER=composer.json composer install 16 | - run: COMPOSER=composer.json composer test 17 | build_php7.4: 18 | docker: 19 | - image: circleci/php:7.4.6 20 | steps: 21 | - checkout 22 | - run: COMPOSER=composer.json composer install 23 | - run: COMPOSER=composer.json composer test 24 | workflows: 25 | version: 2 26 | build_php_versions: 27 | jobs: 28 | - build_php7.0 29 | - build_php7.4 30 | - build_php8.0 31 | -------------------------------------------------------------------------------- /ImportDetection/FileSymbolRecord.php: -------------------------------------------------------------------------------- 1 | importedFunctions = array_merge($this->importedFunctions, $names); 15 | } 16 | 17 | public function addImportedClasses($names) { 18 | $this->importedClasses = array_merge($this->importedClasses, $names); 19 | } 20 | 21 | public function addImportedConsts($names) { 22 | $this->importedConsts = array_merge($this->importedConsts, $names); 23 | } 24 | 25 | public function addImportedSymbolRecords($names) { 26 | $this->importedSymbolRecords = array_merge($this->importedSymbolRecords, $names); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /composer-php7.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sirbrillig/phpcs-import-detection", 3 | "description": "A set of phpcs sniffs to look for unused or unimported symbols.", 4 | "type": "phpcodesniffer-standard", 5 | "keywords" : [ "phpcs", "static analysis" ], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Payton Swick", 10 | "email": "payton@foolord.com" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "ImportDetection\\": "ImportDetection/" 16 | } 17 | }, 18 | "minimum-stability": "dev", 19 | "prefer-stable": true, 20 | "scripts": { 21 | "test": "./vendor/bin/phpunit" 22 | }, 23 | "require" : { 24 | "php": "^7.0", 25 | "squizlabs/php_codesniffer": "^3.5.8" 26 | }, 27 | "require-dev": { 28 | "sirbrillig/phpcs-variable-analysis": "^2.0.1", 29 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", 30 | "phpunit/phpunit": "^6.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sirbrillig/phpcs-import-detection", 3 | "description": "A set of phpcs sniffs to look for unused or unimported symbols.", 4 | "type": "phpcodesniffer-standard", 5 | "keywords" : [ "phpcs", "static analysis" ], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Payton Swick", 10 | "email": "payton@foolord.com" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "ImportDetection\\": "ImportDetection/" 16 | } 17 | }, 18 | "minimum-stability": "dev", 19 | "prefer-stable": true, 20 | "scripts": { 21 | "test": "./vendor/bin/phpunit" 22 | }, 23 | "require" : { 24 | "php": "^7.0 || ^8.0", 25 | "squizlabs/php_codesniffer": "^3.5.8" 26 | }, 27 | "require-dev": { 28 | "sirbrillig/phpcs-variable-analysis": "^2.0.1", 29 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", 30 | "phpunit/phpunit": "^9.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/NestedFunctionsFixture.php: -------------------------------------------------------------------------------- 1 | nestedFuncA( 'hello' ); 22 | 23 | if (true) { 24 | nestedFuncB( 'boo' ); 25 | } 26 | 27 | if (true) { 28 | function nestedFuncC() { 29 | echo 'we are deep now'; 30 | } 31 | 32 | nestedFuncC(); 33 | } 34 | } 35 | 36 | public function doANestedThing() { 37 | function nestedFuncA() { 38 | echo 'nope'; 39 | } 40 | 41 | \registerThing(new class { 42 | public function subClassFunc() { 43 | function nestedFuncC( $arg ) { 44 | echo $arg . ' world'; 45 | } 46 | 47 | nestedFuncC( 'hello' ); 48 | nestedFuncA( 'blarg' ); // warning: undefined 49 | } 50 | }); 51 | 52 | doANestedThing('thisfunc'); // warning: undefined 53 | } 54 | } 55 | 56 | nestedFuncA( 'hi' ); // warning: undefined 57 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | From https://gist.github.com/Ovsyanka/e2ab2ff76e7c0d7e75a1e4213a03ff95 5 | PSR2 with changes: 6 | * tabs instead of spaces (https://gist.github.com/gsherwood/9d22f634c57f990a7c64) 7 | * bracers on end of line instead new line 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | error 43 | 44 | 45 | 0 46 | 47 | 48 | -------------------------------------------------------------------------------- /ImportDetection/Symbol.php: -------------------------------------------------------------------------------- 1 | tokens = $tokens; 21 | $this->isUsed = false; 22 | $this->alias = $alias; 23 | } 24 | 25 | public static function getTokenWithPosition(array $token, int $stackPtr): array { 26 | $token['tokenPosition'] = $stackPtr; 27 | return $token; 28 | } 29 | 30 | public function getTokens(): array { 31 | return $this->tokens; 32 | } 33 | 34 | public function getName(): string { 35 | return $this->joinSymbolParts($this->tokens); 36 | } 37 | 38 | public function getAlias(): string { 39 | if ($this->alias) { 40 | return $this->alias->getName(); 41 | } 42 | 43 | return $this->tokens[count($this->tokens) - 1]['content']; 44 | } 45 | 46 | public function isAbsoluteNamespace(): bool { 47 | $type = $this->tokens[0]['type'] ?? ''; 48 | return $type === 'T_NS_SEPARATOR'; 49 | } 50 | 51 | public function isNamespaced(): bool { 52 | return count($this->tokens) > 1; 53 | } 54 | 55 | /** 56 | * @return string|null 57 | */ 58 | public function getTopLevelNamespace() { 59 | if (! $this->isNamespaced()) { 60 | return null; 61 | } 62 | return $this->tokens[0]['content'] ?? null; 63 | } 64 | 65 | public function getSymbolPosition(): int { 66 | return $this->tokens[0]['tokenPosition'] ?? 1; 67 | } 68 | 69 | public function getSymbolConditions(): array { 70 | return $this->tokens[0]['conditions'] ?? []; 71 | } 72 | 73 | public function markUsed() { 74 | $this->isUsed = true; 75 | } 76 | 77 | public function isUsed(): bool { 78 | return $this->isUsed; 79 | } 80 | 81 | private function joinSymbolParts(array $tokens): string { 82 | $symbolStrings = array_map(function (array $token): string { 83 | return $token['content'] ?? ''; 84 | }, $tokens); 85 | return implode('', $symbolStrings); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/WordPressFixture.php: -------------------------------------------------------------------------------- 1 | test_classes(); 52 | if (is_wp_error($output)) { // WordPressSymbol 53 | return 4; 54 | } 55 | $count = HOUR_IN_SECONDS + 10; // WordPressSymbol 56 | return $count; 57 | } 58 | 59 | public function test_classes() { 60 | if (WP_USE_THEMES) { // WordPressSymbol 61 | return new WP_Error(); // unimported 62 | } 63 | return new WP_Post(); // unimported 64 | } 65 | 66 | public function test_additional_constants() { 67 | global $wpdb; 68 | return $wpdb->get_results( 69 | "SELECT data.* 70 | FROM {$wpdb->data} bar 71 | LEFT JOIN {$wpdb->data_meta} foo 72 | ON foo.id = bar.id 73 | LIMIT 1", 74 | OBJECT_K // WordPressSymbol 75 | ); 76 | } 77 | } 78 | 79 | function foo() {} 80 | add_action('init', foo); 81 | foo(); 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD Licence Agreement 2 | ----------------------------------------------------------------------- 3 | 4 | This software is available to you under the BSD license, 5 | available in the LICENSE file accompanying this software. 6 | You may obtain a copy of the License at 7 | 8 | http://www.opensource.org/licenses/bsd-license.php 9 | 10 | ----------------------------------------------------------------------- 11 | 12 | Copyright (c) 2011, Sam Graham 13 | All rights reserved. 14 | 15 | Redistribution and use in source and binary forms, with or without 16 | modification, are permitted provided that the following conditions are 17 | met: 18 | 19 | * Redistributions of source code must retain the above copyright 20 | notice, this list of conditions and the following disclaimer. 21 | * Redistributions in binary form must reproduce the above copyright 22 | notice, this list of conditions and the following disclaimer in the 23 | documentation and/or other materials provided with the 24 | distribution. 25 | 26 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 27 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 28 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 29 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 30 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 31 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 32 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 33 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 34 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 35 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 36 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 37 | 38 | ----------------------------------------------------------------------- 39 | 40 | Portions of this sofware derived from work Copyright (c) 2010, Monotek d.o.o, 41 | released under a BSD License available at: 42 | 43 | http://www.opensource.org/licenses/bsd-license.php 44 | 45 | ----------------------------------------------------------------------- 46 | 47 | Portions of this software derived from work Copyright (c), 2006 Squiz 48 | Pty Ltd (ABN 77 084 670 600), available under the following license: 49 | 50 | BSD Licence Agreement 51 | ----------------------------------------------------------------------- 52 | 53 | This software is available to you under the BSD license, 54 | available in the LICENSE file accompanying this software. 55 | You may obtain a copy of the License at 56 | 57 | http://matrix.squiz.net/developer/tools/php_cs/licence 58 | 59 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 60 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 61 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 62 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 63 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 64 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 65 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 66 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 67 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 68 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 69 | 70 | Copyright (c), 2006 Squiz Pty Ltd (ABN 77 084 670 600). 71 | All rights reserved. 72 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/Fixtures/RequireImportsFixture.php: -------------------------------------------------------------------------------- 1 | polluter = new PollutionProducer(); 61 | // next line has an unimported class 62 | $data = new stdClass(PHP_VERSION); 63 | $store = new WeatherStore($data); 64 | try { 65 | $store->trackWeather($store->key); 66 | } catch (\MyException $err) { 67 | Alerts\makeAnAlert($err); 68 | // next line has an unimported function 69 | OldAlerts\makeAlert($err); 70 | // next line has an unimported class 71 | $oldAlert = new OldAlerts\OldAlert(); 72 | // next line has an unimported class 73 | $oldAlert = OldAlerts\OldAlert::makeRed($oldAlert); 74 | $oldAlert->warn(); 75 | $alert = new Alerts\MyAlert(); 76 | $alert = Alerts\MyAlert::markAlertImportant($alert); 77 | $alert->notifyUser(); 78 | // next line has an unimported class 79 | return new Exception('buggy'); 80 | } 81 | $name = $store->current_name_as_string; 82 | $data = new \stdClass(PHP_VERSION); 83 | return new MovementType('driving in ' . $name); 84 | } 85 | 86 | // next line has an unimported class 87 | public function convertToRobot(Car $car): Robot { 88 | // next line has an unimported class 89 | return new Robot($car); 90 | } 91 | } 92 | 93 | function startMonitor() { 94 | // next line has an unimported function 95 | OldAlerts\makeAlert(); 96 | new Car(); 97 | echo str_replace('Foo', 'Bar', 'Foobar...'); 98 | $rows = whitelisted_function(); 99 | $data = allowed_funcs_function_one(); 100 | array_map(function (array $row) use ($data) { 101 | $data && monitor_begin($row); 102 | }, $rows); 103 | } 104 | 105 | define(WHATEVER, 'some words'); 106 | 107 | function engageMagic(object $foobar) { 108 | echo $foobar; 109 | } 110 | 111 | function engageMoreMagic(mixed $foobar) { 112 | echo $foobar; 113 | } 114 | -------------------------------------------------------------------------------- /tests/SniffTestHelper.php: -------------------------------------------------------------------------------- 1 | registerSniffs($sniffFiles, [], []); 27 | $ruleset->populateTokenListeners(); 28 | if (! file_exists($fixtureFile)) { 29 | throw new \Exception('Fixture file does not exist! ' . $fixtureFile); 30 | } 31 | return new LocalFile($fixtureFile, $ruleset, $config); 32 | } 33 | 34 | public function prepareLocalFilesForSniffs($sniffFiles, $fixtureFiles): FileList { 35 | $config = new Config(); 36 | if (! is_array($fixtureFiles)) { 37 | $fixtureFiles = [$fixtureFiles]; 38 | } 39 | $config->files = $fixtureFiles; 40 | $ruleset = new Ruleset($config); 41 | if (! is_array($sniffFiles)) { 42 | $sniffFiles = [$sniffFiles]; 43 | } 44 | $ruleset->registerSniffs($sniffFiles, [], []); 45 | $ruleset->populateTokenListeners(); 46 | return new FileList($config, $ruleset); 47 | } 48 | 49 | public function processFiles($sniffFiles) { 50 | foreach ($sniffFiles as $phpcsFile) { 51 | if (! file_exists($phpcsFile->path)) { 52 | throw new \Exception('Fixture file does not exist! ' . $phpcsFile->path); 53 | } 54 | $phpcsFile->process(); 55 | } 56 | } 57 | 58 | public function getWarningMessageRecords(array $messages) { 59 | $messageRecords = []; 60 | foreach ($messages as $rowNumber => $messageRow) { 61 | foreach ($messageRow as $columnNumber => $messageArrays) { 62 | foreach ($messageArrays as $messageArray) { 63 | $messageRecord = new MessageRecord(); 64 | $messageRecord->rowNumber = $rowNumber; 65 | $messageRecord->columnNumber = $columnNumber; 66 | $messageRecord->message = $messageArray['message']; 67 | $messageRecord->source = $messageArray['source']; 68 | $messageRecords[] = $messageRecord; 69 | } 70 | } 71 | } 72 | return $messageRecords; 73 | } 74 | 75 | public function getLineNumbersFromMessages(array $messages): array { 76 | $lines = array_keys($messages); 77 | sort($lines); 78 | return $lines; 79 | } 80 | 81 | public function getNoticesFromFiles($phpcsFiles, string $noticeType): array { 82 | $noticesByFile = []; 83 | foreach ($phpcsFiles as $phpcsFile) { 84 | switch ($noticeType) { 85 | case 'warning': 86 | $noticesByFile[$phpcsFile->path] = $this->getLineNumbersFromMessages($phpcsFile->getWarnings()); 87 | break; 88 | case 'error': 89 | $noticesByFile[$phpcsFile->path] = $this->getLineNumbersFromMessages($phpcsFile->getErrors()); 90 | break; 91 | default: 92 | throw new \Exception("Invalid notice type '{$noticeType}'"); 93 | } 94 | } 95 | return $noticesByFile; 96 | } 97 | 98 | public function getWarningLineNumbersFromFile(LocalFile $phpcsFile): array { 99 | return $this->getLineNumbersFromMessages($phpcsFile->getWarnings()); 100 | } 101 | 102 | public function getErrorLineNumbersFromFile(LocalFile $phpcsFile): array { 103 | return $this->getLineNumbersFromMessages($phpcsFile->getErrors()); 104 | } 105 | 106 | public function getFixedFileContents(LocalFile $phpcsFile) { 107 | $phpcsFile->fixer->startFile($phpcsFile); 108 | $phpcsFile->fixer->fixFile(); 109 | return $phpcsFile->fixer->getContents(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImportDetection 2 | 3 | A set of [phpcs](https://github.com/squizlabs/PHP_CodeSniffer) sniffs to look for unused or unimported symbols. 4 | 5 | This adds a sniff which shows warnings if a symbol (function, constant, class) is used and is not defined directly, imported explicitly, nor has its namespace imported. 6 | 7 | > [!WARNING] 8 | > PHP 8 changed how `use` statements are tokenized, leading to [this bug](https://github.com/sirbrillig/phpcs-import-detection/issues/52) which basically breaks this sniff. This sniff also has fairly poor performance. I don't have time with my current work to continue to refactor this sniff at the moment and I wouldn't recommend it until at least that issue is fixed. If anyone wants to work on improvements, feel free to open a PR! 9 | 10 | When code is moved around, it can be problematic if classes which are used in a relative or global context get moved to a different namespace. In those cases it's better if the classes use their fully-qualified namespace, or if they are imported explicitly using `use` (in which case they can be detected by a linter like this one). These warnings should help when refactoring code to avoid bugs. 11 | 12 | It also detects imports which are _not_ being used. 13 | 14 | For example: 15 | 16 | ```php 17 | namespace Vehicles; 18 | use Registry; 19 | use function Vehicles\startCar; 20 | use Chocolate; // this will be a warning because `Chocolate` is never used 21 | class Car { 22 | public function drive() { 23 | startCar(); // this is fine because `startCar` is imported 24 | Registry\registerCar($this); // this is fine because `Registry` is imported 25 | \DrivingTracker\registerDrive($this); // this is fine because it's fully-qualified 26 | goFaster(); // this will be a warning because `goFaster` was not imported 27 | } 28 | } 29 | ``` 30 | 31 | **Note:** This sniff is a lightweight syntax checker providing a scan of the current file and it doesn't know what other files might have defined. Therefore it will warn you about implicitly imported symbols even if they're in the same namespace. It's safe to import something from the same namespace and can even improve readability, but if you'd prefer to scan multiple files, I suggest using static analysis tools like [psalm](https://psalm.dev/) or [phpstan](https://github.com/phpstan/phpstan). 32 | 33 | ## Installation 34 | 35 | To use these rules in a project which is set up using [composer](https://href.li/?https://getcomposer.org/), we recommend using the [phpcodesniffer-composer-installer library](https://href.li/?https://github.com/DealerDirect/phpcodesniffer-composer-installer) which will automatically use all installed standards in the current project with the composer type `phpcodesniffer-standard` when you run phpcs. 36 | 37 | ``` 38 | composer require --dev sirbrillig/phpcs-import-detection dealerdirect/phpcodesniffer-composer-installer 39 | ``` 40 | 41 | ## Configuration 42 | 43 | When installing sniff standards in a project, you edit a `phpcs.xml` file with the `rule` tag inside the `ruleset` tag. The `ref` attribute of that tag should specify a standard, category, sniff, or error code to enable. It’s also possible to use these tags to disable or modify certain rules. The [official annotated file](https://href.li/?https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-ruleset.xml) explains how to do this. 44 | 45 | ```xml 46 | 47 | 48 | My library. 49 | 50 | 51 | ``` 52 | 53 | ## Sniff Codes 54 | 55 | There are two sniff codes that are reported by this sniff. Both are warnings. 56 | 57 | - `ImportDetection.Imports.RequireImports.Symbol`: A symbol has been used but not imported 58 | - `ImportDetection.Imports.RequireImports.Import`: A symbol has been imported and not used 59 | 60 | In any given file, you can use phpcs comments to disable these sniffs. For example, if you have a global class called `MyGlobalClass` which you don't want to import, you could use it like this: 61 | 62 | ```php 63 | doSomething(); 67 | ``` 68 | 69 | For a whole file, you can ignore a sniff like this: 70 | 71 | ```php 72 | doSomething(); 77 | ``` 78 | 79 | For a whole project, you can use the `phpcs.xml` file to disable these sniffs or modify their priority. For example, to disable checks for unused imports, you could use a configuration like this: 80 | 81 | ```xml 82 | 83 | 84 | My library. 85 | 86 | 87 | 0 88 | 89 | 90 | ``` 91 | 92 | ## Ignoring Symbol Patterns 93 | 94 | Oftentimes there might be global symbols that you want to use without importing or using a fully-qualified path. 95 | 96 | (Remember that function call resolution first searches the current namespace, then the global namespace, but constant and class resolution only searches the current namespace! You still have to import things like `Exception` or use the fully-qualified `\Exception`.) 97 | 98 | You can ignore certain patterns by using the `ignoreUnimportedSymbols` config option. It is a regular expression. Here is an example for some common WordPress symbols: 99 | 100 | ```xml 101 | 102 | 103 | My library. 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ``` 112 | 113 | Despite the name, you can also use the `ignoreUnimportedSymbols` pattern to ignore specific unused imports. 114 | 115 | ## Ignoring Global Symbols in Global Namespace 116 | 117 | If a file is in the global namespace, then sometimes it may be unnecessary to import functions that are also global. If you'd like to ignore global symbol use in the global namespace, you can enable the `ignoreGlobalsWhenInGlobalScope` option, like this: 118 | 119 | ```xml 120 | 121 | 122 | My library. 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | ``` 131 | 132 | ## Ignoring WordPress Patterns 133 | 134 | A common use-case is to ignore all the globally available WordPress symbols. Rather than trying to come up with a pattern to ignore them all yourself, you can set the config option `ignoreWordPressSymbols` which will ignore as many of them as it knows about. For example: 135 | 136 | ```xml 137 | 138 | 139 | My library. 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | ``` 148 | 149 | ## Usage 150 | 151 | Most editors have a phpcs plugin available, but you can also run phpcs manually. To run phpcs on a file in your project, just use the command-line as follows (the `-s` causes the sniff code to be shown, which is very important for learning about an error). 152 | 153 | ``` 154 | vendor/bin/phpcs -s src/MyProject/MyClass.php 155 | ``` 156 | 157 | ## See Also 158 | 159 | - [VariableAnalysis](https://github.com/sirbrillig/phpcs-variable-analysis): Find undefined and unused variables. 160 | -------------------------------------------------------------------------------- /ImportDetection/SniffHelpers.php: -------------------------------------------------------------------------------- 1 | getTokens(); 12 | $prevPtr = $phpcsFile->findPrevious([T_OBJECT_OPERATOR], $stackPtr - 1, $stackPtr - 2); 13 | return ($prevPtr && isset($tokens[$prevPtr])); 14 | } 15 | 16 | public function isStaticReference(File $phpcsFile, $stackPtr) { 17 | $tokens = $phpcsFile->getTokens(); 18 | $prevPtr = $phpcsFile->findPrevious([T_DOUBLE_COLON], $stackPtr - 1, $stackPtr - 2); 19 | return ($prevPtr && isset($tokens[$prevPtr])); 20 | } 21 | 22 | // Borrowed this idea from 23 | // https://pear.php.net/reference/PHP_CodeSniffer-3.1.1/apidoc/PHP_CodeSniffer/LowercasePHPFunctionsSniff.html 24 | public function isBuiltInFunction(File $phpcsFile, $stackPtr) { 25 | $allFunctions = get_defined_functions(); 26 | $builtInFunctions = array_flip($allFunctions['internal']); 27 | $tokens = $phpcsFile->getTokens(); 28 | $functionName = $tokens[$stackPtr]['content']; 29 | return isset($builtInFunctions[strtolower($functionName)]); 30 | } 31 | 32 | public function isPredefinedTypehint(File $phpcsFile, $stackPtr) { 33 | $allTypehints = [ 34 | 'bool', 35 | 'string', 36 | 'mixed', 37 | 'object', 38 | 'int', 39 | 'float', 40 | 'void', 41 | 'self', 42 | 'array', 43 | 'callable', 44 | 'iterable', 45 | ]; 46 | $tokens = $phpcsFile->getTokens(); 47 | $tokenContent = $tokens[$stackPtr]['content']; 48 | return in_array($tokenContent, $allTypehints, true); 49 | } 50 | 51 | public function isPredefinedConstant(File $phpcsFile, $stackPtr) { 52 | $allConstants = get_defined_constants(); 53 | $tokens = $phpcsFile->getTokens(); 54 | $constantName = $tokens[$stackPtr]['content']; 55 | return isset($allConstants[$constantName]); 56 | } 57 | 58 | public function isPredefinedClass(File $phpcsFile, $stackPtr) { 59 | $allClasses = get_declared_classes(); 60 | $tokens = $phpcsFile->getTokens(); 61 | $className = $tokens[$stackPtr]['content']; 62 | return in_array($className, $allClasses); 63 | } 64 | 65 | public function getImportType(File $phpcsFile, $stackPtr): string { 66 | $tokens = $phpcsFile->getTokens(); 67 | if (! empty($tokens[$stackPtr]['conditions'])) { 68 | return 'trait-application'; 69 | } 70 | $nextStringPtr = $phpcsFile->findNext([T_STRING], $stackPtr + 1); 71 | if (! $nextStringPtr) { 72 | return 'unknown'; 73 | } 74 | $isClosureImport = $phpcsFile->findNext([T_OPEN_PARENTHESIS], $stackPtr + 1, $nextStringPtr); 75 | if ($isClosureImport) { 76 | return 'closure'; 77 | } 78 | $nextString = $tokens[$nextStringPtr]; 79 | if ($nextString['content'] === 'function') { 80 | return 'function'; 81 | } 82 | if ($nextString['content'] === 'const') { 83 | return 'const'; 84 | } 85 | return 'class'; 86 | } 87 | 88 | private function getImportedSymbolsFromGroupStatement(File $phpcsFile, int $stackPtr): array { 89 | $tokens = $phpcsFile->getTokens(); 90 | $endBracketPtr = $phpcsFile->findNext([T_CLOSE_USE_GROUP], $stackPtr + 1); 91 | $startBracketPtr = $phpcsFile->findNext([T_OPEN_USE_GROUP], $stackPtr + 1); 92 | if (! $endBracketPtr || ! $startBracketPtr) { 93 | throw new \Exception('Invalid group import statement starting at token ' . $stackPtr . ': ' . $tokens[$stackPtr]['content']); 94 | } 95 | 96 | // Get the namespace for the import first, so we can attach it to each Symbol 97 | $endOfImportNamespace = $phpcsFile->findPrevious([T_NS_SEPARATOR], $startBracketPtr - 1); 98 | if (! $endOfImportNamespace) { 99 | throw new \Exception('Unable to parse namespace for group import statement starting at token ' . $stackPtr . ': ' . $tokens[$stackPtr]['content']); 100 | } 101 | $importPrefixSymbol = $this->getFullSymbol($phpcsFile, $endOfImportNamespace); 102 | 103 | $collectedSymbols = []; 104 | $lastStringPtr = $startBracketPtr; 105 | 106 | while ($lastStringPtr < $endBracketPtr) { 107 | $commaPtr = $phpcsFile->findNext([T_COMMA], $lastStringPtr + 1, $endBracketPtr) ?: $endBracketPtr; 108 | $aliasPtr = $phpcsFile->findNext([T_AS], $lastStringPtr + 1, $commaPtr); 109 | $aliasSymbol = null; 110 | 111 | if ($aliasPtr) { 112 | $aliasStringPtr = $phpcsFile->findNext([T_STRING], $aliasPtr + 1, $commaPtr); 113 | $aliasSymbol = new Symbol([Symbol::getTokenWithPosition($tokens[$aliasStringPtr], $aliasStringPtr)]); 114 | } 115 | 116 | $endOfStringsPtr = $aliasPtr ?: $commaPtr; 117 | $importSuffixSymbols = []; 118 | 119 | do { 120 | $lastStringPtr = $phpcsFile->findNext([T_STRING, T_NS_SEPARATOR], $lastStringPtr + 1, $endOfStringsPtr); 121 | 122 | if ($lastStringPtr) { 123 | $importSuffixSymbols[] = Symbol::getTokenWithPosition($tokens[$lastStringPtr], $lastStringPtr); 124 | } 125 | } while ($lastStringPtr); 126 | 127 | $lastStringPtr = $commaPtr; 128 | 129 | if (! empty($importSuffixSymbols)) { 130 | $importCompleteSymbol = array_merge($importPrefixSymbol->getTokens(), $importSuffixSymbols); 131 | $collectedSymbols[] = new Symbol($importCompleteSymbol, $aliasSymbol); 132 | } 133 | } 134 | 135 | return $collectedSymbols; 136 | } 137 | 138 | public function getImportNames(File $phpcsFile, $stackPtr): array { 139 | $symbols = $this->getImportedSymbolsFromImportStatement($phpcsFile, $stackPtr); 140 | return array_map(function ($symbol) { 141 | return $symbol->getAlias(); 142 | }, $symbols); 143 | } 144 | 145 | public function getImportedSymbolsFromImportStatement(File $phpcsFile, $stackPtr): array { 146 | $tokens = $phpcsFile->getTokens(); 147 | 148 | $endOfStatementPtr = $phpcsFile->findNext([T_SEMICOLON], $stackPtr + 1); 149 | if (! $endOfStatementPtr) { 150 | return []; 151 | } 152 | 153 | // Process grouped imports differently 154 | $nextBracketPtr = $phpcsFile->findNext([T_OPEN_USE_GROUP], $stackPtr + 1, $endOfStatementPtr); 155 | if ($nextBracketPtr) { 156 | return $this->getImportedSymbolsFromGroupStatement($phpcsFile, $stackPtr); 157 | } 158 | 159 | // Get the last string before the last semicolon, comma, or closing curly bracket 160 | $endOfImportPtr = $phpcsFile->findPrevious( 161 | [T_COMMA, T_CLOSE_USE_GROUP], 162 | $stackPtr + 1, 163 | $endOfStatementPtr 164 | ); 165 | if (! $endOfImportPtr) { 166 | $endOfImportPtr = $endOfStatementPtr; 167 | } 168 | $lastStringPtr = $phpcsFile->findPrevious([T_STRING], $endOfImportPtr - 1, $stackPtr); 169 | if (! $lastStringPtr || ! isset($tokens[$lastStringPtr])) { 170 | return []; 171 | } 172 | return [$this->getFullSymbol($phpcsFile, $lastStringPtr)]; 173 | } 174 | 175 | public function getPreviousStatementPtr(File $phpcsFile, int $stackPtr): int { 176 | return $phpcsFile->findPrevious([T_SEMICOLON, T_CLOSE_CURLY_BRACKET], $stackPtr - 1) ?: 1; 177 | } 178 | 179 | public function isWithinDeclareCall(File $phpcsFile, $stackPtr): bool { 180 | $previousStatementPtr = $this->getPreviousStatementPtr($phpcsFile, $stackPtr); 181 | return !! $phpcsFile->findPrevious([T_DECLARE], $stackPtr - 1, $previousStatementPtr); 182 | } 183 | 184 | public function isWithinDefineCall(File $phpcsFile, $stackPtr): bool { 185 | $previousStatementPtr = $this->getPreviousStatementPtr($phpcsFile, $stackPtr); 186 | return !! $phpcsFile->findPrevious([T_STRING], $stackPtr - 1, $previousStatementPtr, false, 'define'); 187 | } 188 | 189 | public function isWithinNamespaceStatement(File $phpcsFile, $stackPtr): bool { 190 | $previousStatementPtr = $this->getPreviousStatementPtr($phpcsFile, $stackPtr); 191 | return !! $phpcsFile->findPrevious([T_NAMESPACE], $stackPtr - 1, $previousStatementPtr); 192 | } 193 | 194 | public function isWithinImportStatement(File $phpcsFile, $stackPtr): bool { 195 | $tokens = $phpcsFile->getTokens(); 196 | if (! empty($tokens[$stackPtr]['conditions'])) { 197 | return false; 198 | } 199 | $isClosureImport = $phpcsFile->findNext([T_OPEN_PARENTHESIS], $stackPtr + 1, $stackPtr + 5); 200 | if ($isClosureImport) { 201 | return false; 202 | } 203 | $previousStatementPtr = $this->getPreviousStatementPtr($phpcsFile, $stackPtr); 204 | return !! $phpcsFile->findPrevious([T_USE], $stackPtr - 1, $previousStatementPtr); 205 | } 206 | 207 | /** 208 | * @return array|null 209 | */ 210 | public function getPreviousNonWhitespaceToken(File $phpcsFile, int $stackPtr) { 211 | $tokens = $phpcsFile->getTokens(); 212 | $prevNonWhitespacePtr = $phpcsFile->findPrevious(T_WHITESPACE, $stackPtr - 1, $stackPtr - 3, true, null, false); 213 | if (! $prevNonWhitespacePtr || ! isset($tokens[$prevNonWhitespacePtr])) { 214 | return null; 215 | } 216 | return $tokens[$prevNonWhitespacePtr]; 217 | } 218 | 219 | public function getConstantName(File $phpcsFile, $stackPtr) { 220 | $tokens = $phpcsFile->getTokens(); 221 | $nextStringPtr = $phpcsFile->findNext([T_STRING], $stackPtr + 1, $stackPtr + 3); 222 | if (! $nextStringPtr || ! isset($tokens[$nextStringPtr])) { 223 | return null; 224 | } 225 | return $tokens[$nextStringPtr]['content']; 226 | } 227 | 228 | public function isSymbolADefinition(File $phpcsFile, Symbol $symbol): bool { 229 | // if the previous non-whitespace token is const, function, class, or trait, it is a definition 230 | // Note: this does not handle use statements, for that use isWithinImportStatement 231 | $stackPtr = $symbol->getSymbolPosition(); 232 | $prevToken = $this->getPreviousNonWhitespaceToken($phpcsFile, $stackPtr) ?? []; 233 | return $this->isTokenADefinition($prevToken) || $this->isWithinDefineCall($phpcsFile, $stackPtr) || $this->isWithinDeclareCall($phpcsFile, $stackPtr); 234 | } 235 | 236 | public function isTokenADefinition(array $token): bool { 237 | // Note: this does not handle use or define 238 | $type = $token['type'] ?? ''; 239 | $definitionTypes = ['T_CLASS', 'T_FUNCTION', 'T_CONST', 'T_INTERFACE', 'T_TRAIT']; 240 | return in_array($type, $definitionTypes, true); 241 | } 242 | 243 | public function getFullSymbol($phpcsFile, $stackPtr): Symbol { 244 | $originalPtr = $stackPtr; 245 | $tokens = $phpcsFile->getTokens(); 246 | // go backwards and forward and collect all the tokens until we encounter 247 | // anything other than a backslash or a string 248 | $currentToken = Symbol::getTokenWithPosition($tokens[$stackPtr], $stackPtr); 249 | $fullSymbolParts = []; 250 | while ($this->isTokenASymbolPart($currentToken)) { 251 | $fullSymbolParts[] = $currentToken; 252 | $stackPtr--; 253 | $currentToken = Symbol::getTokenWithPosition($tokens[$stackPtr] ?? [], $stackPtr); 254 | } 255 | $fullSymbolParts = array_reverse($fullSymbolParts); 256 | $stackPtr = $originalPtr + 1; 257 | $currentToken = Symbol::getTokenWithPosition($tokens[$stackPtr] ?? [], $stackPtr); 258 | while ($this->isTokenASymbolPart($currentToken)) { 259 | $fullSymbolParts[] = $currentToken; 260 | $stackPtr++; 261 | $currentToken = Symbol::getTokenWithPosition($tokens[$stackPtr] ?? [], $stackPtr); 262 | } 263 | return new Symbol($fullSymbolParts); 264 | } 265 | 266 | public function isTokenASymbolPart(array $token): bool { 267 | $type = $token['type'] ?? ''; 268 | $symbolParts = ['T_NS_SEPARATOR', 'T_STRING', 'T_RETURN_TYPE']; 269 | return in_array($type, $symbolParts, true); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /tests/Sniffs/Imports/RequireImportsSniffTest.php: -------------------------------------------------------------------------------- 1 | prepareLocalFileForSniffs($sniffFile, $fixtureFile); 14 | $phpcsFile->ruleset->setSniffProperty( 15 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 16 | 'ignoreUnimportedSymbols', 17 | '/^(something_to_ignore|whitelisted_function|allowed_funcs_\w+)$/' 18 | ); 19 | $phpcsFile->process(); 20 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 21 | $expectedLines = [ 22 | 10, 23 | 15, 24 | 19, 25 | 27, 26 | 30, 27 | 34, 28 | 37, 29 | 39, 30 | 47, 31 | 52, 32 | 57, 33 | 62, 34 | 69, 35 | 71, 36 | 73, 37 | 79, 38 | 87, 39 | 89, 40 | 95, 41 | ]; 42 | $this->assertEquals($expectedLines, $lines); 43 | $this->assertSame(count($expectedLines), $phpcsFile->getWarningCount()); 44 | } 45 | 46 | public function testRequireImportsSniffFindsUnimportedFunctionsWithNoConfig() { 47 | $fixtureFile = __DIR__ . '/Fixtures/RequireImportsAllowedPatternFixture.php'; 48 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 49 | $helper = new SniffTestHelper(); 50 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 51 | $phpcsFile->ruleset->setSniffProperty( 52 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 53 | 'ignoreUnimportedSymbols', 54 | '' 55 | ); 56 | $phpcsFile->process(); 57 | $messages = $helper->getWarningMessageRecords($phpcsFile->getWarnings()); 58 | $messages = array_values(array_filter($messages, function ($message) { 59 | return $message->source === 'ImportDetection.Imports.RequireImports.Symbol'; 60 | })); 61 | $lines = array_map(function ($message) { 62 | return $message->rowNumber; 63 | }, $messages); 64 | $expectedLines = [ 65 | 15, 66 | 16, 67 | ]; 68 | $this->assertEquals($expectedLines, $lines); 69 | } 70 | 71 | public function testRequireImportsSniffFindsUnusedImportsWithNoConfig() { 72 | $fixtureFile = __DIR__ . '/Fixtures/RequireImportsAllowedPatternFixture.php'; 73 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 74 | $helper = new SniffTestHelper(); 75 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 76 | $phpcsFile->ruleset->setSniffProperty( 77 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 78 | 'ignoreUnimportedSymbols', 79 | '' 80 | ); 81 | $phpcsFile->process(); 82 | $messages = $helper->getWarningMessageRecords($phpcsFile->getWarnings()); 83 | $messages = array_values(array_filter($messages, function ($message) { 84 | return $message->source === 'ImportDetection.Imports.RequireImports.Import'; 85 | })); 86 | $lines = array_map(function ($message) { 87 | return $message->rowNumber; 88 | }, $messages); 89 | $expectedLines = [ 90 | 8, 91 | 9, 92 | ]; 93 | $this->assertEquals($expectedLines, $lines); 94 | } 95 | 96 | public function testRequireImportsSniffIgnoresWhitelistedUnimportedSymbols() { 97 | $fixtureFile = __DIR__ . '/Fixtures/RequireImportsAllowedPatternFixture.php'; 98 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 99 | $helper = new SniffTestHelper(); 100 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 101 | $phpcsFile->ruleset->setSniffProperty( 102 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 103 | 'ignoreUnimportedSymbols', 104 | '/^(something_to_ignore|whitelisted_function|allowed_funcs_\w+|another_[a-z_]+)$/' 105 | ); 106 | $phpcsFile->process(); 107 | $messages = $helper->getWarningMessageRecords($phpcsFile->getWarnings()); 108 | $messages = array_values(array_filter($messages, function ($message) { 109 | return $message->source === 'ImportDetection.Imports.RequireImports.Symbol'; 110 | })); 111 | $lines = array_map(function ($message) { 112 | return $message->rowNumber; 113 | }, $messages); 114 | $expectedLines = [ 115 | 16, 116 | ]; 117 | $this->assertEquals($expectedLines, $lines); 118 | } 119 | 120 | public function testRequireImportsSniffIgnoresWhitelistedUnusedImports() { 121 | $fixtureFile = __DIR__ . '/Fixtures/RequireImportsAllowedPatternFixture.php'; 122 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 123 | $helper = new SniffTestHelper(); 124 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 125 | $phpcsFile->ruleset->setSniffProperty( 126 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 127 | 'ignoreUnimportedSymbols', 128 | '/\\\unused_function|\\\four_function/' 129 | ); 130 | $phpcsFile->process(); 131 | $messages = $helper->getWarningMessageRecords($phpcsFile->getWarnings()); 132 | $messages = array_values(array_filter($messages, function ($message) { 133 | return $message->source === 'ImportDetection.Imports.RequireImports.Import'; 134 | })); 135 | $lines = array_map(function ($message) { 136 | return $message->rowNumber; 137 | }, $messages); 138 | $expectedLines = []; 139 | $this->assertEquals($expectedLines, $lines); 140 | } 141 | 142 | public function testRequireImportsSniffFindsWordPressPatternsIfNotSet() { 143 | $fixtureFile = __DIR__ . '/Fixtures/WordPressFixture.php'; 144 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 145 | $helper = new SniffTestHelper(); 146 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 147 | $phpcsFile->ruleset->setSniffProperty( 148 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 149 | 'ignoreWordPressSymbols', 150 | 'false' 151 | ); 152 | $phpcsFile->process(); 153 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 154 | $expectedLines = [ 155 | 23, 156 | 32, 157 | 33, 158 | 34, 159 | 35, 160 | 36, 161 | 37, 162 | 38, 163 | 42, 164 | 45, 165 | 48, 166 | 52, 167 | 55, 168 | 60, 169 | 61, 170 | 63, 171 | 74, 172 | 80, 173 | ]; 174 | $this->assertEquals($expectedLines, $lines); 175 | } 176 | 177 | public function testRequireImportsSniffIgnoresWordPressPatternsIfSet() { 178 | $fixtureFile = __DIR__ . '/Fixtures/WordPressFixture.php'; 179 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 180 | $helper = new SniffTestHelper(); 181 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 182 | $phpcsFile->ruleset->setSniffProperty( 183 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 184 | 'ignoreWordPressSymbols', 185 | 'true' 186 | ); 187 | $phpcsFile->process(); 188 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 189 | $expectedLines = [ 38, 61, 63, 80 ]; 190 | $this->assertEquals($expectedLines, $lines); 191 | } 192 | 193 | public function testRequireImportsSniffDoesNotCountMethodNames() { 194 | $fixtureFile = __DIR__ . '/Fixtures/RequireImportsMethodNameFixture.php'; 195 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 196 | $helper = new SniffTestHelper(); 197 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 198 | $phpcsFile->process(); 199 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 200 | $expectedLines = [ 11 ]; 201 | $this->assertEquals($expectedLines, $lines); 202 | } 203 | 204 | public function testRequireImportsSniffCountsTraitUseAsUsage() { 205 | $fixtureFile = __DIR__ . '/Fixtures/UsedTraitFixture.php'; 206 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 207 | $helper = new SniffTestHelper(); 208 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 209 | $phpcsFile->process(); 210 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 211 | $expectedLines = [10]; 212 | $this->assertEquals($expectedLines, $lines); 213 | } 214 | 215 | public function testRequireImportsSniffWorksWithInterfaces() { 216 | $fixtureFile = __DIR__ . '/Fixtures/InterfaceFixture.php'; 217 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 218 | $helper = new SniffTestHelper(); 219 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 220 | $phpcsFile->process(); 221 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 222 | $expectedLines = [31]; 223 | $this->assertEquals($expectedLines, $lines); 224 | } 225 | 226 | public function testRequireImportsSniffWorksWithTraits() { 227 | $fixtureFile = __DIR__ . '/Fixtures/TraitFixture.php'; 228 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 229 | $helper = new SniffTestHelper(); 230 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 231 | $phpcsFile->process(); 232 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 233 | $expectedLines = [37]; 234 | $this->assertEquals($expectedLines, $lines); 235 | } 236 | 237 | public function testRequireImportsSniffFindsGlobalSymbolsIfNoConfig() { 238 | $fixtureFile = __DIR__ . '/Fixtures/GlobalNamespaceFixture.php'; 239 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 240 | $helper = new SniffTestHelper(); 241 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 242 | $phpcsFile->process(); 243 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 244 | $expectedLines = [ 245 | 6, 246 | 7, 247 | 13, 248 | 14, 249 | 19, 250 | ]; 251 | $this->assertEquals($expectedLines, $lines); 252 | } 253 | 254 | public function testRequireImportsSniffIgnoresGlobalSymbolsIfConfigured() { 255 | $fixtureFile = __DIR__ . '/Fixtures/GlobalNamespaceFixture.php'; 256 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 257 | $helper = new SniffTestHelper(); 258 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 259 | $phpcsFile->ruleset->setSniffProperty( 260 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 261 | 'ignoreGlobalsWhenInGlobalScope', 262 | 'true' 263 | ); 264 | $phpcsFile->process(); 265 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 266 | $expectedLines = [ 267 | 6, 268 | 7, 269 | 19, 270 | ]; 271 | $this->assertEquals($expectedLines, $lines); 272 | } 273 | 274 | public function testRequireImportsSniffFindsGlobalSymbolsInNamespaceIfConfigured() { 275 | $fixtureFile = __DIR__ . '/Fixtures/RequireImportsAllowedPatternFixture.php'; 276 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 277 | $helper = new SniffTestHelper(); 278 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 279 | $phpcsFile->ruleset->setSniffProperty( 280 | 'ImportDetection\Sniffs\Imports\RequireImportsSniff', 281 | 'ignoreGlobalsWhenInGlobalScope', 282 | 'true' 283 | ); 284 | $phpcsFile->process(); 285 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 286 | $expectedLines = [ 287 | 8, 288 | 9, 289 | 15, 290 | 16, 291 | ]; 292 | $this->assertEquals($expectedLines, $lines); 293 | } 294 | 295 | public function testRequireImportsDoesNotBleedToMultipleFiles() { 296 | $fixtureFile = __DIR__ . '/Fixtures/MultipleFilesFixtures'; 297 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 298 | $helper = new SniffTestHelper(); 299 | $phpcsFiles = $helper->prepareLocalFilesForSniffs($sniffFile, $fixtureFile); 300 | // Unclear why this works, but if I run this twice the first fixture file 301 | // gets all its warnings cleared (and the other fixture file has the same 302 | // warning twice). 303 | $helper->processFiles($phpcsFiles); 304 | $helper->processFiles($phpcsFiles); 305 | $linesByFile = $helper->getNoticesFromFiles($phpcsFiles, 'warning'); 306 | $expectedLines = [ 307 | // The runner runs 'MultipleFilesFixtures2' first. 308 | __DIR__ . '/Fixtures/MultipleFilesFixtures/MultipleFilesFixtures2.php' => [], 309 | __DIR__ . '/Fixtures/MultipleFilesFixtures/MultipleFilesFixtures1.php' => [5], 310 | ]; 311 | $this->assertEquals($expectedLines, $linesByFile); 312 | } 313 | 314 | public function testRequireImportsFindsUnimportedNamespaceIdenticalToClass() { 315 | $fixtureFile = __DIR__ . '/Fixtures/ClassUsedAsNamespaceFixture.php'; 316 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 317 | $helper = new SniffTestHelper(); 318 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 319 | $phpcsFile->process(); 320 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 321 | $expectedLines = [ 322 | 7, 323 | ]; 324 | $this->assertEquals($expectedLines, $lines); 325 | } 326 | 327 | public function testRequireImportsNoticesUnusedClasses() { 328 | $fixtureFile = __DIR__ . '/Fixtures/ClassFixtures.php'; 329 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 330 | $helper = new SniffTestHelper(); 331 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 332 | $phpcsFile->process(); 333 | 334 | $warnings = $phpcsFile->getWarnings(); 335 | $messages = $helper->getWarningMessageRecords($warnings); 336 | $messages = array_values(array_filter($messages, function ($message) { 337 | return $message->source === 'ImportDetection.Imports.RequireImports.Import'; 338 | })); 339 | $lines = array_map(function ($message) { 340 | return $message->rowNumber; 341 | }, $messages); 342 | $expectedLines = [ 343 | 2, 344 | 7, 345 | 7, 346 | ]; 347 | $this->assertEquals($expectedLines, $lines); 348 | $this->assertCount(3, $messages); 349 | $this->assertEquals('Found unused symbol \'NamespaceName\C\D\'.', $messages[0]->message); 350 | $this->assertEquals('Found unused symbol \'NamespaceName\B\'.', $messages[1]->message); 351 | $this->assertEquals('Found unused symbol \'NamespaceName\I\J\'.', $messages[2]->message); 352 | } 353 | 354 | public function testRequireImportsNoticesUnusedConstants() { 355 | $fixtureFile = __DIR__ . '/Fixtures/ConstantsFixure.php'; 356 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 357 | $helper = new SniffTestHelper(); 358 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 359 | $phpcsFile->process(); 360 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 361 | $expectedLines = [ 362 | 3, 363 | 12, 364 | ]; 365 | $this->assertEquals($expectedLines, $lines); 366 | } 367 | 368 | public function testRequireImportsSniffTreatsFileImportAsUsedWhenUsed() { 369 | $fixtureFile = __DIR__ . '/Fixtures/FileKeywordFixture.php'; 370 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 371 | $helper = new SniffTestHelper(); 372 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 373 | $phpcsFile->process(); 374 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 375 | $expectedLines = []; 376 | $this->assertEquals($expectedLines, $lines); 377 | } 378 | 379 | public function testRequireImportsNoticesNestedFunctions() { 380 | $fixtureFile = __DIR__ . '/Fixtures/NestedFunctionsFixture.php'; 381 | $sniffFile = __DIR__ . '/../../../ImportDetection/Sniffs/Imports/RequireImportsSniff.php'; 382 | $helper = new SniffTestHelper(); 383 | $phpcsFile = $helper->prepareLocalFileForSniffs($sniffFile, $fixtureFile); 384 | $phpcsFile->process(); 385 | $lines = $helper->getWarningLineNumbersFromFile($phpcsFile); 386 | $expectedLines = [ 387 | 9, 388 | 10, 389 | 20, 390 | 48, 391 | 52, 392 | 56, 393 | ]; 394 | $this->assertEquals($expectedLines, $lines); 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /ImportDetection/Sniffs/Imports/RequireImportsSniff.php: -------------------------------------------------------------------------------- 1 | getTokens(); 26 | $token = $tokens[$stackPtr]; 27 | // Keep one set of symbol records per file 28 | $this->symbolRecordsByFile[$phpcsFile->path] = $this->symbolRecordsByFile[$phpcsFile->path] ?? new FileSymbolRecord; 29 | if ($token['type'] === 'T_NAMESPACE') { 30 | return $this->processNamespace($phpcsFile, $stackPtr); 31 | } 32 | if ($token['type'] === 'T_WHITESPACE') { 33 | $this->debug('found whitespace'); 34 | return $this->processEndOfFile($phpcsFile, $stackPtr); 35 | } 36 | if ($token['type'] === 'T_USE') { 37 | $this->debug('found import'); 38 | if (in_array($helper->getImportType($phpcsFile, $stackPtr), ['function', 'const', 'class'])) { 39 | $this->recordImportedSymbols($phpcsFile, $stackPtr); 40 | } 41 | return $this->processUse($phpcsFile, $stackPtr); 42 | } 43 | $symbol = $helper->getFullSymbol($phpcsFile, $stackPtr); 44 | // If the symbol has been seen before (if this is a duplicate), ignore it 45 | if (in_array($symbol, $this->symbolRecordsByFile[$phpcsFile->path]->seenSymbols)) { 46 | $this->debug('found duplicate symbol: ' . $symbol->getName()); 47 | return; 48 | } 49 | $this->symbolRecordsByFile[$phpcsFile->path]->seenSymbols[] = $symbol; 50 | // If the symbol is in the ignore list, ignore it 51 | if ($this->isSymbolIgnored($symbol)) { 52 | $this->debug('found ignored symbol: ' . $symbol->getName()); 53 | $this->markSymbolUsed($phpcsFile, $symbol); 54 | return; 55 | } 56 | // If the symbol is a fully-qualified namespace, ignore it 57 | if ($symbol->isAbsoluteNamespace()) { 58 | $this->debug('found absolute namespaced symbol: ' . $symbol->getName()); 59 | return; 60 | } 61 | // If this symbol is a definition, ignore it 62 | if ($helper->isSymbolADefinition($phpcsFile, $symbol)) { 63 | $this->debug('found definition symbol: ' . $symbol->getName()); 64 | return; 65 | } 66 | // If this symbol is a static reference or an object reference, ignore it 67 | if ($helper->isStaticReference($phpcsFile, $stackPtr) || $helper->isObjectReference($phpcsFile, $stackPtr)) { 68 | $this->debug('found static symbol: ' . $symbol->getName()); 69 | return; 70 | } 71 | // If this symbol is a namespace definition, ignore it 72 | if ($helper->isWithinNamespaceStatement($phpcsFile, $symbol->getSymbolPosition())) { 73 | $this->debug('found namespace definition symbol: ' . $symbol->getName()); 74 | return; 75 | } 76 | // If this symbol is an import, ignore it 77 | if ($helper->isWithinImportStatement($phpcsFile, $symbol->getSymbolPosition())) { 78 | $this->debug('found symbol inside an import: ' . $symbol->getName()); 79 | return; 80 | } 81 | // If the symbol's namespace is imported or defined, ignore it 82 | // If the symbol has no namespace and is itself is imported or defined, ignore it 83 | if ($this->isSymbolDefined($phpcsFile, $symbol)) { 84 | $this->debug('found defined symbol: ' . $symbol->getName()); 85 | $this->markSymbolUsed($phpcsFile, $symbol); 86 | return; 87 | } 88 | // If the symbol is predefined, ignore it 89 | if ($helper->isPredefinedConstant($phpcsFile, $stackPtr) || $helper->isBuiltInFunction($phpcsFile, $stackPtr)) { 90 | $this->debug('found predefined symbol: ' . $symbol->getName()); 91 | return; 92 | } 93 | // If this symbol is a predefined typehint, ignore it 94 | if ($helper->isPredefinedTypehint($phpcsFile, $stackPtr)) { 95 | $this->debug('found typehint symbol: ' . $symbol->getName()); 96 | return; 97 | } 98 | // If the symbol is global, we are in the global namespace, and 99 | // configured to ignore global symbols in the global namespace, 100 | // ignore it 101 | if ($this->ignoreGlobalsWhenInGlobalScope && ! $symbol->isNamespaced() && ! $this->symbolRecordsByFile[$phpcsFile->path]->activeNamespace) { 102 | $this->debug('found global symbol in global namespace: ' . $symbol->getName()); 103 | return; 104 | } 105 | $this->debug('found unimported symbol: ' . $symbol->getName()); 106 | $error = "Found unimported symbol '{$symbol->getName()}'."; 107 | $phpcsFile->addWarning($error, $stackPtr, 'Symbol'); 108 | } 109 | 110 | private function debug(string $message) { 111 | if (! defined('PHP_CODESNIFFER_VERBOSITY')) { 112 | return; 113 | } 114 | if (PHP_CODESNIFFER_VERBOSITY > 3) { 115 | echo PHP_EOL . "RequireImportsSniff: DEBUG: $message" . PHP_EOL; 116 | } 117 | } 118 | 119 | private function isSymbolIgnored(Symbol $symbol): bool { 120 | $ignorePattern = $this->getIgnoredSymbolPattern(); 121 | $doesSymbolMatchIgnorePattern = $this->doesSymbolMatchPattern($symbol, $ignorePattern); 122 | if ($doesSymbolMatchIgnorePattern) { 123 | return true; 124 | } 125 | 126 | $wordPressPatterns = $this->getIgnoredWordPressSymbolPatterns(); 127 | $matchingWordPressPatterns = array_values(array_filter($wordPressPatterns, function (string $pattern) use ($symbol): bool { 128 | return $this->doesSymbolMatchPattern($symbol, "/{$pattern}/"); 129 | })); 130 | return count($matchingWordPressPatterns) > 0; 131 | } 132 | 133 | private function doesSymbolMatchPattern(Symbol $symbol, string $pattern): bool { 134 | $symbolName = $symbol->getName(); 135 | if (empty($pattern)) { 136 | return false; 137 | } 138 | try { 139 | return (1 === preg_match($pattern, $symbolName)); 140 | } catch (\Exception $err) { 141 | throw new \Exception("Invalid ignore pattern found: '{$pattern}'"); 142 | } 143 | } 144 | 145 | private function getIgnoredWordPressSymbolPatterns() { 146 | return empty($this->ignoreWordPressSymbols) ? [] : WordPressSymbols::getWordPressSymbolPatterns(); 147 | } 148 | 149 | private function getIgnoredSymbolPattern() { 150 | return $this->ignoreUnimportedSymbols ?? ''; 151 | } 152 | 153 | private function isSymbolDefined(File $phpcsFile, Symbol $symbol): bool { 154 | $namespace = $symbol->getTopLevelNamespace(); 155 | // If the symbol's namespace is imported, ignore it 156 | if ($namespace) { 157 | return $this->isNamespaceImported($phpcsFile, $namespace); 158 | } 159 | // If the symbol has no namespace and is itself is imported or defined, ignore it 160 | return $this->isNamespaceImportedOrDefined($phpcsFile, $symbol); 161 | } 162 | 163 | private function isNamespaceImported(File $phpcsFile, string $namespace): bool { 164 | return ( 165 | $this->isClassImported($phpcsFile, $namespace) 166 | || $this->isFunctionImported($phpcsFile, $namespace) 167 | || $this->isConstImported($phpcsFile, $namespace) 168 | ); 169 | } 170 | 171 | private function isSymbolAFunctionCall(File $phpcsFile, Symbol $symbol): bool { 172 | $tokens = $phpcsFile->getTokens(); 173 | $stackPtr = $symbol->getSymbolPosition(); 174 | if (isset($tokens[$stackPtr + 1]) && $tokens[$stackPtr + 1]['type'] === 'T_OPEN_PARENTHESIS') { 175 | return true; 176 | } 177 | return false; 178 | } 179 | 180 | private function isNamespaceImportedOrDefined(File $phpcsFile, Symbol $symbol): bool { 181 | $namespace = $symbol->getName(); 182 | $conditions = $symbol->getSymbolConditions(); 183 | return ( 184 | $this->isClassImported($phpcsFile, $namespace) 185 | || $this->isClassDefined($phpcsFile, $namespace) 186 | || $this->isFunctionImported($phpcsFile, $namespace) 187 | || $this->isFunctionDefined($phpcsFile, $symbol, $namespace, $conditions) 188 | || $this->isConstImported($phpcsFile, $namespace) 189 | || $this->isConstDefined($phpcsFile, $namespace) 190 | ); 191 | } 192 | 193 | private function processUse(File $phpcsFile, $stackPtr) { 194 | $helper = new SniffHelpers(); 195 | $importType = $helper->getImportType($phpcsFile, $stackPtr); 196 | switch ($importType) { 197 | case 'function': 198 | return $this->saveFunctionImport($phpcsFile, $stackPtr); 199 | case 'const': 200 | return $this->saveConstImport($phpcsFile, $stackPtr); 201 | case 'class': 202 | return $this->saveClassImport($phpcsFile, $stackPtr); 203 | } 204 | } 205 | 206 | private function recordImportedSymbols(File $phpcsFile, int $stackPtr) { 207 | $helper = new SniffHelpers(); 208 | $symbols = $helper->getImportedSymbolsFromImportStatement($phpcsFile, $stackPtr); 209 | $this->debug('recording imported symbols: ' . implode(', ', array_map(function (Symbol $symbol): string { 210 | return $symbol->getName(); 211 | }, $symbols))); 212 | $symbols = array_map(function ($symbol) { 213 | if ($this->isSymbolIgnored($symbol)) { 214 | $this->debug('found ignored imported symbol: ' . $symbol->getName()); 215 | $symbol->markUsed(); 216 | } 217 | return $symbol; 218 | }, $symbols); 219 | $this->symbolRecordsByFile[$phpcsFile->path]->addImportedSymbolRecords($symbols); 220 | } 221 | 222 | private function saveFunctionImport(File $phpcsFile, $stackPtr) { 223 | $helper = new SniffHelpers(); 224 | $importNames = $helper->getImportNames($phpcsFile, $stackPtr); 225 | $this->symbolRecordsByFile[$phpcsFile->path]->addImportedFunctions($importNames); 226 | } 227 | 228 | private function saveConstImport(File $phpcsFile, $stackPtr) { 229 | $helper = new SniffHelpers(); 230 | $importNames = $helper->getImportNames($phpcsFile, $stackPtr); 231 | $this->symbolRecordsByFile[$phpcsFile->path]->addImportedConsts($importNames); 232 | } 233 | 234 | private function saveClassImport(File $phpcsFile, $stackPtr) { 235 | $helper = new SniffHelpers(); 236 | $importNames = $helper->getImportNames($phpcsFile, $stackPtr); 237 | $this->symbolRecordsByFile[$phpcsFile->path]->addImportedClasses($importNames); 238 | } 239 | 240 | private function isFunctionImported(File $phpcsFile, string $functionName): bool { 241 | return in_array($functionName, $this->symbolRecordsByFile[$phpcsFile->path]->importedFunctions); 242 | } 243 | 244 | private function isConstImported(File $phpcsFile, string $constName): bool { 245 | return in_array($constName, $this->symbolRecordsByFile[$phpcsFile->path]->importedConsts); 246 | } 247 | 248 | private function isClassImported(File $phpcsFile, string $name): bool { 249 | return in_array($name, $this->symbolRecordsByFile[$phpcsFile->path]->importedClasses); 250 | } 251 | 252 | private function isClassDefined(File $phpcsFile, string $className): bool { 253 | $classPtr = $phpcsFile->findNext([T_CLASS, T_INTERFACE, T_TRAIT], 0); 254 | while ($classPtr) { 255 | $thisClassName = $phpcsFile->getDeclarationName($classPtr); 256 | if ($className === $thisClassName) { 257 | return true; 258 | } 259 | $classPtr = $phpcsFile->findNext([T_CLASS, T_INTERFACE, T_TRAIT], $classPtr + 1); 260 | } 261 | return false; 262 | } 263 | 264 | private function isFunctionDefined(File $phpcsFile, Symbol $symbol, string $functionName, array $conditions): bool { 265 | $tokens = $phpcsFile->getTokens(); 266 | 267 | if (! $this->isSymbolAFunctionCall($phpcsFile, $symbol)) { 268 | return false; 269 | } 270 | 271 | $scopesToEnter = array_filter(array_keys($conditions), function ($conditionPtr) use ($conditions) { 272 | return $conditions[$conditionPtr] === T_FUNCTION; 273 | }); 274 | $this->debug("looking for definition for function {$functionName}"); 275 | $this->debug("my conditions are " . json_encode($conditions)); 276 | $this->debug("scopes to enter " . implode(',', $scopesToEnter)); 277 | 278 | // Only look at the inner-most scope and global scope 279 | $scopesToEnter = [end($scopesToEnter), 0]; 280 | 281 | foreach ($scopesToEnter as $scopeStart) { 282 | $functionToken = $tokens[$scopeStart]; 283 | $scopeEnd = $functionToken['scope_closer'] ?? null; 284 | 285 | // Within each function scope, find all the function definitions and 286 | // compare their names to the name we are looking for. 287 | $functionDefinitionsInScope = $this->findAllFunctionDefinitionsInScope($phpcsFile, $scopeStart, $scopeEnd); 288 | 289 | foreach ($functionDefinitionsInScope as $thisFunctionName) { 290 | $this->debug("is this function the one we want? " . $thisFunctionName); 291 | if ($functionName === $thisFunctionName) { 292 | $this->debug("yes indeed"); 293 | return true; 294 | } 295 | } 296 | } 297 | return false; 298 | } 299 | 300 | /** 301 | * Return an array of function names defined in a scope 302 | */ 303 | private function findAllFunctionDefinitionsInScope(File $phpcsFile, int $scopeStart, int $scopeEnd = null): array { 304 | $this->debug("looking for functions defined between {$scopeStart} and {$scopeEnd}"); 305 | $tokens = $phpcsFile->getTokens(); 306 | $functionNames = []; 307 | 308 | $tokensToInvestigate = [T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE]; 309 | 310 | // Skip the function we are in, but not the global scope 311 | $functionToken = $tokens[$scopeStart]; 312 | $scopeOffset = $functionToken['type'] === 'T_FUNCTION' ? 2 : 0; 313 | $functionPtr = $phpcsFile->findNext($tokensToInvestigate, $scopeStart + $scopeOffset, $scopeEnd); 314 | 315 | while ($functionPtr) { 316 | $functionName = $phpcsFile->getDeclarationName($functionPtr); 317 | $functionToken = $tokens[$functionPtr]; 318 | $thisFunctionScopeEnd = $functionToken['scope_closer'] ?? 0; 319 | 320 | // Skip things other than IF that have their own scope 321 | if ($functionToken['type'] !== 'T_FUNCTION') { 322 | if (! $thisFunctionScopeEnd) { 323 | $this->debug("function at {$functionPtr} has no end:" . $functionName); 324 | break; 325 | } 326 | $functionPtr = $phpcsFile->findNext($tokensToInvestigate, $thisFunctionScopeEnd, $scopeEnd); 327 | continue; 328 | } 329 | 330 | $this->debug("found function at {$functionPtr}:" . $functionName); 331 | $functionNames[] = $functionName; 332 | $functionPtr = $phpcsFile->findNext($tokensToInvestigate, $thisFunctionScopeEnd, $scopeEnd); 333 | } 334 | return $functionNames; 335 | } 336 | 337 | private function isConstDefined(File $phpcsFile, string $functionName): bool { 338 | $helper = new SniffHelpers(); 339 | $functionPtr = $phpcsFile->findNext([T_CONST], 0); 340 | while ($functionPtr) { 341 | $thisFunctionName = $helper->getConstantName($phpcsFile, $functionPtr); 342 | if ($functionName === $thisFunctionName) { 343 | return true; 344 | } 345 | $functionPtr = $phpcsFile->findNext([T_CONST], $functionPtr + 1); 346 | } 347 | return false; 348 | } 349 | 350 | private function markSymbolUsed(File $phpcsFile, Symbol $symbol) { 351 | $record = $this->getRecordedImportedSymbolMatchingSymbol($phpcsFile, $symbol); 352 | if (! $record) { 353 | // Symbol records only exist for imported symbols, so if a used symbol 354 | // has not been imported we don't need to mark anything. 355 | $this->debug("ignoring marking symbol used since it was never imported: {$symbol->getName()}"); 356 | return; 357 | } 358 | $record->markUsed(); 359 | } 360 | 361 | private function getRecordedImportedSymbolMatchingSymbol(File $phpcsFile, Symbol $symbol) { 362 | foreach ($this->symbolRecordsByFile[$phpcsFile->path]->importedSymbolRecords as $record) { 363 | $namespaceOrAlias = $symbol->getTopLevelNamespace() ?? $symbol->getAlias(); 364 | $this->debug("comparing symbol {$namespaceOrAlias} to alias {$record->getAlias()}"); 365 | if ($record->getAlias() === $namespaceOrAlias) { 366 | return $record; 367 | } 368 | } 369 | return null; 370 | } 371 | 372 | private function processEndOfFile(File $phpcsFile, int $stackPtr) { 373 | $tokens = $phpcsFile->getTokens(); 374 | // If this is not the end of the file, ignore it 375 | if (isset($tokens[$stackPtr + 1])) { 376 | return; 377 | } 378 | // For each import, if the Symbol was not used, mark a warning 379 | foreach ($this->symbolRecordsByFile[$phpcsFile->path]->importedSymbolRecords as $record) { 380 | if (! $record->isUsed()) { 381 | $this->debug("found unused symbol: {$record->getName()}"); 382 | $error = "Found unused symbol '{$record->getName()}'."; 383 | $phpcsFile->addWarning($error, $record->getSymbolPosition(), 'Import'); 384 | } 385 | } 386 | } 387 | 388 | private function processNamespace(File $phpcsFile, int $stackPtr) { 389 | $helper = new SniffHelpers(); 390 | $symbols = $helper->getImportedSymbolsFromImportStatement($phpcsFile, $stackPtr); 391 | if (count($symbols) < 1) { 392 | return; 393 | } 394 | if (count($symbols) > 1) { 395 | throw new \Exception('Found more than one namespace: ' . var_export($symbols, true)); 396 | } 397 | $this->debug('we are in the namespace: ' . $symbols[0]->getName()); 398 | $this->symbolRecordsByFile[$phpcsFile->path]->activeNamespace = $symbols[0]; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /ImportDetection/WordPressSymbols.php: -------------------------------------------------------------------------------- 1 |