├── LICENSE ├── README.md ├── bin └── composer-dependency-analyser ├── composer.json └── src ├── Analyser.php ├── Cli.php ├── CliOptions.php ├── ComposerJson.php ├── Config ├── Configuration.php ├── ErrorType.php ├── Ignore │ ├── IgnoreList.php │ ├── UnusedErrorIgnore.php │ └── UnusedSymbolIgnore.php └── PathToScan.php ├── Exception ├── AbortException.php ├── InvalidCliException.php ├── InvalidConfigException.php ├── InvalidPathException.php └── RuntimeException.php ├── Initializer.php ├── Path.php ├── Printer.php ├── Result ├── AnalysisResult.php ├── ConsoleFormatter.php ├── JunitFormatter.php ├── ResultFormatter.php └── SymbolUsage.php ├── Stopwatch.php ├── SymbolKind.php └── UsedSymbolExtractor.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Bedabox, LLC dba ShipMonk 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Composer dependency analyser 2 | 3 | - 💪 **Powerful:** Detects unused, shadow and misplaced composer dependencies 4 | - ⚡ **Performant:** Scans 15 000 files in 2s! 5 | - ⚙️ **Configurable:** Fine-grained ignores via PHP config 6 | - 🕸️ **Lightweight:** No composer dependencies 7 | - 🍰 **Easy-to-use:** No config needed for first try 8 | - ✨ **Compatible:** PHP 7.2 - 8.4 9 | 10 | ## Comparison: 11 | 12 | | Project | Dead
dependency | Shadow
dependency | Misplaced
in `require` | Misplaced
in `require-dev` | Time* | 13 | |-------------------------------------------|---------------------|------------------------|--------------------------|-------------------------------|------------| 14 | | maglnet/
**composer-require-checker** | ❌ | ✅ | ❌ | ❌ | 124 secs | 15 | | icanhazstring/
**composer-unused** | ✅ | ❌ | ❌ | ❌ | 72 secs | 16 | | shipmonk/
**composer-dependency-analyser** | ✅ | ✅ | ✅ | ✅ | **2 secs** | 17 | 18 | \*Time measured on codebase with ~15 000 files 19 | 20 | ## Installation: 21 | 22 | ```sh 23 | composer require --dev shipmonk/composer-dependency-analyser 24 | ``` 25 | 26 | *Note that this package itself has **zero composer dependencies.*** 27 | 28 | ## Usage: 29 | 30 | ```sh 31 | vendor/bin/composer-dependency-analyser 32 | ``` 33 | 34 | Example output: 35 | ```txt 36 | 37 | Found shadow dependencies! 38 | (those are used, but not listed as dependency in composer.json) 39 | 40 | • nette/utils 41 | e.g. Nette\Utils\Strings in app/Controller/ProductController.php:24 (+ 6 more) 42 | 43 | Found unused dependencies! 44 | (those are listed in composer.json, but no usage was found in scanned paths) 45 | 46 | • nette/utils 47 | 48 | (scanned 13970 files in 2.297 s) 49 | ``` 50 | 51 | ## Detected issues: 52 | This tool reads your `composer.json` and scans all paths listed in `autoload` & `autoload-dev` sections while analysing you dependencies (both **packages and PHP extensions**). 53 | 54 | ### Shadowed dependencies 55 | - Those are dependencies of your dependencies, which are not listed in `composer.json` 56 | - Your code can break when your direct dependency gets updated to newer version which does not require that shadowed dependency anymore 57 | - You should list all those packages within your dependencies 58 | 59 | ### Unused dependencies 60 | - Any non-dev dependency is expected to have at least single usage within the scanned paths 61 | - To avoid false positives here, you might need to adjust scanned paths or ignore some packages by `--config` 62 | 63 | ### Dev dependencies in production code 64 | - For libraries, this is risky as your users might not have those installed 65 | - For applications, it can break once you run it with `composer install --no-dev` 66 | - You should move those from `require-dev` to `require` 67 | 68 | ### Prod dependencies used only in dev paths 69 | - For libraries, this miscategorization can lead to uselessly required dependencies for your users 70 | - You should move those from `require` to `require-dev` 71 | 72 | ### Unknown classes 73 | - Any class that cannot be autoloaded gets reported as we cannot say if that one is shadowed or not 74 | 75 | ### Unknown functions 76 | - Any function that is used, but not defined during runtime gets reported as we cannot say if that one is shadowed or not 77 | 78 | ## Cli options: 79 | - `--composer-json path/to/composer.json` for custom path to composer.json 80 | - `--dump-usages symfony/console` to show usages of certain package(s), `*` placeholder is supported 81 | - `--config path/to/config.php` for custom path to config file 82 | - `--version` display version 83 | - `--help` display usage & cli options 84 | - `--verbose` to see more example classes & usages 85 | - `--show-all-usages` to see all usages 86 | - `--format` to use different output format, available are: `console` (default), `junit` 87 | - `--disable-ext-analysis` to disable php extensions analysis (e.g. `ext-xml`) 88 | - `--ignore-unknown-classes` to globally ignore unknown classes 89 | - `--ignore-unknown-functions` to globally ignore unknown functions 90 | - `--ignore-shadow-deps` to globally ignore shadow dependencies 91 | - `--ignore-unused-deps` to globally ignore unused dependencies 92 | - `--ignore-dev-in-prod-deps` to globally ignore dev dependencies in prod code 93 | - `--ignore-prod-only-in-dev-deps` to globally ignore prod dependencies used only in dev paths 94 | 95 | 96 | ## Configuration: 97 | When a file named `composer-dependency-analyser.php` is located in cwd, it gets loaded automatically. 98 | The file must return `ShipMonk\ComposerDependencyAnalyser\Config\Configuration` object. 99 | You can use custom path and filename via `--config` cli option. 100 | Here is example of what you can do: 101 | 102 | ```php 103 | addPathToScan(__DIR__ . '/build', isDev: false) 113 | ->addPathToExclude(__DIR__ . '/samples') 114 | ->disableComposerAutoloadPathScan() // disable automatic scan of autoload & autoload-dev paths from composer.json 115 | ->setFileExtensions(['php']) // applies only to directory scanning, not directly listed files 116 | 117 | //// Ignoring errors 118 | ->ignoreErrors([ErrorType::DEV_DEPENDENCY_IN_PROD]) 119 | ->ignoreErrorsOnPath(__DIR__ . '/cache/DIC.php', [ErrorType::SHADOW_DEPENDENCY]) 120 | ->ignoreErrorsOnPackage('symfony/polyfill-php73', [ErrorType::UNUSED_DEPENDENCY]) 121 | ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/src/OptionalCommand.php', [ErrorType::SHADOW_DEPENDENCY]) 122 | 123 | //// Ignoring unknown symbols 124 | ->ignoreUnknownClasses(['Memcached']) 125 | ->ignoreUnknownClassesRegex('~^DDTrace~') 126 | ->ignoreUnknownFunctions(['opcache_invalidate']) 127 | ->ignoreUnknownFunctionsRegex('~^opcache_~') 128 | 129 | //// Adjust analysis 130 | ->enableAnalysisOfUnusedDevDependencies() // dev packages are often used only in CI, so this is not enabled by default 131 | ->disableReportingUnmatchedIgnores() // do not report ignores that never matched any error 132 | ->disableExtensionsAnalysis() // do not analyse ext-* dependencies 133 | 134 | //// Use symbols from yaml/xml/neon files 135 | // - designed for DIC config files (see below) 136 | // - beware that those are not validated and do not even trigger unknown class error 137 | ->addForceUsedSymbols($classesExtractedFromNeonJsonYamlXmlEtc) 138 | ``` 139 | 140 | All paths are expected to exist. If you need some glob functionality, you can do it in your config file and pass the expanded list to e.g. `ignoreErrorsOnPaths`. 141 | 142 | ### Detecting classes from non-php files: 143 | 144 | Some classes might be used only in your DIC config files. Here is a simple way to extract those: 145 | 146 | ```php 147 | $classNameRegex = '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*'; // https://www.php.net/manual/en/language.oop5.basic.php 148 | $dicFileContents = file_get_contents(__DIR__ . '/config/services.yaml'); 149 | 150 | preg_match_all( 151 | "~$classNameRegex(?:\\\\$classNameRegex)+~", // at least one backslash 152 | $dicFileContents, 153 | $matches 154 | ); // or parse the yaml properly 155 | 156 | $config->addForceUsedSymbols($matches[1]); // possibly filter by class_exists || interface_exists 157 | ``` 158 | 159 | Similar approach should help you to avoid false positives in unused dependencies. 160 | Another approach for DIC-only usages is to scan the generated php file, but that gave us worse results. 161 | 162 | ### Scanning codebase located elsewhere: 163 | - This can be done by pointing `--composer-json` to `composer.json` of the other codebase 164 | 165 | ### Disable colored output: 166 | - Set `NO_COLOR` environment variable to disable colored output: 167 | ``` 168 | NO_COLOR=1 vendor/bin/composer-dependency-analyser 169 | ``` 170 | 171 | ## Recommendations: 172 | - For precise `ext-*` analysis, your enabled extensions of your php runtime should be superset of those used in the scanned project 173 | 174 | ## Contributing: 175 | - Check your code by `composer check` 176 | - Autofix coding-style by `composer fix:cs` 177 | - All functionality must be tested 178 | 179 | ## Supported PHP versions 180 | - Runtime requires PHP 7.2 - 8.4 181 | - Scanned codebase should use PHP >= 5.3 182 | -------------------------------------------------------------------------------- /bin/composer-dependency-analyser: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | initCliOptions($cwd, $argv); 39 | $composerJson = $initializer->initComposerJson($options); 40 | $initializer->initComposerAutoloader($composerJson); 41 | $configuration = $initializer->initConfiguration($options, $composerJson); 42 | $classLoaders = $initializer->initComposerClassLoaders(); 43 | 44 | $analyser = new Analyser($stopwatch, $composerJson->composerVendorDir, $classLoaders, $configuration, $composerJson->dependencies); 45 | $result = $analyser->run(); 46 | 47 | $formatter = $initializer->initFormatter($options); 48 | $exitCode = $formatter->format($result, $options, $configuration); 49 | 50 | } catch ( 51 | InvalidPathException | 52 | InvalidConfigException | 53 | InvalidCliException $e 54 | ) { 55 | $stdErrPrinter->printLine("\n{$e->getMessage()}" . PHP_EOL); 56 | exit(1); 57 | 58 | } catch (AbortException $e) { 59 | exit(0); 60 | } 61 | 62 | exit($exitCode); 63 | 64 | 65 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shipmonk/composer-dependency-analyser", 3 | "description": "Fast detection of composer dependency issues (dead dependencies, shadow dependencies, misplaced dependencies)", 4 | "license": [ 5 | "MIT" 6 | ], 7 | "keywords": [ 8 | "dev", 9 | "static analysis", 10 | "composer", 11 | "detector", 12 | "analyser", 13 | "composer dependency", 14 | "unused dependency", 15 | "dead dependency", 16 | "shadow dependency", 17 | "misplaced dependency", 18 | "dead code", 19 | "unused code" 20 | ], 21 | "require": { 22 | "php": "^7.2 || ^8.0", 23 | "ext-json": "*", 24 | "ext-tokenizer": "*" 25 | }, 26 | "require-dev": { 27 | "ext-dom": "*", 28 | "ext-libxml": "*", 29 | "editorconfig-checker/editorconfig-checker": "^10.6.0", 30 | "ergebnis/composer-normalize": "^2.19.0", 31 | "phpcompatibility/php-compatibility": "^9.3.5", 32 | "phpstan/phpstan": "^1.12.3", 33 | "phpstan/phpstan-phpunit": "^1.4.0", 34 | "phpstan/phpstan-strict-rules": "^1.6.0", 35 | "phpunit/phpunit": "^8.5.39 || ^9.6.20", 36 | "shipmonk/name-collision-detector": "^2.1.1", 37 | "slevomat/coding-standard": "^8.15.0" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "ShipMonk\\ComposerDependencyAnalyser\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "ShipMonk\\ComposerDependencyAnalyser\\": "tests/" 47 | }, 48 | "classmap": [ 49 | "tests/data/autoloaded/" 50 | ] 51 | }, 52 | "bin": [ 53 | "bin/composer-dependency-analyser" 54 | ], 55 | "config": { 56 | "allow-plugins": { 57 | "dealerdirect/phpcodesniffer-composer-installer": false, 58 | "ergebnis/composer-normalize": true 59 | }, 60 | "sort-packages": true 61 | }, 62 | "scripts": { 63 | "check": [ 64 | "@check:composer", 65 | "@check:ec", 66 | "@check:cs", 67 | "@check:types", 68 | "@check:tests", 69 | "@check:self", 70 | "@check:collisions", 71 | "@check:scripts" 72 | ], 73 | "check:collisions": "detect-collisions src tests", 74 | "check:composer": [ 75 | "composer normalize --dry-run --no-check-lock --no-update-lock", 76 | "composer validate --strict" 77 | ], 78 | "check:cs": "phpcs", 79 | "check:ec": "ec src tests", 80 | "check:scripts": "phpstan analyse -vv --ansi --level=6 scripts/*.php", 81 | "check:self": "bin/composer-dependency-analyser", 82 | "check:tests": "phpunit -vvv tests", 83 | "check:types": "phpstan analyse -vv --ansi", 84 | "fix:cs": "phpcbf" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Analyser.php: -------------------------------------------------------------------------------- 1 | 73 | */ 74 | private $vendorDirs; 75 | 76 | /** 77 | * @var list 78 | */ 79 | private $classLoaders; 80 | 81 | /** 82 | * @var Configuration 83 | */ 84 | private $config; 85 | 86 | /** 87 | * className => path 88 | * 89 | * @var array 90 | */ 91 | private $classmap = []; 92 | 93 | /** 94 | * package or ext-* => is dev dependency 95 | * 96 | * @var array 97 | */ 98 | private $composerJsonDependencies; 99 | 100 | /** 101 | * symbol name => true 102 | * 103 | * @var array 104 | */ 105 | private $ignoredSymbols; 106 | 107 | /** 108 | * custom function name => path 109 | * 110 | * @var array 111 | */ 112 | private $definedFunctions = []; 113 | 114 | /** 115 | * kind => [symbol name => ext-*] 116 | * 117 | * @var array> 118 | */ 119 | private $extensionSymbols = []; 120 | 121 | /** 122 | * lowercase symbol name => kind 123 | * 124 | * @var array 125 | */ 126 | private $knownSymbolKinds = []; 127 | 128 | /** 129 | * @param array $classLoaders vendorDir => ClassLoader (e.g. result of \Composer\Autoload\ClassLoader::getRegisteredLoaders()) 130 | * @param array $composerJsonDependencies package or ext-* => is dev dependency 131 | */ 132 | public function __construct( 133 | Stopwatch $stopwatch, 134 | string $defaultVendorDir, 135 | array $classLoaders, 136 | Configuration $config, 137 | array $composerJsonDependencies 138 | ) 139 | { 140 | $this->stopwatch = $stopwatch; 141 | $this->config = $config; 142 | $this->composerJsonDependencies = $this->filterDependencies($composerJsonDependencies, $config); 143 | $this->vendorDirs = array_keys($classLoaders + [$defaultVendorDir => null]); 144 | $this->classLoaders = array_values($classLoaders); 145 | 146 | $this->initExistingSymbols($config); 147 | } 148 | 149 | /** 150 | * @throws InvalidPathException 151 | */ 152 | public function run(): AnalysisResult 153 | { 154 | $this->stopwatch->start(); 155 | 156 | $scannedFilesCount = 0; 157 | $unknownClassErrors = []; 158 | $unknownFunctionErrors = []; 159 | $shadowErrors = []; 160 | $devInProdErrors = []; 161 | $prodOnlyInDevErrors = []; 162 | $unusedErrors = []; 163 | 164 | $usedDependencies = []; 165 | $prodDependenciesUsedInProdPath = []; 166 | 167 | $usages = []; 168 | 169 | $ignoreList = $this->config->getIgnoreList(); 170 | 171 | foreach ($this->getUniqueFilePathsToScan() as $filePath => $isDevFilePath) { 172 | $scannedFilesCount++; 173 | 174 | $usedSymbolsByKind = $this->getUsedSymbolsInFile($filePath); 175 | 176 | foreach ($usedSymbolsByKind as $kind => $usedSymbols) { 177 | foreach ($usedSymbols as $usedSymbol => $lineNumbers) { 178 | $normalizedUsedSymbolName = $kind === SymbolKind::FUNCTION ? strtolower($usedSymbol) : $usedSymbol; 179 | 180 | if (isset($this->ignoredSymbols[$normalizedUsedSymbolName])) { 181 | continue; 182 | } 183 | 184 | if (isset($this->extensionSymbols[$kind][$normalizedUsedSymbolName])) { 185 | $dependencyName = $this->extensionSymbols[$kind][$normalizedUsedSymbolName]; 186 | 187 | } else { 188 | $symbolPath = $this->getSymbolPath($usedSymbol, $kind); 189 | 190 | if ($symbolPath === null) { 191 | if ($kind === SymbolKind::CLASSLIKE && !$ignoreList->shouldIgnoreUnknownClass($usedSymbol, $filePath)) { 192 | foreach ($lineNumbers as $lineNumber) { 193 | $unknownClassErrors[$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); 194 | } 195 | } 196 | 197 | if ($kind === SymbolKind::FUNCTION && !$ignoreList->shouldIgnoreUnknownFunction($usedSymbol, $filePath)) { 198 | foreach ($lineNumbers as $lineNumber) { 199 | $unknownFunctionErrors[$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); 200 | } 201 | } 202 | 203 | continue; 204 | } 205 | 206 | if (!$this->isVendorPath($symbolPath)) { 207 | continue; // local class 208 | } 209 | 210 | $dependencyName = $this->getPackageNameFromVendorPath($symbolPath); 211 | } 212 | 213 | if ( 214 | $this->isShadowDependency($dependencyName) 215 | && !$ignoreList->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, $filePath, $dependencyName) 216 | ) { 217 | foreach ($lineNumbers as $lineNumber) { 218 | $shadowErrors[$dependencyName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); 219 | } 220 | } 221 | 222 | if ( 223 | !$isDevFilePath 224 | && $this->isDevDependency($dependencyName) 225 | && !$ignoreList->shouldIgnoreError(ErrorType::DEV_DEPENDENCY_IN_PROD, $filePath, $dependencyName) 226 | ) { 227 | foreach ($lineNumbers as $lineNumber) { 228 | $devInProdErrors[$dependencyName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); 229 | } 230 | } 231 | 232 | if ( 233 | !$isDevFilePath 234 | && !$this->isDevDependency($dependencyName) 235 | ) { 236 | $prodDependenciesUsedInProdPath[$dependencyName] = true; 237 | } 238 | 239 | $usedDependencies[$dependencyName] = true; 240 | 241 | foreach ($lineNumbers as $lineNumber) { 242 | $usages[$dependencyName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); 243 | } 244 | } 245 | } 246 | } 247 | 248 | $forceUsedPackages = []; 249 | 250 | foreach ($this->config->getForceUsedSymbols() as $forceUsedSymbol) { 251 | if (isset($this->ignoredSymbols[$forceUsedSymbol])) { 252 | continue; 253 | } 254 | 255 | if ( 256 | isset($this->extensionSymbols[SymbolKind::FUNCTION][$forceUsedSymbol]) 257 | || isset($this->extensionSymbols[SymbolKind::CONSTANT][$forceUsedSymbol]) 258 | || isset($this->extensionSymbols[SymbolKind::CLASSLIKE][$forceUsedSymbol]) 259 | ) { 260 | $forceUsedDependency = $this->extensionSymbols[SymbolKind::FUNCTION][$forceUsedSymbol] 261 | ?? $this->extensionSymbols[SymbolKind::CONSTANT][$forceUsedSymbol] 262 | ?? $this->extensionSymbols[SymbolKind::CLASSLIKE][$forceUsedSymbol]; 263 | } else { 264 | $symbolPath = $this->getSymbolPath($forceUsedSymbol, null); 265 | 266 | if ($symbolPath === null || !$this->isVendorPath($symbolPath)) { 267 | continue; 268 | } 269 | 270 | $forceUsedDependency = $this->getPackageNameFromVendorPath($symbolPath); 271 | } 272 | 273 | $usedDependencies[$forceUsedDependency] = true; 274 | $forceUsedPackages[$forceUsedDependency] = true; 275 | } 276 | 277 | if ($this->config->shouldReportUnusedDevDependencies()) { 278 | $dependenciesForUnusedAnalysis = array_keys($this->composerJsonDependencies); 279 | 280 | } else { 281 | $dependenciesForUnusedAnalysis = array_keys(array_filter($this->composerJsonDependencies, static function (bool $devDependency) { 282 | return !$devDependency; // dev deps are typically used only in CI 283 | })); 284 | } 285 | 286 | $unusedDependencies = array_diff( 287 | $dependenciesForUnusedAnalysis, 288 | array_keys($usedDependencies), 289 | self::CORE_EXTENSIONS 290 | ); 291 | 292 | foreach ($unusedDependencies as $unusedDependency) { 293 | if (!$ignoreList->shouldIgnoreError(ErrorType::UNUSED_DEPENDENCY, null, $unusedDependency)) { 294 | $unusedErrors[] = $unusedDependency; 295 | } 296 | } 297 | 298 | $prodDependencies = array_keys(array_filter($this->composerJsonDependencies, static function (bool $devDependency) { 299 | return !$devDependency; 300 | })); 301 | $prodPackagesUsedOnlyInDev = array_diff( 302 | $prodDependencies, 303 | array_keys($prodDependenciesUsedInProdPath), 304 | array_keys($forceUsedPackages), // we dont know where are those used, lets not report them 305 | $unusedDependencies, 306 | self::CORE_EXTENSIONS 307 | ); 308 | 309 | foreach ($prodPackagesUsedOnlyInDev as $prodPackageUsedOnlyInDev) { 310 | if (!$ignoreList->shouldIgnoreError(ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV, null, $prodPackageUsedOnlyInDev)) { 311 | $prodOnlyInDevErrors[] = $prodPackageUsedOnlyInDev; 312 | } 313 | } 314 | 315 | return new AnalysisResult( 316 | $scannedFilesCount, 317 | $this->stopwatch->stop(), 318 | $usages, 319 | $unknownClassErrors, 320 | $unknownFunctionErrors, 321 | $shadowErrors, 322 | $devInProdErrors, 323 | $prodOnlyInDevErrors, 324 | $unusedErrors, 325 | $ignoreList->getUnusedIgnores() 326 | ); 327 | } 328 | 329 | /** 330 | * What paths overlap in composer.json autoload sections, 331 | * we don't want to scan paths multiple times 332 | * 333 | * @return array 334 | * @throws InvalidPathException 335 | */ 336 | private function getUniqueFilePathsToScan(): array 337 | { 338 | $allFilePaths = []; 339 | 340 | $scanPaths = $this->config->getPathsToScan(); 341 | usort($scanPaths, static function (PathToScan $a, PathToScan $b): int { 342 | return strlen($a->getPath()) <=> strlen($b->getPath()); 343 | }); 344 | 345 | foreach ($scanPaths as $scanPath) { 346 | foreach ($this->listPhpFilesIn($scanPath->getPath()) as $filePath) { 347 | if ($this->config->isExcludedFilepath($filePath)) { 348 | continue; 349 | } 350 | 351 | $allFilePaths[$filePath] = $scanPath->isDev(); 352 | } 353 | } 354 | 355 | return $allFilePaths; 356 | } 357 | 358 | private function isShadowDependency(string $packageName): bool 359 | { 360 | return !isset($this->composerJsonDependencies[$packageName]); 361 | } 362 | 363 | private function isDevDependency(string $packageName): bool 364 | { 365 | $isDevDependency = $this->composerJsonDependencies[$packageName] ?? null; 366 | return $isDevDependency === true; 367 | } 368 | 369 | private function getPackageNameFromVendorPath(string $realPath): string 370 | { 371 | foreach ($this->vendorDirs as $vendorDir) { 372 | if (strpos($realPath, $vendorDir) === 0) { 373 | $filePathInVendor = trim(str_replace($vendorDir, '', $realPath), DIRECTORY_SEPARATOR); 374 | [$vendor, $package] = explode(DIRECTORY_SEPARATOR, $filePathInVendor, 3); 375 | return "$vendor/$package"; 376 | } 377 | } 378 | 379 | throw new LogicException("Path '$realPath' not found in vendor. This method can be called only when isVendorPath(\$realPath) returns true"); 380 | } 381 | 382 | /** 383 | * @return array>> 384 | * @throws InvalidPathException 385 | */ 386 | private function getUsedSymbolsInFile(string $filePath): array 387 | { 388 | $code = file_get_contents($filePath); 389 | 390 | if ($code === false) { 391 | throw new InvalidPathException("Unable to get contents of '$filePath'"); 392 | } 393 | 394 | return (new UsedSymbolExtractor($code))->parseUsedSymbols( 395 | $this->knownSymbolKinds 396 | ); 397 | } 398 | 399 | /** 400 | * @return Generator 401 | * @throws InvalidPathException 402 | */ 403 | private function listPhpFilesIn(string $path): Generator 404 | { 405 | if (is_file($path)) { 406 | yield $path; 407 | return; 408 | } 409 | 410 | try { 411 | $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); 412 | } catch (UnexpectedValueException $e) { 413 | throw new InvalidPathException("Unable to list files in $path", $e); 414 | } 415 | 416 | foreach ($iterator as $entry) { 417 | /** @var DirectoryIterator $entry */ 418 | if (!$entry->isFile() || !in_array($entry->getExtension(), $this->config->getFileExtensions(), true)) { 419 | continue; 420 | } 421 | 422 | yield $entry->getPathname(); 423 | } 424 | } 425 | 426 | private function isVendorPath(string $realPath): bool 427 | { 428 | foreach ($this->vendorDirs as $vendorDir) { 429 | if (strpos($realPath, $vendorDir) === 0) { 430 | return true; 431 | } 432 | } 433 | 434 | return false; 435 | } 436 | 437 | private function getSymbolPath(string $symbol, ?int $kind): ?string 438 | { 439 | if ($kind === SymbolKind::FUNCTION || $kind === null) { 440 | $lowerSymbol = strtolower($symbol); 441 | 442 | if (isset($this->definedFunctions[$lowerSymbol])) { 443 | return $this->definedFunctions[$lowerSymbol]; 444 | } 445 | 446 | if ($kind === SymbolKind::FUNCTION) { 447 | return null; 448 | } 449 | } 450 | 451 | if (!array_key_exists($symbol, $this->classmap)) { 452 | $path = $this->detectFileByClassLoader($symbol) ?? $this->detectFileByReflection($symbol); 453 | $this->classmap[$symbol] = $path === null 454 | ? null 455 | : $this->normalizePath($path); // composer ClassLoader::findFile() returns e.g. /opt/project/vendor/composer/../../src/Config/Configuration.php (which is not vendor path) 456 | } 457 | 458 | return $this->classmap[$symbol]; 459 | } 460 | 461 | /** 462 | * This should minimize the amount autoloaded classes 463 | */ 464 | private function detectFileByClassLoader(string $usedSymbol): ?string 465 | { 466 | foreach ($this->classLoaders as $classLoader) { 467 | $filePath = $classLoader->findFile($usedSymbol); 468 | 469 | if ($filePath !== false) { 470 | return $filePath; 471 | } 472 | } 473 | 474 | return null; 475 | } 476 | 477 | private function detectFileByReflection(string $usedSymbol): ?string 478 | { 479 | try { 480 | $reflection = new ReflectionClass($usedSymbol); // @phpstan-ignore-line ignore not a class-string, we catch the exception 481 | } catch (ReflectionException $e) { 482 | return null; // not autoloadable class 483 | } 484 | 485 | $filePath = $reflection->getFileName(); 486 | 487 | if ($filePath === false) { 488 | return null; // should probably never happen as internal classes are handled earlier 489 | } 490 | 491 | return $filePath; 492 | } 493 | 494 | private function normalizePath(string $filePath): string 495 | { 496 | $pharPrefix = 'phar://'; 497 | 498 | if (strpos($filePath, $pharPrefix) === 0) { 499 | /** @var string $filePath Cannot resolve to false */ 500 | $filePath = substr($filePath, strlen($pharPrefix)); 501 | } 502 | 503 | return Path::normalize($filePath); 504 | } 505 | 506 | private function initExistingSymbols(Configuration $config): void 507 | { 508 | $this->ignoredSymbols = [ 509 | // built-in types 510 | 'bool' => true, 511 | 'int' => true, 512 | 'float' => true, 513 | 'string' => true, 514 | 'null' => true, 515 | 'array' => true, 516 | 'object' => true, 517 | 'never' => true, 518 | 'void' => true, 519 | 520 | // value types 521 | 'false' => true, 522 | 'true' => true, 523 | 524 | // callable 525 | 'callable' => true, 526 | 527 | // relative class types 528 | 'self' => true, 529 | 'parent' => true, 530 | 'static' => true, 531 | 532 | // aliases 533 | 'mixed' => true, 534 | 'iterable' => true, 535 | 536 | // composer internal classes 537 | 'Composer\\InstalledVersions' => true, 538 | 'Composer\\Autoload\\ClassLoader' => true, 539 | ]; 540 | 541 | /** @var array> $definedConstants */ 542 | $definedConstants = get_defined_constants(true); 543 | 544 | foreach ($definedConstants as $constantExtension => $constants) { 545 | foreach ($constants as $constantName => $_) { 546 | if ($constantExtension === 'user' || !$config->shouldAnalyseExtensions()) { 547 | $this->ignoredSymbols[$constantName] = true; 548 | 549 | } else { 550 | $extensionName = $this->getNormalizedExtensionName($constantExtension); 551 | 552 | if (in_array($extensionName, self::CORE_EXTENSIONS, true)) { 553 | $this->ignoredSymbols[$constantName] = true; 554 | } else { 555 | $this->extensionSymbols[SymbolKind::CONSTANT][$constantName] = $extensionName; 556 | $this->knownSymbolKinds[strtolower($constantName)] = SymbolKind::CONSTANT; 557 | } 558 | } 559 | } 560 | } 561 | 562 | foreach (get_defined_functions() as $functionNames) { 563 | foreach ($functionNames as $functionName) { 564 | $reflectionFunction = new ReflectionFunction($functionName); 565 | $functionFilePath = $reflectionFunction->getFileName(); 566 | 567 | if ($reflectionFunction->getExtension() === null) { 568 | if (is_string($functionFilePath)) { 569 | $this->definedFunctions[$functionName] = Path::normalize($functionFilePath); 570 | $this->knownSymbolKinds[$functionName] = SymbolKind::FUNCTION; 571 | } 572 | } else { 573 | $extensionName = $this->getNormalizedExtensionName($reflectionFunction->getExtension()->name); 574 | 575 | if (in_array($extensionName, self::CORE_EXTENSIONS, true) || !$config->shouldAnalyseExtensions()) { 576 | $this->ignoredSymbols[$functionName] = true; 577 | } else { 578 | $this->extensionSymbols[SymbolKind::FUNCTION][$functionName] = $extensionName; 579 | $this->knownSymbolKinds[$functionName] = SymbolKind::FUNCTION; 580 | } 581 | } 582 | } 583 | } 584 | 585 | $classLikes = [ 586 | get_declared_classes(), 587 | get_declared_interfaces(), 588 | get_declared_traits(), 589 | ]; 590 | 591 | foreach ($classLikes as $classLikeNames) { 592 | foreach ($classLikeNames as $classLikeName) { 593 | $classReflection = new ReflectionClass($classLikeName); 594 | 595 | if ($classReflection->getExtension() !== null) { 596 | $extensionName = $this->getNormalizedExtensionName($classReflection->getExtension()->name); 597 | 598 | if (in_array($extensionName, self::CORE_EXTENSIONS, true) || !$config->shouldAnalyseExtensions()) { 599 | $this->ignoredSymbols[$classLikeName] = true; 600 | } else { 601 | $this->extensionSymbols[SymbolKind::CLASSLIKE][$classLikeName] = $extensionName; 602 | $this->knownSymbolKinds[strtolower($classLikeName)] = SymbolKind::CLASSLIKE; 603 | } 604 | } 605 | } 606 | } 607 | } 608 | 609 | private function getNormalizedExtensionName(string $extension): string 610 | { 611 | return 'ext-' . ComposerJson::normalizeExtensionName($extension); 612 | } 613 | 614 | /** 615 | * @param array $dependencies 616 | * @return array 617 | */ 618 | private function filterDependencies(array $dependencies, Configuration $config): array 619 | { 620 | $filtered = []; 621 | 622 | foreach ($dependencies as $dependency => $isDevDependency) { 623 | if (!$config->shouldAnalyseExtensions() && strpos($dependency, 'ext-') === 0) { 624 | continue; 625 | } 626 | 627 | $filtered[$dependency] = $isDevDependency; 628 | } 629 | 630 | return $filtered; 631 | } 632 | 633 | } 634 | -------------------------------------------------------------------------------- /src/Cli.php: -------------------------------------------------------------------------------- 1 | false, 21 | 'help' => false, 22 | 'verbose' => false, 23 | 'disable-ext-analysis' => false, 24 | 'ignore-shadow-deps' => false, 25 | 'ignore-unused-deps' => false, 26 | 'ignore-dev-in-prod-deps' => false, 27 | 'ignore-prod-only-in-dev-deps' => false, 28 | 'ignore-unknown-classes' => false, 29 | 'ignore-unknown-functions' => false, 30 | 'ignore-unknown-symbols' => false, 31 | 'composer-json' => true, 32 | 'config' => true, 33 | 'dump-usages' => true, 34 | 'show-all-usages' => false, 35 | 'format' => true, 36 | ]; 37 | 38 | /** 39 | * @var array 40 | */ 41 | private $providedOptions = []; 42 | 43 | /** 44 | * @param list $argv 45 | * @throws InvalidCliException 46 | */ 47 | public function __construct(string $cwd, array $argv) 48 | { 49 | $ignoreNextArg = false; 50 | $argsWithoutScript = array_slice($argv, 1); 51 | 52 | foreach ($argsWithoutScript as $argIndex => $arg) { 53 | if ($ignoreNextArg === true) { 54 | $ignoreNextArg = false; 55 | continue; 56 | } 57 | 58 | $startsWithDash = strpos($arg, '-') === 0; 59 | $startsWithDashDash = strpos($arg, '--') === 0; 60 | 61 | if ($startsWithDash && !$startsWithDashDash) { 62 | $suggestedOption = $this->suggestOption($arg); 63 | throw new InvalidCliException("Unknown option $arg, $suggestedOption"); 64 | } 65 | 66 | if (!$startsWithDashDash) { 67 | if (is_file($cwd . '/' . $arg) || is_dir($cwd . '/' . $arg)) { 68 | throw new InvalidCliException("Cannot pass paths ($arg) to analyse as arguments, use --config instead."); 69 | } 70 | 71 | $suggestedOption = $this->suggestOption($arg); 72 | throw new InvalidCliException("Unknown argument $arg, $suggestedOption"); 73 | } 74 | 75 | /** @var string $noDashesArg this is never false as we know it starts with -- */ 76 | $noDashesArg = substr($arg, 2); 77 | $optionName = $this->getKnownOptionName($noDashesArg); 78 | 79 | if ($optionName === null) { 80 | $suggestedOption = $this->suggestOption($noDashesArg); 81 | throw new InvalidCliException("Unknown option $arg, $suggestedOption"); 82 | } 83 | 84 | if ($this->isOptionWithRequiredValue($optionName)) { 85 | $optionArgument = $this->getOptionArgumentAfterAssign($arg); 86 | 87 | if ($optionArgument === null) { // next $arg is the argument 88 | $ignoreNextArg = true; 89 | $nextArg = $argsWithoutScript[$argIndex + 1] ?? false; 90 | 91 | if ($nextArg === false || strpos($nextArg, '-') === 0) { 92 | throw new InvalidCliException("Missing argument for $arg, see --help"); 93 | } 94 | 95 | $this->providedOptions[$optionName] = $nextArg; 96 | } elseif ($optionArgument === '') { 97 | throw new InvalidCliException("Missing argument value in $arg, see --help"); 98 | } else { 99 | $this->providedOptions[$optionName] = $optionArgument; 100 | } 101 | } else { 102 | if ($this->getOptionArgumentAfterAssign($arg) !== null) { 103 | throw new InvalidCliException("Option --$optionName does not accept arguments, see --help"); 104 | } 105 | 106 | $this->providedOptions[$optionName] = true; 107 | } 108 | } 109 | } 110 | 111 | private function getOptionArgumentAfterAssign(string $arg): ?string 112 | { 113 | $position = strpos($arg, '='); 114 | 115 | if ($position !== false) { 116 | return substr($arg, $position + 1); // @phpstan-ignore-line this will never be false 117 | } 118 | 119 | return null; 120 | } 121 | 122 | private function isOptionWithRequiredValue(string $optionName): bool 123 | { 124 | return self::OPTIONS[$optionName]; 125 | } 126 | 127 | private function getKnownOptionName(string $option): ?string 128 | { 129 | foreach (self::OPTIONS as $knownOption => $needsArgument) { 130 | if ( 131 | strpos($option, $knownOption) === 0 132 | && (strlen($option) === strlen($knownOption) || $option[strlen($knownOption)] === '=') 133 | ) { 134 | return $knownOption; 135 | } 136 | } 137 | 138 | return null; 139 | } 140 | 141 | public function getProvidedOptions(): CliOptions 142 | { 143 | $options = new CliOptions(); 144 | 145 | if (isset($this->providedOptions['version'])) { 146 | $options->version = true; 147 | } 148 | 149 | if (isset($this->providedOptions['help'])) { 150 | $options->help = true; 151 | } 152 | 153 | if (isset($this->providedOptions['verbose'])) { 154 | $options->verbose = true; 155 | } 156 | 157 | if (isset($this->providedOptions['disable-ext-analysis'])) { 158 | $options->disableExtAnalysis = true; 159 | } 160 | 161 | if (isset($this->providedOptions['ignore-shadow-deps'])) { 162 | $options->ignoreShadowDeps = true; 163 | } 164 | 165 | if (isset($this->providedOptions['ignore-unused-deps'])) { 166 | $options->ignoreUnusedDeps = true; 167 | } 168 | 169 | if (isset($this->providedOptions['ignore-dev-in-prod-deps'])) { 170 | $options->ignoreDevInProdDeps = true; 171 | } 172 | 173 | if (isset($this->providedOptions['ignore-prod-only-in-dev-deps'])) { 174 | $options->ignoreProdOnlyInDevDeps = true; 175 | } 176 | 177 | if (isset($this->providedOptions['ignore-unknown-classes'])) { 178 | $options->ignoreUnknownClasses = true; 179 | } 180 | 181 | if (isset($this->providedOptions['ignore-unknown-functions'])) { 182 | $options->ignoreUnknownFunctions = true; 183 | } 184 | 185 | if (isset($this->providedOptions['composer-json'])) { 186 | $options->composerJson = $this->providedOptions['composer-json']; // @phpstan-ignore-line type is ensured 187 | } 188 | 189 | if (isset($this->providedOptions['config'])) { 190 | $options->config = $this->providedOptions['config']; // @phpstan-ignore-line type is ensured 191 | } 192 | 193 | if (isset($this->providedOptions['dump-usages'])) { 194 | $options->dumpUsages = $this->providedOptions['dump-usages']; // @phpstan-ignore-line type is ensured 195 | } 196 | 197 | if (isset($this->providedOptions['show-all-usages'])) { 198 | $options->showAllUsages = true; 199 | } 200 | 201 | if (isset($this->providedOptions['format'])) { 202 | $options->format = $this->providedOptions['format']; // @phpstan-ignore-line type is ensured 203 | } 204 | 205 | return $options; 206 | } 207 | 208 | /** 209 | * Params inspired by tracy/tracy 210 | */ 211 | private function suggestOption(string $input): string 212 | { 213 | $value = trim($input, '-'); 214 | $options = array_keys(self::OPTIONS); 215 | 216 | $bestGuess = null; 217 | $minDistance = (strlen($value) / 4 + 1) * 10 + .1; 218 | 219 | foreach ($options as $option) { 220 | $distance = levenshtein($option, $value, 9, 11, 9); 221 | 222 | if ($distance > 0 && $distance < $minDistance) { 223 | $minDistance = $distance; 224 | $bestGuess = $option; 225 | } 226 | } 227 | 228 | return $bestGuess === null 229 | ? 'see --help' 230 | : "did you mean --$bestGuess?"; 231 | } 232 | 233 | } 234 | -------------------------------------------------------------------------------- /src/CliOptions.php: -------------------------------------------------------------------------------- 1 | isDev 49 | * 50 | * @readonly 51 | * @var array 52 | */ 53 | public $dependencies; 54 | 55 | /** 56 | * Absolute path => isDev 57 | * 58 | * @readonly 59 | * @var array 60 | */ 61 | public $autoloadPaths; 62 | 63 | /** 64 | * Regex => isDev 65 | * 66 | * @readonly 67 | * @var array 68 | */ 69 | public $autoloadExcludeRegexes; 70 | 71 | /** 72 | * @throws InvalidPathException 73 | * @throws InvalidConfigException 74 | */ 75 | public function __construct( 76 | string $composerJsonPath 77 | ) 78 | { 79 | $basePath = dirname($composerJsonPath); 80 | 81 | $composerJsonData = $this->parseComposerJson($composerJsonPath); 82 | $this->composerVendorDir = $this->resolveComposerVendorDir($basePath, $composerJsonData['config']['vendor-dir'] ?? 'vendor'); 83 | $this->composerAutoloadPath = Path::normalize($this->composerVendorDir . '/autoload.php'); 84 | 85 | $requiredPackages = $composerJsonData['require'] ?? []; 86 | $requiredDevPackages = $composerJsonData['require-dev'] ?? []; 87 | 88 | $this->autoloadPaths = array_merge( 89 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload']['psr-0'] ?? [], false), 90 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload']['psr-4'] ?? [], false), 91 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload']['files'] ?? [], false), 92 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload']['classmap'] ?? [], false), 93 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-0'] ?? [], true), 94 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-4'] ?? [], true), 95 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['files'] ?? [], true), 96 | $this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['classmap'] ?? [], true) 97 | ); 98 | $this->autoloadExcludeRegexes = array_merge( 99 | $this->extractAutoloadExcludeRegexes($basePath, $composerJsonData['autoload']['exclude-from-classmap'] ?? [], false), 100 | $this->extractAutoloadExcludeRegexes($basePath, $composerJsonData['autoload-dev']['exclude-from-classmap'] ?? [], true) 101 | ); 102 | 103 | $filterExtensions = static function (string $dependency): bool { 104 | return strpos($dependency, 'ext-') === 0; 105 | }; 106 | $filterPackages = static function (string $package): bool { 107 | return strpos($package, '/') !== false; 108 | }; 109 | 110 | $this->dependencies = $this->normalizeNames(array_merge( 111 | array_fill_keys(array_keys(array_filter($requiredPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), false), 112 | array_fill_keys(array_keys(array_filter($requiredPackages, $filterExtensions, ARRAY_FILTER_USE_KEY)), false), 113 | array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true), 114 | array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterExtensions, ARRAY_FILTER_USE_KEY)), true) 115 | )); 116 | 117 | if (count($this->dependencies) === 0) { 118 | throw new InvalidConfigException("No dependencies found in $composerJsonPath file."); 119 | } 120 | } 121 | 122 | /** 123 | * @param array $dependencies 124 | * @return array 125 | */ 126 | private function normalizeNames(array $dependencies): array 127 | { 128 | $normalized = []; 129 | 130 | foreach ($dependencies as $dependency => $isDev) { 131 | if (strpos($dependency, 'ext-') === 0) { 132 | $key = self::normalizeExtensionName($dependency); 133 | } else { 134 | $key = $dependency; 135 | } 136 | 137 | $normalized[$key] = $isDev; 138 | } 139 | 140 | return $normalized; 141 | } 142 | 143 | /** 144 | * Zend Opcache -> zend-opcache 145 | */ 146 | public static function normalizeExtensionName(string $extension): string 147 | { 148 | return str_replace(' ', '-', strtolower($extension)); 149 | } 150 | 151 | /** 152 | * @param array> $autoload 153 | * @return array 154 | * @throws InvalidPathException 155 | */ 156 | private function extractAutoloadPaths(string $basePath, array $autoload, bool $isDev): array 157 | { 158 | $result = []; 159 | 160 | foreach ($autoload as $paths) { 161 | if (!is_array($paths)) { 162 | $paths = [$paths]; 163 | } 164 | 165 | foreach ($paths as $path) { 166 | if (Path::isAbsolute($path)) { 167 | $absolutePath = $path; 168 | } else { 169 | $absolutePath = $basePath . '/' . $path; 170 | } 171 | 172 | if (strpos($path, '*') !== false) { // https://getcomposer.org/doc/04-schema.md#classmap 173 | $globPaths = glob($absolutePath); 174 | 175 | if ($globPaths === false) { 176 | throw new InvalidPathException("Failure while globbing $absolutePath path."); 177 | } 178 | 179 | foreach ($globPaths as $globPath) { 180 | $result[Path::normalize($globPath)] = $isDev; 181 | } 182 | 183 | continue; 184 | } 185 | 186 | $result[Path::normalize($absolutePath)] = $isDev; 187 | } 188 | } 189 | 190 | return $result; 191 | } 192 | 193 | /** 194 | * @param array $exclude 195 | * @return array 196 | * @throws InvalidPathException 197 | */ 198 | private function extractAutoloadExcludeRegexes(string $basePath, array $exclude, bool $isDev): array 199 | { 200 | $regexes = []; 201 | 202 | foreach ($exclude as $path) { 203 | $regexes[$this->resolveAutoloadExclude($basePath, $path)] = $isDev; 204 | } 205 | 206 | return $regexes; 207 | } 208 | 209 | /** 210 | * Implementation copied from composer/composer. 211 | * 212 | * @license MIT https://github.com/composer/composer/blob/ee2c9afdc86ef3f06a4bd49b1fea7d1d636afc92/LICENSE 213 | * @see https://getcomposer.org/doc/04-schema.md#exclude-files-from-classmaps 214 | * @see https://github.com/composer/composer/blob/ee2c9afdc86ef3f06a4bd49b1fea7d1d636afc92/src/Composer/Autoload/AutoloadGenerator.php#L1256-L1286 215 | * @throws InvalidPathException 216 | */ 217 | private function resolveAutoloadExclude(string $basePath, string $pathPattern): string 218 | { 219 | // first escape user input 220 | $path = preg_replace('{/+}', '/', preg_quote(trim(strtr($pathPattern, '\\', '/'), '/'))); 221 | 222 | if ($path === null) { 223 | throw new InvalidPathException("Failure while globbing $pathPattern path."); 224 | } 225 | 226 | // add support for wildcards * and ** 227 | $path = strtr($path, ['\\*\\*' => '.+?', '\\*' => '[^/]+?']); 228 | 229 | // add support for up-level relative paths 230 | $updir = null; 231 | $path = preg_replace_callback( 232 | '{^((?:(?:\\\\\\.){1,2}+/)+)}', 233 | static function ($matches) use (&$updir): string { 234 | // undo preg_quote for the matched string 235 | $updir = str_replace('\\.', '.', $matches[1]); 236 | 237 | return ''; 238 | }, 239 | $path 240 | // note: composer also uses `PREG_UNMATCHED_AS_NULL` but the `$flags` arg supported since PHP v7.4 241 | ); 242 | 243 | if ($path === null) { 244 | throw new InvalidPathException("Failure while globbing $pathPattern path."); 245 | } 246 | 247 | $resolvedPath = realpath($basePath . '/' . $updir); 248 | 249 | if ($resolvedPath === false) { 250 | throw new InvalidPathException("Failure while globbing $pathPattern path."); 251 | } 252 | 253 | // Finalize 254 | $delimiter = '#'; 255 | $pattern = '^' . preg_quote(strtr($resolvedPath, '\\', '/'), $delimiter) . '/' . $path . '($|/)'; 256 | $pattern = $delimiter . $pattern . $delimiter; 257 | 258 | return $pattern; 259 | } 260 | 261 | /** 262 | * @return array{ 263 | * require?: array, 264 | * require-dev?: array, 265 | * config?: array{ 266 | * vendor-dir?: string, 267 | * }, 268 | * autoload?: array{ 269 | * psr-0?: array, 270 | * psr-4?: array, 271 | * files?: string[], 272 | * classmap?: string[], 273 | * exclude-from-classmap?: string[] 274 | * }, 275 | * autoload-dev?: array{ 276 | * psr-0?: array, 277 | * psr-4?: array, 278 | * files?: string[], 279 | * classmap?: string[], 280 | * exclude-from-classmap?: string[] 281 | * } 282 | * } 283 | * @throws InvalidPathException 284 | */ 285 | private function parseComposerJson(string $composerJsonPath): array 286 | { 287 | if (!is_file($composerJsonPath)) { 288 | throw new InvalidPathException("File composer.json not found, '$composerJsonPath' is not a file."); 289 | } 290 | 291 | $composerJsonRawData = file_get_contents($composerJsonPath); 292 | 293 | if ($composerJsonRawData === false) { 294 | throw new InvalidPathException("Failure while reading $composerJsonPath file."); 295 | } 296 | 297 | $composerJsonData = json_decode($composerJsonRawData, true); 298 | 299 | $jsonError = json_last_error(); 300 | 301 | if ($jsonError !== JSON_ERROR_NONE) { 302 | throw new InvalidPathException("Failure while parsing $composerJsonPath file: " . json_last_error_msg()); 303 | } 304 | 305 | return $composerJsonData; // @phpstan-ignore-line ignore mixed returned 306 | } 307 | 308 | private function resolveComposerVendorDir(string $basePath, string $vendorDir): string 309 | { 310 | if (Path::isAbsolute($vendorDir)) { 311 | return Path::normalize($vendorDir); 312 | } 313 | 314 | return Path::normalize($basePath . '/' . $vendorDir); 315 | } 316 | 317 | } 318 | -------------------------------------------------------------------------------- /src/Config/Configuration.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | private $forceUsedSymbols = []; 41 | 42 | /** 43 | * @var list 44 | */ 45 | private $ignoredErrors = []; 46 | 47 | /** 48 | * @var list 49 | */ 50 | private $fileExtensions = ['php']; 51 | 52 | /** 53 | * @var list 54 | */ 55 | private $pathsToScan = []; 56 | 57 | /** 58 | * @var list 59 | */ 60 | private $pathsToExclude = []; 61 | 62 | /** 63 | * @var list 64 | */ 65 | private $pathRegexesToExclude = []; 66 | 67 | /** 68 | * @var array> 69 | */ 70 | private $ignoredErrorsOnPath = []; 71 | 72 | /** 73 | * @var array> 74 | */ 75 | private $ignoredErrorsOnDependency = []; 76 | 77 | /** 78 | * @var array>> 79 | */ 80 | private $ignoredErrorsOnDependencyAndPath = []; 81 | 82 | /** 83 | * @var list 84 | */ 85 | private $ignoredUnknownClasses = []; 86 | 87 | /** 88 | * @var list 89 | */ 90 | private $ignoredUnknownClassesRegexes = []; 91 | 92 | /** 93 | * @var list 94 | */ 95 | private $ignoredUnknownFunctions = []; 96 | 97 | /** 98 | * @var list 99 | */ 100 | private $ignoredUnknownFunctionsRegexes = []; 101 | 102 | /** 103 | * Disable analysis of ext-* dependencies 104 | * 105 | * @return $this 106 | */ 107 | public function disableExtensionsAnalysis(): self 108 | { 109 | $this->extensionsAnalysis = false; 110 | return $this; 111 | } 112 | 113 | /** 114 | * @return $this 115 | */ 116 | public function disableComposerAutoloadPathScan(): self 117 | { 118 | $this->scanComposerAutoloadPaths = false; 119 | return $this; 120 | } 121 | 122 | /** 123 | * @return $this 124 | */ 125 | public function disableReportingUnmatchedIgnores(): self 126 | { 127 | $this->reportUnmatchedIgnores = false; 128 | return $this; 129 | } 130 | 131 | /** 132 | * @return $this 133 | */ 134 | public function enableAnalysisOfUnusedDevDependencies(): self 135 | { 136 | $this->reportUnusedDevDependencies = true; 137 | return $this; 138 | } 139 | 140 | /** 141 | * @param list $errorTypes 142 | * @return $this 143 | */ 144 | public function ignoreErrors(array $errorTypes): self 145 | { 146 | $this->ignoredErrors = array_merge($this->ignoredErrors, $errorTypes); 147 | return $this; 148 | } 149 | 150 | /** 151 | * @param list $extensions 152 | * @return $this 153 | */ 154 | public function setFileExtensions(array $extensions): self 155 | { 156 | $this->fileExtensions = $extensions; 157 | return $this; 158 | } 159 | 160 | /** 161 | * @return $this 162 | */ 163 | public function addForceUsedSymbol(string $symbol): self 164 | { 165 | $this->forceUsedSymbols[] = $symbol; 166 | return $this; 167 | } 168 | 169 | /** 170 | * @param list $symbols 171 | * @return $this 172 | */ 173 | public function addForceUsedSymbols(array $symbols): self 174 | { 175 | foreach ($symbols as $symbol) { 176 | $this->addForceUsedSymbol($symbol); 177 | } 178 | 179 | return $this; 180 | } 181 | 182 | /** 183 | * @return $this 184 | * @throws InvalidPathException 185 | */ 186 | public function addPathToScan(string $path, bool $isDev): self 187 | { 188 | $this->pathsToScan[] = new PathToScan(Path::realpath($path), $isDev); 189 | return $this; 190 | } 191 | 192 | /** 193 | * @param list $paths 194 | * @return $this 195 | * @throws InvalidPathException 196 | */ 197 | public function addPathsToScan(array $paths, bool $isDev): self 198 | { 199 | foreach ($paths as $path) { 200 | $this->addPathToScan($path, $isDev); 201 | } 202 | 203 | return $this; 204 | } 205 | 206 | /** 207 | * @param list $paths 208 | * @return $this 209 | * @throws InvalidPathException 210 | */ 211 | public function addPathsToExclude(array $paths): self 212 | { 213 | foreach ($paths as $path) { 214 | $this->addPathToExclude($path); 215 | } 216 | 217 | return $this; 218 | } 219 | 220 | /** 221 | * @return $this 222 | * @throws InvalidPathException 223 | */ 224 | public function addPathToExclude(string $path): self 225 | { 226 | $this->pathsToExclude[] = Path::realpath($path); 227 | return $this; 228 | } 229 | 230 | /** 231 | * @param list $regexes 232 | * @return $this 233 | * @throws InvalidConfigException 234 | */ 235 | public function addPathRegexesToExclude(array $regexes): self 236 | { 237 | foreach ($regexes as $regex) { 238 | $this->addPathRegexToExclude($regex); 239 | } 240 | 241 | return $this; 242 | } 243 | 244 | /** 245 | * @return $this 246 | * @throws InvalidConfigException 247 | */ 248 | public function addPathRegexToExclude(string $regex): self 249 | { 250 | if (@preg_match($regex, '') === false) { 251 | throw new InvalidConfigException("Invalid regex '$regex'"); 252 | } 253 | 254 | $this->pathRegexesToExclude[] = $regex; 255 | return $this; 256 | } 257 | 258 | /** 259 | * @param list $errorTypes 260 | * @return $this 261 | * @throws InvalidPathException 262 | * @throws InvalidConfigException 263 | */ 264 | public function ignoreErrorsOnPath(string $path, array $errorTypes): self 265 | { 266 | $this->checkAllowedErrorTypeForPathIgnore($errorTypes); 267 | 268 | $realpath = Path::realpath($path); 269 | 270 | $previousErrorTypes = $this->ignoredErrorsOnPath[$realpath] ?? []; 271 | $this->ignoredErrorsOnPath[$realpath] = array_merge($previousErrorTypes, $errorTypes); 272 | return $this; 273 | } 274 | 275 | /** 276 | * @param list $paths 277 | * @param list $errorTypes 278 | * @return $this 279 | * @throws InvalidPathException 280 | * @throws InvalidConfigException 281 | */ 282 | public function ignoreErrorsOnPaths(array $paths, array $errorTypes): self 283 | { 284 | foreach ($paths as $path) { 285 | $this->ignoreErrorsOnPath($path, $errorTypes); 286 | } 287 | 288 | return $this; 289 | } 290 | 291 | /** 292 | * @param list $errorTypes 293 | * @return $this 294 | * @throws InvalidConfigException 295 | */ 296 | public function ignoreErrorsOnPackage(string $packageName, array $errorTypes): self 297 | { 298 | $this->checkPackageName($packageName); 299 | $this->ignoreErrorsOnDependency($packageName, $errorTypes); 300 | return $this; 301 | } 302 | 303 | /** 304 | * @param list $errorTypes 305 | * @return $this 306 | * @throws InvalidConfigException 307 | */ 308 | public function ignoreErrorsOnExtension(string $extension, array $errorTypes): self 309 | { 310 | $this->checkExtensionName($extension); 311 | $this->ignoreErrorsOnDependency($extension, $errorTypes); 312 | return $this; 313 | } 314 | 315 | /** 316 | * @param list $errorTypes 317 | * @throws InvalidConfigException 318 | */ 319 | private function ignoreErrorsOnDependency(string $dependency, array $errorTypes): void 320 | { 321 | $this->checkAllowedErrorTypeForPackageIgnore($errorTypes); 322 | 323 | $previousErrorTypes = $this->ignoredErrorsOnDependency[$dependency] ?? []; 324 | $this->ignoredErrorsOnDependency[$dependency] = array_merge($previousErrorTypes, $errorTypes); 325 | } 326 | 327 | /** 328 | * @param list $packageNames 329 | * @param list $errorTypes 330 | * @return $this 331 | * @throws InvalidConfigException 332 | */ 333 | public function ignoreErrorsOnPackages(array $packageNames, array $errorTypes): self 334 | { 335 | foreach ($packageNames as $packageName) { 336 | $this->ignoreErrorsOnPackage($packageName, $errorTypes); 337 | } 338 | 339 | return $this; 340 | } 341 | 342 | /** 343 | * @param list $extensions 344 | * @param list $errorTypes 345 | * @return $this 346 | * @throws InvalidConfigException 347 | */ 348 | public function ignoreErrorsOnExtensions(array $extensions, array $errorTypes): self 349 | { 350 | foreach ($extensions as $extension) { 351 | $this->ignoreErrorsOnExtension($extension, $errorTypes); 352 | } 353 | 354 | return $this; 355 | } 356 | 357 | /** 358 | * @param list $errorTypes 359 | * @return $this 360 | * @throws InvalidPathException 361 | * @throws InvalidConfigException 362 | */ 363 | public function ignoreErrorsOnPackageAndPath(string $packageName, string $path, array $errorTypes): self 364 | { 365 | $this->checkPackageName($packageName); 366 | $this->ignoreErrorsOnDependencyAndPath($packageName, $path, $errorTypes); 367 | return $this; 368 | } 369 | 370 | /** 371 | * @param list $errorTypes 372 | * @return $this 373 | * @throws InvalidPathException 374 | * @throws InvalidConfigException 375 | */ 376 | public function ignoreErrorsOnExtensionAndPath(string $extension, string $path, array $errorTypes): self 377 | { 378 | $this->checkExtensionName($extension); 379 | $this->ignoreErrorsOnDependencyAndPath($extension, $path, $errorTypes); 380 | return $this; 381 | } 382 | 383 | /** 384 | * @param list $errorTypes 385 | * @throws InvalidPathException 386 | * @throws InvalidConfigException 387 | */ 388 | private function ignoreErrorsOnDependencyAndPath(string $dependency, string $path, array $errorTypes): void 389 | { 390 | $this->checkAllowedErrorTypeForPathIgnore($errorTypes); 391 | $this->checkAllowedErrorTypeForPackageIgnore($errorTypes); 392 | 393 | $realpath = Path::realpath($path); 394 | 395 | $previousErrorTypes = $this->ignoredErrorsOnDependencyAndPath[$dependency][$realpath] ?? []; 396 | $this->ignoredErrorsOnDependencyAndPath[$dependency][$realpath] = array_merge($previousErrorTypes, $errorTypes); 397 | } 398 | 399 | /** 400 | * @param list $paths 401 | * @param list $errorTypes 402 | * @return $this 403 | * @throws InvalidPathException 404 | * @throws InvalidConfigException 405 | */ 406 | public function ignoreErrorsOnPackageAndPaths(string $packageName, array $paths, array $errorTypes): self 407 | { 408 | foreach ($paths as $path) { 409 | $this->ignoreErrorsOnPackageAndPath($packageName, $path, $errorTypes); 410 | } 411 | 412 | return $this; 413 | } 414 | 415 | /** 416 | * @param list $paths 417 | * @param list $errorTypes 418 | * @return $this 419 | * @throws InvalidPathException 420 | * @throws InvalidConfigException 421 | */ 422 | public function ignoreErrorsOnExtensionAndPaths(string $extension, array $paths, array $errorTypes): self 423 | { 424 | foreach ($paths as $path) { 425 | $this->ignoreErrorsOnExtensionAndPath($extension, $path, $errorTypes); 426 | } 427 | 428 | return $this; 429 | } 430 | 431 | /** 432 | * @param list $packages 433 | * @param list $paths 434 | * @param list $errorTypes 435 | * @return $this 436 | * @throws InvalidPathException 437 | * @throws InvalidConfigException 438 | */ 439 | public function ignoreErrorsOnPackagesAndPaths(array $packages, array $paths, array $errorTypes): self 440 | { 441 | foreach ($packages as $package) { 442 | $this->ignoreErrorsOnPackageAndPaths($package, $paths, $errorTypes); 443 | } 444 | 445 | return $this; 446 | } 447 | 448 | /** 449 | * @param list $extensions 450 | * @param list $paths 451 | * @param list $errorTypes 452 | * @return $this 453 | * @throws InvalidPathException 454 | * @throws InvalidConfigException 455 | */ 456 | public function ignoreErrorsOnExtensionsAndPaths(array $extensions, array $paths, array $errorTypes): self 457 | { 458 | foreach ($extensions as $extension) { 459 | $this->ignoreErrorsOnExtensionAndPaths($extension, $paths, $errorTypes); 460 | } 461 | 462 | return $this; 463 | } 464 | 465 | /** 466 | * @param list $classNames 467 | * @return $this 468 | */ 469 | public function ignoreUnknownClasses(array $classNames): self 470 | { 471 | $this->ignoredUnknownClasses = array_merge($this->ignoredUnknownClasses, $classNames); 472 | 473 | return $this; 474 | } 475 | 476 | /** 477 | * @param list $functionNames 478 | * @return $this 479 | */ 480 | public function ignoreUnknownFunctions(array $functionNames): self 481 | { 482 | $this->ignoredUnknownFunctions = array_merge($this->ignoredUnknownFunctions, $functionNames); 483 | 484 | return $this; 485 | } 486 | 487 | /** 488 | * @return $this 489 | * @throws InvalidConfigException 490 | */ 491 | public function ignoreUnknownClassesRegex(string $classNameRegex): self 492 | { 493 | if (@preg_match($classNameRegex, '') === false) { 494 | throw new InvalidConfigException("Invalid regex '$classNameRegex'"); 495 | } 496 | 497 | $this->ignoredUnknownClassesRegexes[] = $classNameRegex; 498 | return $this; 499 | } 500 | 501 | /** 502 | * @return $this 503 | * @throws InvalidConfigException 504 | */ 505 | public function ignoreUnknownFunctionsRegex(string $functionNameRegex): self 506 | { 507 | if (@preg_match($functionNameRegex, '') === false) { 508 | throw new InvalidConfigException("Invalid regex '$functionNameRegex'"); 509 | } 510 | 511 | $this->ignoredUnknownFunctionsRegexes[] = $functionNameRegex; 512 | return $this; 513 | } 514 | 515 | public function getIgnoreList(): IgnoreList 516 | { 517 | return new IgnoreList( 518 | $this->ignoredErrors, 519 | $this->ignoredErrorsOnPath, 520 | $this->ignoredErrorsOnDependency, 521 | $this->ignoredErrorsOnDependencyAndPath, 522 | $this->ignoredUnknownClasses, 523 | $this->ignoredUnknownClassesRegexes, 524 | $this->ignoredUnknownFunctions, 525 | $this->ignoredUnknownFunctionsRegexes 526 | ); 527 | } 528 | 529 | /** 530 | * @return list 531 | */ 532 | public function getFileExtensions(): array 533 | { 534 | return $this->fileExtensions; 535 | } 536 | 537 | /** 538 | * @return list 539 | */ 540 | public function getForceUsedSymbols(): array 541 | { 542 | return $this->forceUsedSymbols; 543 | } 544 | 545 | /** 546 | * @return list 547 | */ 548 | public function getPathsToScan(): array 549 | { 550 | return $this->pathsToScan; 551 | } 552 | 553 | public function shouldAnalyseExtensions(): bool 554 | { 555 | return $this->extensionsAnalysis; 556 | } 557 | 558 | public function shouldScanComposerAutoloadPaths(): bool 559 | { 560 | return $this->scanComposerAutoloadPaths; 561 | } 562 | 563 | public function shouldReportUnusedDevDependencies(): bool 564 | { 565 | return $this->reportUnusedDevDependencies; 566 | } 567 | 568 | public function shouldReportUnmatchedIgnoredErrors(): bool 569 | { 570 | return $this->reportUnmatchedIgnores; 571 | } 572 | 573 | public function isExcludedFilepath(string $filePath): bool 574 | { 575 | foreach ($this->pathsToExclude as $pathToExclude) { 576 | if ($this->isFilepathWithinPath($filePath, $pathToExclude)) { 577 | return true; 578 | } 579 | } 580 | 581 | foreach ($this->pathRegexesToExclude as $pathRegexToExclude) { 582 | if ((bool) preg_match($pathRegexToExclude, $filePath)) { 583 | return true; 584 | } 585 | } 586 | 587 | return false; 588 | } 589 | 590 | private function isFilepathWithinPath(string $filePath, string $path): bool 591 | { 592 | return strpos($filePath, $path) === 0; 593 | } 594 | 595 | /** 596 | * @throws InvalidConfigException 597 | */ 598 | private function checkExtensionName(string $extension): void 599 | { 600 | if (strpos($extension, 'ext-') !== 0) { 601 | throw new InvalidConfigException("Invalid php extension dependency name '$extension', it is expected to start with ext-"); 602 | } 603 | } 604 | 605 | /** 606 | * @throws InvalidConfigException 607 | */ 608 | private function checkPackageName(string $packageName): void 609 | { 610 | $regex = '~^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$~'; // https://getcomposer.org/doc/04-schema.md 611 | 612 | if (preg_match($regex, $packageName) !== 1) { 613 | throw new InvalidConfigException("Invalid package name '$packageName'"); 614 | } 615 | } 616 | 617 | /** 618 | * @param list $errorTypes 619 | * @throws InvalidConfigException 620 | */ 621 | private function checkAllowedErrorTypeForPathIgnore(array $errorTypes): void 622 | { 623 | if (in_array(ErrorType::UNUSED_DEPENDENCY, $errorTypes, true)) { 624 | throw new InvalidConfigException('UNUSED_DEPENDENCY errors cannot be ignored on a path'); 625 | } 626 | 627 | if (in_array(ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV, $errorTypes, true)) { 628 | throw new InvalidConfigException('PROD_DEPENDENCY_ONLY_IN_DEV errors cannot be ignored on a path'); 629 | } 630 | } 631 | 632 | /** 633 | * @param list $errorTypes 634 | * @throws InvalidConfigException 635 | */ 636 | private function checkAllowedErrorTypeForPackageIgnore(array $errorTypes): void 637 | { 638 | if (in_array(ErrorType::UNKNOWN_CLASS, $errorTypes, true)) { 639 | throw new InvalidConfigException('UNKNOWN_CLASS errors cannot be ignored on a package'); 640 | } 641 | 642 | if (in_array(ErrorType::UNKNOWN_FUNCTION, $errorTypes, true)) { 643 | throw new InvalidConfigException('UNKNOWN_FUNCTION errors cannot be ignored on a package'); 644 | } 645 | } 646 | 647 | } 648 | -------------------------------------------------------------------------------- /src/Config/ErrorType.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private $ignoredErrors; 19 | 20 | /** 21 | * @var array> 22 | */ 23 | private $ignoredErrorsOnPath = []; 24 | 25 | /** 26 | * @var array> 27 | */ 28 | private $ignoredErrorsOnDependency = []; 29 | 30 | /** 31 | * @var array>> 32 | */ 33 | private $ignoredErrorsOnDependencyAndPath = []; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private $ignoredUnknownClasses; 39 | 40 | /** 41 | * @var array 42 | */ 43 | private $ignoredUnknownClassesRegexes; 44 | 45 | /** 46 | * @var array 47 | */ 48 | private $ignoredUnknownFunctions; 49 | 50 | /** 51 | * @var array 52 | */ 53 | private $ignoredUnknownFunctionsRegexes; 54 | 55 | /** 56 | * @param list $ignoredErrors 57 | * @param array> $ignoredErrorsOnPath 58 | * @param array> $ignoredErrorsOnDependency 59 | * @param array>> $ignoredErrorsOnDependencyAndPath 60 | * @param list $ignoredUnknownClasses 61 | * @param list $ignoredUnknownClassesRegexes 62 | * @param list $ignoredUnknownFunctions 63 | * @param list $ignoredUnknownFunctionsRegexes 64 | */ 65 | public function __construct( 66 | array $ignoredErrors, 67 | array $ignoredErrorsOnPath, 68 | array $ignoredErrorsOnDependency, 69 | array $ignoredErrorsOnDependencyAndPath, 70 | array $ignoredUnknownClasses, 71 | array $ignoredUnknownClassesRegexes, 72 | array $ignoredUnknownFunctions, 73 | array $ignoredUnknownFunctionsRegexes 74 | ) 75 | { 76 | $this->ignoredErrors = array_fill_keys($ignoredErrors, false); 77 | 78 | foreach ($ignoredErrorsOnPath as $path => $errorTypes) { 79 | $this->ignoredErrorsOnPath[$path] = array_fill_keys($errorTypes, false); 80 | } 81 | 82 | foreach ($ignoredErrorsOnDependency as $dependency => $errorTypes) { 83 | $this->ignoredErrorsOnDependency[$dependency] = array_fill_keys($errorTypes, false); 84 | } 85 | 86 | foreach ($ignoredErrorsOnDependencyAndPath as $dependency => $paths) { 87 | foreach ($paths as $path => $errorTypes) { 88 | $this->ignoredErrorsOnDependencyAndPath[$dependency][$path] = array_fill_keys($errorTypes, false); 89 | } 90 | } 91 | 92 | $this->ignoredUnknownClasses = array_fill_keys($ignoredUnknownClasses, false); 93 | $this->ignoredUnknownClassesRegexes = array_fill_keys($ignoredUnknownClassesRegexes, false); 94 | $this->ignoredUnknownFunctions = array_fill_keys($ignoredUnknownFunctions, false); 95 | $this->ignoredUnknownFunctionsRegexes = array_fill_keys($ignoredUnknownFunctionsRegexes, false); 96 | } 97 | 98 | /** 99 | * @return list 100 | */ 101 | public function getUnusedIgnores(): array 102 | { 103 | $unused = []; 104 | 105 | foreach ($this->ignoredErrors as $errorType => $ignored) { 106 | if (!$ignored) { 107 | $unused[] = new UnusedErrorIgnore($errorType, null, null); 108 | } 109 | } 110 | 111 | foreach ($this->ignoredErrorsOnPath as $path => $errorTypes) { 112 | foreach ($errorTypes as $errorType => $ignored) { 113 | if (!$ignored) { 114 | $unused[] = new UnusedErrorIgnore($errorType, $path, null); 115 | } 116 | } 117 | } 118 | 119 | foreach ($this->ignoredErrorsOnDependency as $packageName => $errorTypes) { 120 | foreach ($errorTypes as $errorType => $ignored) { 121 | if (!$ignored) { 122 | $unused[] = new UnusedErrorIgnore($errorType, null, $packageName); 123 | } 124 | } 125 | } 126 | 127 | foreach ($this->ignoredErrorsOnDependencyAndPath as $packageName => $paths) { 128 | foreach ($paths as $path => $errorTypes) { 129 | foreach ($errorTypes as $errorType => $ignored) { 130 | if (!$ignored) { 131 | $unused[] = new UnusedErrorIgnore($errorType, $path, $packageName); 132 | } 133 | } 134 | } 135 | } 136 | 137 | foreach ($this->ignoredUnknownClasses as $class => $ignored) { 138 | if (!$ignored) { 139 | $unused[] = new UnusedSymbolIgnore($class, false, SymbolKind::CLASSLIKE); 140 | } 141 | } 142 | 143 | foreach ($this->ignoredUnknownClassesRegexes as $regex => $ignored) { 144 | if (!$ignored) { 145 | $unused[] = new UnusedSymbolIgnore($regex, true, SymbolKind::CLASSLIKE); 146 | } 147 | } 148 | 149 | foreach ($this->ignoredUnknownFunctions as $function => $ignored) { 150 | if (!$ignored) { 151 | $unused[] = new UnusedSymbolIgnore($function, false, SymbolKind::FUNCTION); 152 | } 153 | } 154 | 155 | foreach ($this->ignoredUnknownFunctionsRegexes as $regex => $ignored) { 156 | if (!$ignored) { 157 | $unused[] = new UnusedSymbolIgnore($regex, true, SymbolKind::FUNCTION); 158 | } 159 | } 160 | 161 | return $unused; 162 | } 163 | 164 | public function shouldIgnoreUnknownClass(string $class, string $filePath): bool 165 | { 166 | $ignoredGlobally = $this->shouldIgnoreErrorGlobally(ErrorType::UNKNOWN_CLASS); 167 | $ignoredByPath = $this->shouldIgnoreErrorOnPath(ErrorType::UNKNOWN_CLASS, $filePath); 168 | $ignoredByRegex = $this->shouldIgnoreUnknownClassByRegex($class); 169 | $ignoredByBlacklist = $this->shouldIgnoreUnknownClassByBlacklist($class); 170 | 171 | return $ignoredGlobally || $ignoredByPath || $ignoredByRegex || $ignoredByBlacklist; 172 | } 173 | 174 | public function shouldIgnoreUnknownFunction(string $function, string $filePath): bool 175 | { 176 | $ignoredGlobally = $this->shouldIgnoreErrorGlobally(ErrorType::UNKNOWN_FUNCTION); 177 | $ignoredByPath = $this->shouldIgnoreErrorOnPath(ErrorType::UNKNOWN_FUNCTION, $filePath); 178 | $ignoredByRegex = $this->shouldIgnoreUnknownFunctionByRegex($function); 179 | $ignoredByBlacklist = $this->shouldIgnoreUnknownFunctionByBlacklist($function); 180 | 181 | return $ignoredGlobally || $ignoredByPath || $ignoredByRegex || $ignoredByBlacklist; 182 | } 183 | 184 | private function shouldIgnoreUnknownClassByBlacklist(string $class): bool 185 | { 186 | if (isset($this->ignoredUnknownClasses[$class])) { 187 | $this->ignoredUnknownClasses[$class] = true; 188 | return true; 189 | } 190 | 191 | return false; 192 | } 193 | 194 | private function shouldIgnoreUnknownFunctionByBlacklist(string $function): bool 195 | { 196 | if (isset($this->ignoredUnknownFunctions[$function])) { 197 | $this->ignoredUnknownFunctions[$function] = true; 198 | return true; 199 | } 200 | 201 | return false; 202 | } 203 | 204 | private function shouldIgnoreUnknownClassByRegex(string $class): bool 205 | { 206 | foreach ($this->ignoredUnknownClassesRegexes as $regex => $ignoreUsed) { 207 | $matches = preg_match($regex, $class); 208 | 209 | if ($matches === false) { 210 | throw new LogicException("Invalid regex: '$regex'"); 211 | } 212 | 213 | if ($matches === 1) { 214 | $this->ignoredUnknownClassesRegexes[$regex] = true; 215 | return true; 216 | } 217 | } 218 | 219 | return false; 220 | } 221 | 222 | private function shouldIgnoreUnknownFunctionByRegex(string $function): bool 223 | { 224 | foreach ($this->ignoredUnknownFunctionsRegexes as $regex => $ignoreUsed) { 225 | $matches = preg_match($regex, $function); 226 | 227 | if ($matches === false) { 228 | throw new LogicException("Invalid regex: '$regex'"); 229 | } 230 | 231 | if ($matches === 1) { 232 | $this->ignoredUnknownFunctionsRegexes[$regex] = true; 233 | return true; 234 | } 235 | } 236 | 237 | return false; 238 | } 239 | 240 | /** 241 | * @param ErrorType::SHADOW_DEPENDENCY|ErrorType::UNUSED_DEPENDENCY|ErrorType::DEV_DEPENDENCY_IN_PROD|ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV $errorType 242 | */ 243 | public function shouldIgnoreError(string $errorType, ?string $realPath, ?string $dependency): bool 244 | { 245 | $ignoredGlobally = $this->shouldIgnoreErrorGlobally($errorType); 246 | $ignoredByPath = $realPath !== null && $this->shouldIgnoreErrorOnPath($errorType, $realPath); 247 | $ignoredByPackage = $dependency !== null && $this->shouldIgnoreErrorOnDependency($errorType, $dependency); 248 | $ignoredByPackageAndPath = $realPath !== null && $dependency !== null && $this->shouldIgnoreErrorOnDependencyAndPath($errorType, $dependency, $realPath); 249 | 250 | return $ignoredGlobally || $ignoredByPackageAndPath || $ignoredByPath || $ignoredByPackage; 251 | } 252 | 253 | /** 254 | * @param ErrorType::* $errorType 255 | */ 256 | private function shouldIgnoreErrorGlobally(string $errorType): bool 257 | { 258 | if (isset($this->ignoredErrors[$errorType])) { 259 | $this->ignoredErrors[$errorType] = true; 260 | return true; 261 | } 262 | 263 | return false; 264 | } 265 | 266 | /** 267 | * @param ErrorType::* $errorType 268 | */ 269 | private function shouldIgnoreErrorOnPath(string $errorType, string $filePath): bool 270 | { 271 | foreach ($this->ignoredErrorsOnPath as $path => $errorTypes) { 272 | if ($this->isFilepathWithinPath($filePath, $path) && isset($errorTypes[$errorType])) { 273 | $this->ignoredErrorsOnPath[$path][$errorType] = true; 274 | return true; 275 | } 276 | } 277 | 278 | return false; 279 | } 280 | 281 | /** 282 | * @param ErrorType::* $errorType 283 | */ 284 | private function shouldIgnoreErrorOnDependency(string $errorType, string $dependency): bool 285 | { 286 | if (isset($this->ignoredErrorsOnDependency[$dependency][$errorType])) { 287 | $this->ignoredErrorsOnDependency[$dependency][$errorType] = true; 288 | return true; 289 | } 290 | 291 | return false; 292 | } 293 | 294 | /** 295 | * @param ErrorType::* $errorType 296 | */ 297 | private function shouldIgnoreErrorOnDependencyAndPath(string $errorType, string $packageName, string $filePath): bool 298 | { 299 | if (isset($this->ignoredErrorsOnDependencyAndPath[$packageName])) { 300 | foreach ($this->ignoredErrorsOnDependencyAndPath[$packageName] as $path => $errorTypes) { 301 | if ($this->isFilepathWithinPath($filePath, $path) && isset($errorTypes[$errorType])) { 302 | $this->ignoredErrorsOnDependencyAndPath[$packageName][$path][$errorType] = true; 303 | return true; 304 | } 305 | } 306 | } 307 | 308 | return false; 309 | } 310 | 311 | private function isFilepathWithinPath(string $filePath, string $path): bool 312 | { 313 | return strpos($filePath, $path) === 0; 314 | } 315 | 316 | } 317 | -------------------------------------------------------------------------------- /src/Config/Ignore/UnusedErrorIgnore.php: -------------------------------------------------------------------------------- 1 | errorType = $errorType; 31 | $this->filePath = $filePath; 32 | $this->package = $package; 33 | } 34 | 35 | public function getErrorType(): string 36 | { 37 | return $this->errorType; 38 | } 39 | 40 | public function getPath(): ?string 41 | { 42 | return $this->filePath; 43 | } 44 | 45 | public function getPackage(): ?string 46 | { 47 | return $this->package; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/Config/Ignore/UnusedSymbolIgnore.php: -------------------------------------------------------------------------------- 1 | unknownSymbol = $unknownSymbol; 31 | $this->isRegex = $isRegex; 32 | $this->symbolKind = $symbolKind; 33 | } 34 | 35 | public function getUnknownSymbol(): string 36 | { 37 | return $this->unknownSymbol; 38 | } 39 | 40 | public function isRegex(): bool 41 | { 42 | return $this->isRegex; 43 | } 44 | 45 | /** 46 | * @return SymbolKind::CLASSLIKE|SymbolKind::FUNCTION 47 | */ 48 | public function getSymbolKind(): int 49 | { 50 | return $this->symbolKind; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/Config/PathToScan.php: -------------------------------------------------------------------------------- 1 | path = $path; 21 | $this->isDev = $isDev; 22 | } 23 | 24 | public function getPath(): string 25 | { 26 | return $this->path; 27 | } 28 | 29 | public function isDev(): bool 30 | { 31 | return $this->isDev; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/Exception/AbortException.php: -------------------------------------------------------------------------------- 1 | Dump usages of given package, * placeholder can be used 41 | --composer-json Provide custom path to composer.json 42 | --config Provide path to php configuration file 43 | (must return \ShipMonk\ComposerDependencyAnalyser\Config\Configuration instance) 44 | --format Change output format. Available values: console (default), junit 45 | 46 | Ignore options: 47 | (or use --config for better granularity) 48 | 49 | --ignore-unknown-classes Ignore all non-autoloadable classes 50 | --ignore-unknown-functions Ignore all undefined functions 51 | --ignore-unused-deps Ignore all unused dependency issues 52 | --ignore-shadow-deps Ignore all shadow dependency issues 53 | --ignore-dev-in-prod-deps Ignore all dev dependency in production code issues 54 | --ignore-prod-only-in-dev-deps Ignore all prod dependency used only in dev paths issues 55 | 56 | --disable-ext-analysis Disable analysis of php extensions (e.g. ext-xml) 57 | EOD; 58 | 59 | /** 60 | * @var string 61 | */ 62 | private $cwd; 63 | 64 | /** 65 | * @var Printer 66 | */ 67 | private $stdOutPrinter; 68 | 69 | /** 70 | * @var Printer 71 | */ 72 | private $stdErrPrinter; 73 | 74 | public function __construct( 75 | string $cwd, 76 | Printer $stdOutPrinter, 77 | Printer $stdErrPrinter 78 | ) 79 | { 80 | $this->cwd = $cwd; 81 | $this->stdOutPrinter = $stdOutPrinter; 82 | $this->stdErrPrinter = $stdErrPrinter; 83 | } 84 | 85 | /** 86 | * @throws InvalidConfigException 87 | */ 88 | public function initConfiguration( 89 | CliOptions $options, 90 | ComposerJson $composerJson 91 | ): Configuration 92 | { 93 | if ($options->config !== null) { 94 | $configPath = Path::resolve($this->cwd, $options->config); 95 | 96 | if (!is_file($configPath)) { 97 | throw new InvalidConfigException("Invalid config path given, {$configPath} is not a file."); 98 | } 99 | } else { 100 | $configPath = $this->cwd . '/composer-dependency-analyser.php'; 101 | } 102 | 103 | if (is_file($configPath)) { 104 | $this->stdErrPrinter->printLine('Using config ' . $configPath); 105 | 106 | try { 107 | $config = (static function () use ($configPath) { 108 | return require $configPath; 109 | })(); 110 | } catch (Throwable $e) { 111 | throw new InvalidConfigException("Error while loading configuration from '$configPath':\n\n" . get_class($e) . " in {$e->getFile()}:{$e->getLine()}\n > " . $e->getMessage(), $e); 112 | } 113 | 114 | if (!$config instanceof Configuration) { 115 | throw new InvalidConfigException('Invalid config file, it must return instance of ' . Configuration::class); 116 | } 117 | } else { 118 | $config = new Configuration(); 119 | } 120 | 121 | $disableExtAnalysis = $options->disableExtAnalysis === true; 122 | $ignoreUnknownClasses = $options->ignoreUnknownClasses === true; 123 | $ignoreUnknownFunctions = $options->ignoreUnknownFunctions === true; 124 | $ignoreUnused = $options->ignoreUnusedDeps === true; 125 | $ignoreShadow = $options->ignoreShadowDeps === true; 126 | $ignoreDevInProd = $options->ignoreDevInProdDeps === true; 127 | $ignoreProdOnlyInDev = $options->ignoreProdOnlyInDevDeps === true; 128 | 129 | if ($disableExtAnalysis) { 130 | $config->disableExtensionsAnalysis(); 131 | } 132 | 133 | if ($ignoreUnknownClasses) { 134 | $config->ignoreErrors([ErrorType::UNKNOWN_CLASS]); 135 | } 136 | 137 | if ($ignoreUnknownFunctions) { 138 | $config->ignoreErrors([ErrorType::UNKNOWN_FUNCTION]); 139 | } 140 | 141 | if ($ignoreUnused) { 142 | $config->ignoreErrors([ErrorType::UNUSED_DEPENDENCY]); 143 | } 144 | 145 | if ($ignoreShadow) { 146 | $config->ignoreErrors([ErrorType::SHADOW_DEPENDENCY]); 147 | } 148 | 149 | if ($ignoreDevInProd) { 150 | $config->ignoreErrors([ErrorType::DEV_DEPENDENCY_IN_PROD]); 151 | } 152 | 153 | if ($ignoreProdOnlyInDev) { 154 | $config->ignoreErrors([ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV]); 155 | } 156 | 157 | if ($config->shouldScanComposerAutoloadPaths()) { 158 | try { 159 | foreach ($composerJson->autoloadPaths as $absolutePath => $isDevPath) { 160 | $config->addPathToScan($absolutePath, $isDevPath); 161 | } 162 | 163 | foreach ($composerJson->autoloadExcludeRegexes as $excludeRegex => $isDevRegex) { 164 | $config->addPathRegexToExclude($excludeRegex); 165 | } 166 | } catch (InvalidPathException $e) { 167 | throw new InvalidConfigException('Error while processing composer.json autoload path: ' . $e->getMessage(), $e); 168 | } 169 | 170 | if ($config->getPathsToScan() === []) { 171 | throw new InvalidConfigException('No paths to scan! There is no composer autoload section and no extra path to scan configured.'); 172 | } 173 | } else { 174 | if ($config->getPathsToScan() === []) { 175 | throw new InvalidConfigException('No paths to scan! Scanning composer\'s \'autoload\' sections is disabled and no extra path to scan was configured.'); 176 | } 177 | } 178 | 179 | return $config; 180 | } 181 | 182 | /** 183 | * @throws InvalidPathException 184 | * @throws InvalidConfigException 185 | */ 186 | public function initComposerJson(CliOptions $options): ComposerJson 187 | { 188 | $composerJsonPath = $options->composerJson !== null 189 | ? Path::resolve($this->cwd, $options->composerJson) 190 | : Path::normalize($this->cwd . '/composer.json'); 191 | 192 | return new ComposerJson($composerJsonPath); 193 | } 194 | 195 | /** 196 | * @throws InvalidConfigException 197 | */ 198 | public function initComposerAutoloader(ComposerJson $composerJson): void 199 | { 200 | // load vendor that belongs to given composer.json 201 | $autoloadFile = $composerJson->composerAutoloadPath; 202 | 203 | if (!is_file($autoloadFile)) { 204 | throw new InvalidConfigException("Cannot find composer's autoload file, expected at '$autoloadFile'"); 205 | } 206 | 207 | require $autoloadFile; 208 | } 209 | 210 | /** 211 | * @return array 212 | */ 213 | public function initComposerClassLoaders(): array 214 | { 215 | $loaders = ClassLoader::getRegisteredLoaders(); 216 | 217 | if (count($loaders) > 1) { 218 | $this->stdErrPrinter->printLine("\nDetected multiple class loaders:"); 219 | 220 | foreach ($loaders as $vendorDir => $_) { 221 | $this->stdErrPrinter->printLine(" • $vendorDir"); 222 | } 223 | 224 | $this->stdErrPrinter->printLine(''); 225 | } 226 | 227 | if (count($loaders) === 0) { 228 | $this->stdErrPrinter->printLine("\nNo composer class loader detected!\n"); 229 | } 230 | 231 | return $loaders; 232 | } 233 | 234 | /** 235 | * @param list $argv 236 | * @throws AbortException 237 | * @throws InvalidCliException 238 | */ 239 | public function initCliOptions(string $cwd, array $argv): CliOptions 240 | { 241 | $cliOptions = (new Cli($cwd, $argv))->getProvidedOptions(); 242 | 243 | if ($cliOptions->help !== null) { 244 | $this->stdOutPrinter->printLine(self::$help); 245 | throw new AbortException(); 246 | } 247 | 248 | if ($cliOptions->version !== null) { 249 | $this->stdOutPrinter->printLine('Composer Dependency Analyser ' . $this->deduceVersion()); 250 | throw new AbortException(); 251 | } 252 | 253 | return $cliOptions; 254 | } 255 | 256 | /** 257 | * @throws InvalidConfigException 258 | */ 259 | public function initFormatter(CliOptions $options): ResultFormatter 260 | { 261 | $format = $options->format ?? 'console'; 262 | 263 | if ($format === 'junit') { 264 | if ($options->dumpUsages !== null) { 265 | throw new InvalidConfigException("Cannot use 'junit' format with '--dump-usages' option."); 266 | } 267 | 268 | return new JunitFormatter($this->cwd, $this->stdOutPrinter); 269 | } 270 | 271 | if ($format === 'console') { 272 | return new ConsoleFormatter($this->cwd, $this->stdOutPrinter); 273 | } 274 | 275 | throw new InvalidConfigException("Invalid format option provided, allowed are 'console' or 'junit'."); 276 | } 277 | 278 | private function deduceVersion(): string 279 | { 280 | try { 281 | if (isset($GLOBALS['_composer_autoload_path'])) { 282 | require $GLOBALS['_composer_autoload_path']; 283 | } 284 | 285 | /** @throws OutOfBoundsException */ 286 | if (!class_exists(InstalledVersions::class)) { 287 | return 'unknown'; 288 | } 289 | 290 | $package = 'shipmonk/composer-dependency-analyser'; 291 | 292 | return sprintf( 293 | '%s (%s)', 294 | InstalledVersions::getPrettyVersion($package), 295 | InstalledVersions::getReference($package) 296 | ); 297 | 298 | } catch (OutOfBoundsException $e) { 299 | return 'not found'; 300 | } 301 | } 302 | 303 | } 304 | -------------------------------------------------------------------------------- /src/Path.php: -------------------------------------------------------------------------------- 1 | $parts */ 57 | $parts = $path === '' 58 | ? [] 59 | : preg_split('~[/\\\\]+~', $path); 60 | $result = []; 61 | 62 | foreach ($parts as $part) { 63 | if ($part === '..' && $result !== [] && end($result) !== '..' && end($result) !== '') { 64 | array_pop($result); 65 | } elseif ($part !== '.') { 66 | $result[] = $part; 67 | } 68 | } 69 | 70 | return $result === [''] 71 | ? DIRECTORY_SEPARATOR 72 | : implode(DIRECTORY_SEPARATOR, $result); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/Printer.php: -------------------------------------------------------------------------------- 1 | ' => "\033[31m", 17 | '' => "\033[32m", 18 | '' => "\033[33m", 19 | '' => "\033[37m", 20 | '' => "\033[0m", 21 | '' => "\033[0m", 22 | '' => "\033[0m", 23 | '' => "\033[0m", 24 | ]; 25 | 26 | /** 27 | * @var resource 28 | */ 29 | private $resource; 30 | 31 | /** 32 | * @var bool 33 | */ 34 | private $noColor; 35 | 36 | /** 37 | * @param resource $resource 38 | */ 39 | public function __construct($resource, bool $noColor) 40 | { 41 | $this->resource = $resource; 42 | $this->noColor = $noColor; 43 | } 44 | 45 | public function printLine(string $string): void 46 | { 47 | $this->print($string . PHP_EOL); 48 | } 49 | 50 | public function print(string $string): void 51 | { 52 | $result = fwrite($this->resource, $this->colorize($string)); 53 | 54 | if ($result === false) { 55 | throw new LogicException('Could not write to output stream.'); 56 | } 57 | } 58 | 59 | private function colorize(string $string): string 60 | { 61 | return str_replace( 62 | array_keys(self::COLORS), 63 | $this->noColor ? '' : array_values(self::COLORS), 64 | $string 65 | ); 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Result/AnalysisResult.php: -------------------------------------------------------------------------------- 1 | >> 25 | */ 26 | private $usages; 27 | 28 | /** 29 | * @var array> 30 | */ 31 | private $unknownClassErrors; 32 | 33 | /** 34 | * @var array> 35 | */ 36 | private $unknownFunctionErrors; 37 | 38 | /** 39 | * @var array>> 40 | */ 41 | private $shadowDependencyErrors = []; 42 | 43 | /** 44 | * @var array>> 45 | */ 46 | private $devDependencyInProductionErrors = []; 47 | 48 | /** 49 | * @var list 50 | */ 51 | private $prodDependencyOnlyInDevErrors; 52 | 53 | /** 54 | * @var list 55 | */ 56 | private $unusedDependencyErrors; 57 | 58 | /** 59 | * @var list 60 | */ 61 | private $unusedIgnores; 62 | 63 | /** 64 | * @param array>> $usages package => [ classname => usage[] ] 65 | * @param array> $unknownClassErrors package => usages 66 | * @param array> $unknownFunctionErrors package => usages 67 | * @param array>> $shadowDependencyErrors package => [ classname => usage[] ] 68 | * @param array>> $devDependencyInProductionErrors package => [ classname => usage[] ] 69 | * @param list $prodDependencyOnlyInDevErrors package[] 70 | * @param list $unusedDependencyErrors package[] 71 | * @param list $unusedIgnores 72 | */ 73 | public function __construct( 74 | int $scannedFilesCount, 75 | float $elapsedTime, 76 | array $usages, 77 | array $unknownClassErrors, 78 | array $unknownFunctionErrors, 79 | array $shadowDependencyErrors, 80 | array $devDependencyInProductionErrors, 81 | array $prodDependencyOnlyInDevErrors, 82 | array $unusedDependencyErrors, 83 | array $unusedIgnores 84 | ) 85 | { 86 | ksort($usages); 87 | ksort($unknownClassErrors); 88 | ksort($unknownFunctionErrors); 89 | ksort($shadowDependencyErrors); 90 | ksort($devDependencyInProductionErrors); 91 | sort($prodDependencyOnlyInDevErrors); 92 | sort($unusedDependencyErrors); 93 | 94 | $this->scannedFilesCount = $scannedFilesCount; 95 | $this->elapsedTime = $elapsedTime; 96 | $this->unknownClassErrors = $unknownClassErrors; 97 | $this->unknownFunctionErrors = $unknownFunctionErrors; 98 | 99 | foreach ($usages as $package => $classes) { 100 | ksort($classes); 101 | $this->usages[$package] = $classes; 102 | } 103 | 104 | foreach ($shadowDependencyErrors as $package => $classes) { 105 | ksort($classes); 106 | $this->shadowDependencyErrors[$package] = $classes; 107 | } 108 | 109 | foreach ($devDependencyInProductionErrors as $package => $classes) { 110 | ksort($classes); 111 | $this->devDependencyInProductionErrors[$package] = $classes; 112 | } 113 | 114 | $this->prodDependencyOnlyInDevErrors = $prodDependencyOnlyInDevErrors; 115 | $this->unusedDependencyErrors = $unusedDependencyErrors; 116 | $this->unusedIgnores = $unusedIgnores; 117 | } 118 | 119 | public function getScannedFilesCount(): int 120 | { 121 | return $this->scannedFilesCount; 122 | } 123 | 124 | public function getElapsedTime(): float 125 | { 126 | return $this->elapsedTime; 127 | } 128 | 129 | /** 130 | * @return array>> 131 | */ 132 | public function getUsages(): array 133 | { 134 | return $this->usages; 135 | } 136 | 137 | /** 138 | * @return array> 139 | */ 140 | public function getUnknownClassErrors(): array 141 | { 142 | return $this->unknownClassErrors; 143 | } 144 | 145 | /** 146 | * @return array> 147 | */ 148 | public function getUnknownFunctionErrors(): array 149 | { 150 | return $this->unknownFunctionErrors; 151 | } 152 | 153 | /** 154 | * @return array>> 155 | */ 156 | public function getShadowDependencyErrors(): array 157 | { 158 | return $this->shadowDependencyErrors; 159 | } 160 | 161 | /** 162 | * @return array>> 163 | */ 164 | public function getDevDependencyInProductionErrors(): array 165 | { 166 | return $this->devDependencyInProductionErrors; 167 | } 168 | 169 | /** 170 | * @return list 171 | */ 172 | public function getProdDependencyOnlyInDevErrors(): array 173 | { 174 | return $this->prodDependencyOnlyInDevErrors; 175 | } 176 | 177 | /** 178 | * @return list 179 | */ 180 | public function getUnusedDependencyErrors(): array 181 | { 182 | return $this->unusedDependencyErrors; 183 | } 184 | 185 | /** 186 | * @return list 187 | */ 188 | public function getUnusedIgnores(): array 189 | { 190 | return $this->unusedIgnores; 191 | } 192 | 193 | } 194 | -------------------------------------------------------------------------------- /src/Result/ConsoleFormatter.php: -------------------------------------------------------------------------------- 1 | cwd = $cwd; 39 | $this->printer = $printer; 40 | } 41 | 42 | public function format( 43 | AnalysisResult $result, 44 | CliOptions $options, 45 | Configuration $configuration 46 | ): int 47 | { 48 | if ($options->dumpUsages !== null) { 49 | return $this->printResultUsages($result, $options->dumpUsages, $options->showAllUsages === true); 50 | } 51 | 52 | return $this->printResultErrors($result, $this->getMaxUsagesShownForErrors($options), $configuration->shouldReportUnmatchedIgnoredErrors()); 53 | } 54 | 55 | private function getMaxUsagesShownForErrors(CliOptions $options): int 56 | { 57 | if ($options->showAllUsages === true) { 58 | return PHP_INT_MAX; 59 | } 60 | 61 | if ($options->verbose === true) { 62 | return self::VERBOSE_SHOWN_USAGES; 63 | } 64 | 65 | return 1; 66 | } 67 | 68 | private function printResultUsages( 69 | AnalysisResult $result, 70 | string $package, 71 | bool $showAllUsages 72 | ): int 73 | { 74 | $usagesToDump = $this->filterUsagesToDump($result->getUsages(), $package); 75 | $maxShownUsages = $showAllUsages ? PHP_INT_MAX : self::VERBOSE_SHOWN_USAGES; 76 | $totalUsages = $this->countAllUsages($usagesToDump); 77 | $symbolsWithUsage = $this->countSymbolUsages($usagesToDump); 78 | 79 | $title = $showAllUsages ? "Dumping all usages of $package" : "Dumping sample usages of $package"; 80 | 81 | $totalPlural = $totalUsages === 1 ? '' : 's'; 82 | $symbolsPlural = $symbolsWithUsage === 1 ? '' : 's'; 83 | $subtitle = "{$totalUsages} usage{$totalPlural} of {$symbolsWithUsage} symbol{$symbolsPlural} in total"; 84 | 85 | $this->printPackageBasedErrors("$title", $subtitle, $usagesToDump, $maxShownUsages); 86 | 87 | if ($this->willLimitUsages($usagesToDump, $maxShownUsages)) { 88 | $this->printLine("Use --show-all-usages to show all of them\n"); 89 | } 90 | 91 | return 1; 92 | } 93 | 94 | /** 95 | * @param array>> $usages 96 | * @return array>> 97 | */ 98 | private function filterUsagesToDump(array $usages, string $filter): array 99 | { 100 | $result = []; 101 | 102 | foreach ($usages as $package => $usagesPerSymbol) { 103 | if (fnmatch($filter, $package)) { 104 | $result[$package] = $usagesPerSymbol; 105 | } 106 | } 107 | 108 | return $result; 109 | } 110 | 111 | private function printResultErrors( 112 | AnalysisResult $result, 113 | int $maxShownUsages, 114 | bool $reportUnmatchedIgnores 115 | ): int 116 | { 117 | $hasError = false; 118 | $unusedIgnores = $result->getUnusedIgnores(); 119 | 120 | $unknownClassErrors = $result->getUnknownClassErrors(); 121 | $unknownFunctionErrors = $result->getUnknownFunctionErrors(); 122 | $shadowDependencyErrors = $result->getShadowDependencyErrors(); 123 | $devDependencyInProductionErrors = $result->getDevDependencyInProductionErrors(); 124 | $prodDependencyOnlyInDevErrors = $result->getProdDependencyOnlyInDevErrors(); 125 | $unusedDependencyErrors = $result->getUnusedDependencyErrors(); 126 | 127 | $unknownClassErrorsCount = count($unknownClassErrors); 128 | $unknownFunctionErrorsCount = count($unknownFunctionErrors); 129 | $shadowDependencyErrorsCount = count($shadowDependencyErrors); 130 | $devDependencyInProductionErrorsCount = count($devDependencyInProductionErrors); 131 | $prodDependencyOnlyInDevErrorsCount = count($prodDependencyOnlyInDevErrors); 132 | $unusedDependencyErrorsCount = count($unusedDependencyErrors); 133 | 134 | if ($unknownClassErrorsCount > 0) { 135 | $hasError = true; 136 | $classes = $this->pluralize($unknownClassErrorsCount, 'class'); 137 | $this->printSymbolBasedErrors( 138 | "Found $unknownClassErrorsCount unknown $classes!", 139 | 'unable to autoload those, so we cannot check them', 140 | $unknownClassErrors, 141 | $maxShownUsages 142 | ); 143 | } 144 | 145 | if ($unknownFunctionErrorsCount > 0) { 146 | $hasError = true; 147 | $functions = $this->pluralize($unknownFunctionErrorsCount, 'function'); 148 | $this->printSymbolBasedErrors( 149 | "Found $unknownFunctionErrorsCount unknown $functions!", 150 | 'those are not declared, so we cannot check them', 151 | $unknownFunctionErrors, 152 | $maxShownUsages 153 | ); 154 | } 155 | 156 | if ($shadowDependencyErrorsCount > 0) { 157 | $hasError = true; 158 | $dependencies = $this->pluralize($shadowDependencyErrorsCount, 'dependency'); 159 | $this->printPackageBasedErrors( 160 | "Found $shadowDependencyErrorsCount shadow $dependencies!", 161 | 'those are used, but not listed as dependency in composer.json', 162 | $shadowDependencyErrors, 163 | $maxShownUsages 164 | ); 165 | } 166 | 167 | if ($devDependencyInProductionErrorsCount > 0) { 168 | $hasError = true; 169 | $dependencies = $this->pluralize($devDependencyInProductionErrorsCount, 'dependency'); 170 | $this->printPackageBasedErrors( 171 | "Found $devDependencyInProductionErrorsCount dev $dependencies in production code!", 172 | 'those should probably be moved to "require" section in composer.json', 173 | $devDependencyInProductionErrors, 174 | $maxShownUsages 175 | ); 176 | } 177 | 178 | if ($prodDependencyOnlyInDevErrorsCount > 0) { 179 | $hasError = true; 180 | $dependencies = $this->pluralize($prodDependencyOnlyInDevErrorsCount, 'dependency'); 181 | $this->printPackageBasedErrors( 182 | "Found $prodDependencyOnlyInDevErrorsCount prod $dependencies used only in dev paths!", 183 | 'those should probably be moved to "require-dev" section in composer.json', 184 | array_fill_keys($prodDependencyOnlyInDevErrors, []), 185 | $maxShownUsages 186 | ); 187 | } 188 | 189 | if ($unusedDependencyErrorsCount > 0) { 190 | $hasError = true; 191 | $dependencies = $this->pluralize($unusedDependencyErrorsCount, 'dependency'); 192 | $this->printPackageBasedErrors( 193 | "Found $unusedDependencyErrorsCount unused $dependencies!", 194 | 'those are listed in composer.json, but no usage was found in scanned paths', 195 | array_fill_keys($unusedDependencyErrors, []), 196 | $maxShownUsages 197 | ); 198 | } 199 | 200 | if ($unusedIgnores !== [] && $reportUnmatchedIgnores) { 201 | $hasError = true; 202 | $this->printLine(''); 203 | $this->printLine('Some ignored issues never occurred:'); 204 | $this->printUnusedIgnores($unusedIgnores); 205 | } 206 | 207 | if (!$hasError) { 208 | $this->printLine(''); 209 | $this->printLine('No composer issues found'); 210 | } 211 | 212 | $this->printRunSummary($result); 213 | 214 | return $hasError ? 1 : 0; 215 | } 216 | 217 | /** 218 | * @param array> $errors 219 | */ 220 | private function printSymbolBasedErrors(string $title, string $subtitle, array $errors, int $maxShownUsages): void 221 | { 222 | $this->printHeader($title, $subtitle); 223 | 224 | foreach ($errors as $symbol => $usages) { 225 | $this->printLine(" • {$symbol}"); 226 | 227 | if ($maxShownUsages > 1) { 228 | foreach ($usages as $index => $usage) { 229 | $this->printLine(" {$this->relativizeUsage($usage)}"); 230 | 231 | if ($index === $maxShownUsages - 1) { 232 | $restUsagesCount = count($usages) - $index - 1; 233 | 234 | if ($restUsagesCount > 0) { 235 | $this->printLine(" + {$restUsagesCount} more"); 236 | break; 237 | } 238 | } 239 | } 240 | 241 | $this->printLine(''); 242 | 243 | } else { 244 | $firstUsage = $usages[0]; 245 | $restUsagesCount = count($usages) - 1; 246 | $rest = $restUsagesCount > 0 ? " (+ {$restUsagesCount} more)" : ''; 247 | $this->printLine(" in {$this->relativizeUsage($firstUsage)}$rest" . PHP_EOL); 248 | } 249 | } 250 | 251 | $this->printLine(''); 252 | } 253 | 254 | /** 255 | * @param array>> $errors 256 | */ 257 | private function printPackageBasedErrors(string $title, string $subtitle, array $errors, int $maxShownUsages): void 258 | { 259 | $this->printHeader($title, $subtitle); 260 | 261 | foreach ($errors as $packageName => $usagesPerSymbol) { 262 | $this->printLine(" • {$packageName}"); 263 | 264 | $this->printUsages($usagesPerSymbol, $maxShownUsages); 265 | } 266 | 267 | $this->printLine(''); 268 | } 269 | 270 | /** 271 | * @param array> $usagesPerSymbol 272 | */ 273 | private function printUsages(array $usagesPerSymbol, int $maxShownUsages): void 274 | { 275 | if ($maxShownUsages === 1) { 276 | $countOfAllUsages = array_reduce( 277 | $usagesPerSymbol, 278 | static function (int $carry, array $usages): int { 279 | return $carry + count($usages); 280 | }, 281 | 0 282 | ); 283 | 284 | foreach ($usagesPerSymbol as $symbol => $usages) { 285 | $firstUsage = $usages[0]; 286 | $restUsagesCount = $countOfAllUsages - 1; 287 | $rest = $countOfAllUsages > 1 ? " (+ {$restUsagesCount} more)" : ''; 288 | $this->printLine(" e.g. {$symbol} in {$this->relativizeUsage($firstUsage)}$rest" . PHP_EOL); 289 | break; 290 | } 291 | } else { 292 | $symbolsPrinted = 0; 293 | 294 | foreach ($usagesPerSymbol as $symbol => $usages) { 295 | $symbolsPrinted++; 296 | $this->printLine(" {$symbol}"); 297 | 298 | foreach ($usages as $index => $usage) { 299 | $this->printLine(" {$this->relativizeUsage($usage)}"); 300 | 301 | if ($index === $maxShownUsages - 1) { 302 | $restUsagesCount = count($usages) - $index - 1; 303 | 304 | if ($restUsagesCount > 0) { 305 | $this->printLine(" + {$restUsagesCount} more"); 306 | break; 307 | } 308 | } 309 | } 310 | 311 | if ($symbolsPrinted === $maxShownUsages) { 312 | $restSymbolsCount = count($usagesPerSymbol) - $symbolsPrinted; 313 | 314 | if ($restSymbolsCount > 0) { 315 | $this->printLine(" + {$restSymbolsCount} more symbol" . ($restSymbolsCount > 1 ? 's' : '')); 316 | break; 317 | } 318 | } 319 | } 320 | } 321 | } 322 | 323 | private function printHeader(string $title, string $subtitle): void 324 | { 325 | $this->printLine(''); 326 | $this->printLine("$title"); 327 | $this->printLine("($subtitle)" . PHP_EOL); 328 | } 329 | 330 | private function printLine(string $string): void 331 | { 332 | $this->printer->printLine($string); 333 | } 334 | 335 | private function relativizeUsage(SymbolUsage $usage): string 336 | { 337 | return "{$this->relativizePath($usage->getFilepath())}:{$usage->getLineNumber()}"; 338 | } 339 | 340 | private function relativizePath(string $path): string 341 | { 342 | if (strpos($path, $this->cwd) === 0) { 343 | return (string) substr($path, strlen($this->cwd) + 1); 344 | } 345 | 346 | return $path; 347 | } 348 | 349 | /** 350 | * @param list $unusedIgnores 351 | */ 352 | private function printUnusedIgnores(array $unusedIgnores): void 353 | { 354 | foreach ($unusedIgnores as $unusedIgnore) { 355 | if ($unusedIgnore instanceof UnusedSymbolIgnore) { 356 | $this->printSymbolBasedUnusedIgnore($unusedIgnore); 357 | } else { 358 | $this->printErrorBasedUnusedIgnore($unusedIgnore); 359 | } 360 | } 361 | 362 | $this->printLine(''); 363 | } 364 | 365 | private function printSymbolBasedUnusedIgnore(UnusedSymbolIgnore $unusedIgnore): void 366 | { 367 | $kind = $unusedIgnore->getSymbolKind() === SymbolKind::CLASSLIKE ? 'class' : 'function'; 368 | $regex = $unusedIgnore->isRegex() ? ' regex' : ''; 369 | $this->printLine(" • Unknown {$kind}{$regex} '{$unusedIgnore->getUnknownSymbol()}' was ignored, but it was never applied."); 370 | } 371 | 372 | private function printErrorBasedUnusedIgnore(UnusedErrorIgnore $unusedIgnore): void 373 | { 374 | $package = $unusedIgnore->getPackage(); 375 | $path = $unusedIgnore->getPath(); 376 | 377 | if ($package === null && $path === null) { 378 | $this->printLine(" • Error '{$unusedIgnore->getErrorType()}' was globally ignored, but it was never applied."); 379 | } 380 | 381 | if ($package !== null && $path === null) { 382 | $this->printLine(" • Error '{$unusedIgnore->getErrorType()}' was ignored for package '{$package}', but it was never applied."); 383 | } 384 | 385 | if ($package === null && $path !== null) { 386 | $this->printLine(" • Error '{$unusedIgnore->getErrorType()}' was ignored for path '{$this->relativizePath($path)}', but it was never applied."); 387 | } 388 | 389 | if ($package !== null && $path !== null) { 390 | $this->printLine(" • Error '{$unusedIgnore->getErrorType()}' was ignored for package '{$package}' and path '{$this->relativizePath($path)}', but it was never applied."); 391 | } 392 | } 393 | 394 | private function printRunSummary(AnalysisResult $result): void 395 | { 396 | $elapsed = round($result->getElapsedTime(), 3); 397 | $this->printLine("(scanned {$result->getScannedFilesCount()} files in {$elapsed} s)" . PHP_EOL); 398 | } 399 | 400 | /** 401 | * @param array>> $usages 402 | */ 403 | private function countAllUsages(array $usages): int 404 | { 405 | $total = 0; 406 | 407 | foreach ($usages as $usagesPerSymbol) { 408 | foreach ($usagesPerSymbol as $symbolUsages) { 409 | $total += count($symbolUsages); 410 | } 411 | } 412 | 413 | return $total; 414 | } 415 | 416 | /** 417 | * @param array>> $usages 418 | */ 419 | private function countSymbolUsages(array $usages): int 420 | { 421 | $total = 0; 422 | 423 | foreach ($usages as $usagesPerSymbol) { 424 | $total += count($usagesPerSymbol); 425 | } 426 | 427 | return $total; 428 | } 429 | 430 | /** 431 | * @param array>> $usages 432 | */ 433 | private function willLimitUsages(array $usages, int $limit): bool 434 | { 435 | foreach ($usages as $usagesPerSymbol) { 436 | if (count($usagesPerSymbol) > $limit) { 437 | return true; 438 | } 439 | 440 | foreach ($usagesPerSymbol as $symbolUsages) { 441 | if (count($symbolUsages) > $limit) { 442 | return true; 443 | } 444 | } 445 | } 446 | 447 | return false; 448 | } 449 | 450 | private function pluralize(int $count, string $singular): string 451 | { 452 | if ($count === 1) { 453 | return $singular; 454 | } 455 | 456 | if (substr($singular, -1) === 's' || substr($singular, -1) === 'x' || substr($singular, -2) === 'sh' || substr($singular, -2) === 'ch') { 457 | return $singular . 'es'; 458 | } 459 | 460 | if (substr($singular, -1) === 'y' && !in_array($singular[strlen($singular) - 2], ['a', 'e', 'i', 'o', 'u'], true)) { 461 | return substr($singular, 0, -1) . 'ies'; 462 | } 463 | 464 | return $singular . 's'; 465 | } 466 | 467 | } 468 | -------------------------------------------------------------------------------- /src/Result/JunitFormatter.php: -------------------------------------------------------------------------------- 1 | cwd = $cwd; 42 | $this->printer = $printer; 43 | } 44 | 45 | public function format( 46 | AnalysisResult $result, 47 | CliOptions $options, 48 | Configuration $configuration 49 | ): int 50 | { 51 | $xml = ''; 52 | $xml .= ''; 53 | 54 | $hasError = false; 55 | $unusedIgnores = $result->getUnusedIgnores(); 56 | 57 | $unknownClassErrors = $result->getUnknownClassErrors(); 58 | $unknownFunctionErrors = $result->getUnknownFunctionErrors(); 59 | $shadowDependencyErrors = $result->getShadowDependencyErrors(); 60 | $devDependencyInProductionErrors = $result->getDevDependencyInProductionErrors(); 61 | $prodDependencyOnlyInDevErrors = $result->getProdDependencyOnlyInDevErrors(); 62 | $unusedDependencyErrors = $result->getUnusedDependencyErrors(); 63 | 64 | $maxShownUsages = $this->getMaxUsagesShownForErrors($options); 65 | 66 | if (count($unknownClassErrors) > 0) { 67 | $hasError = true; 68 | $xml .= $this->createSymbolBasedTestSuite( 69 | 'unknown classes', 70 | $unknownClassErrors, 71 | $maxShownUsages 72 | ); 73 | } 74 | 75 | if (count($unknownFunctionErrors) > 0) { 76 | $hasError = true; 77 | $xml .= $this->createSymbolBasedTestSuite( 78 | 'unknown functions', 79 | $unknownFunctionErrors, 80 | $maxShownUsages 81 | ); 82 | } 83 | 84 | if (count($shadowDependencyErrors) > 0) { 85 | $hasError = true; 86 | $xml .= $this->createPackageBasedTestSuite( 87 | 'shadow dependencies', 88 | $shadowDependencyErrors, 89 | $maxShownUsages 90 | ); 91 | } 92 | 93 | if (count($devDependencyInProductionErrors) > 0) { 94 | $hasError = true; 95 | $xml .= $this->createPackageBasedTestSuite( 96 | 'dev dependencies in production code', 97 | $devDependencyInProductionErrors, 98 | $maxShownUsages 99 | ); 100 | } 101 | 102 | if (count($prodDependencyOnlyInDevErrors) > 0) { 103 | $hasError = true; 104 | $xml .= $this->createPackageBasedTestSuite( 105 | 'prod dependencies used only in dev paths', 106 | array_fill_keys($prodDependencyOnlyInDevErrors, []), 107 | $maxShownUsages 108 | ); 109 | } 110 | 111 | if (count($unusedDependencyErrors) > 0) { 112 | $hasError = true; 113 | $xml .= $this->createPackageBasedTestSuite( 114 | 'unused dependencies', 115 | array_fill_keys($unusedDependencyErrors, []), 116 | $maxShownUsages 117 | ); 118 | } 119 | 120 | if ($unusedIgnores !== [] && $configuration->shouldReportUnmatchedIgnoredErrors()) { 121 | $hasError = true; 122 | $xml .= $this->createUnusedIgnoresTestSuite($unusedIgnores); 123 | } 124 | 125 | if ($hasError) { 126 | $xml .= sprintf('', $this->getUsagesComment($maxShownUsages)); 127 | } 128 | 129 | $xml .= ''; 130 | 131 | $this->printer->print($this->prettyPrintXml($xml)); 132 | 133 | if ($hasError) { 134 | return 1; 135 | } 136 | 137 | return 0; 138 | } 139 | 140 | private function getMaxUsagesShownForErrors(CliOptions $options): int 141 | { 142 | if ($options->showAllUsages === true) { 143 | return PHP_INT_MAX; 144 | } 145 | 146 | if ($options->verbose === true) { 147 | return self::VERBOSE_SHOWN_USAGES; 148 | } 149 | 150 | return 1; 151 | } 152 | 153 | /** 154 | * @param array> $errors 155 | */ 156 | private function createSymbolBasedTestSuite(string $title, array $errors, int $maxShownUsages): string 157 | { 158 | $xml = sprintf('', $this->escape($title), count($errors)); 159 | 160 | foreach ($errors as $symbol => $usages) { 161 | $xml .= sprintf('', $this->escape($symbol)); 162 | 163 | foreach ($usages as $index => $usage) { 164 | $xml .= sprintf('%s', $this->escape($this->relativizeUsage($usage))); 165 | 166 | if ($index === $maxShownUsages - 1) { 167 | break; 168 | } 169 | } 170 | 171 | $xml .= ''; 172 | } 173 | 174 | $xml .= ''; 175 | 176 | return $xml; 177 | } 178 | 179 | /** 180 | * @param array>> $errors 181 | */ 182 | private function createPackageBasedTestSuite(string $title, array $errors, int $maxShownUsages): string 183 | { 184 | $xml = sprintf('', $this->escape($title), count($errors)); 185 | 186 | foreach ($errors as $packageName => $usagesPerClassname) { 187 | $xml .= sprintf('', $this->escape($packageName)); 188 | 189 | $printedSymbols = 0; 190 | 191 | foreach ($usagesPerClassname as $symbol => $usages) { 192 | foreach ($this->createUsages($usages, $maxShownUsages) as $usage) { 193 | $printedSymbols++; 194 | $xml .= sprintf( 195 | '%s', 196 | $symbol, 197 | $this->escape($usage) 198 | ); 199 | 200 | if ($printedSymbols === $maxShownUsages) { 201 | break 2; 202 | } 203 | } 204 | } 205 | 206 | $xml .= ''; 207 | } 208 | 209 | $xml .= ''; 210 | 211 | return $xml; 212 | } 213 | 214 | /** 215 | * @param list $usages 216 | * @return list 217 | */ 218 | private function createUsages(array $usages, int $maxShownUsages): array 219 | { 220 | $usageMessages = []; 221 | 222 | foreach ($usages as $index => $usage) { 223 | $usageMessages[] = $this->relativizeUsage($usage); 224 | 225 | if ($index === $maxShownUsages - 1) { 226 | break; 227 | } 228 | } 229 | 230 | return $usageMessages; 231 | } 232 | 233 | /** 234 | * @param list $unusedIgnores 235 | */ 236 | private function createUnusedIgnoresTestSuite(array $unusedIgnores): string 237 | { 238 | $xml = sprintf('', count($unusedIgnores)); 239 | 240 | foreach ($unusedIgnores as $unusedIgnore) { 241 | if ($unusedIgnore instanceof UnusedSymbolIgnore) { 242 | $kind = $unusedIgnore->getSymbolKind() === SymbolKind::CLASSLIKE ? 'class' : 'function'; 243 | $regex = $unusedIgnore->isRegex() ? ' regex' : ''; 244 | $message = "Unknown {$kind}{$regex} '{$unusedIgnore->getUnknownSymbol()}' was ignored, but it was never applied."; 245 | $xml .= sprintf('%s', $this->escape($unusedIgnore->getUnknownSymbol()), $this->escape($message)); 246 | } else { 247 | $package = $unusedIgnore->getPackage(); 248 | $path = $unusedIgnore->getPath(); 249 | $message = "'{$unusedIgnore->getErrorType()}'"; 250 | 251 | if ($package === null && $path === null) { 252 | $message = "'{$unusedIgnore->getErrorType()}' was globally ignored, but it was never applied."; 253 | } 254 | 255 | if ($package !== null && $path === null) { 256 | $message = "'{$unusedIgnore->getErrorType()}' was ignored for package '{$package}', but it was never applied."; 257 | } 258 | 259 | if ($package === null && $path !== null) { 260 | $message = "'{$unusedIgnore->getErrorType()}' was ignored for path '{$this->relativizePath($path)}', but it was never applied."; 261 | } 262 | 263 | if ($package !== null && $path !== null) { 264 | $message = "'{$unusedIgnore->getErrorType()}' was ignored for package '{$package}' and path '{$this->relativizePath($path)}', but it was never applied."; 265 | } 266 | 267 | $xml .= sprintf('%s', $this->escape($unusedIgnore->getErrorType()), $this->escape($message)); 268 | } 269 | } 270 | 271 | return $xml . ''; 272 | } 273 | 274 | private function relativizeUsage(SymbolUsage $usage): string 275 | { 276 | return "{$this->relativizePath($usage->getFilepath())}:{$usage->getLineNumber()}"; 277 | } 278 | 279 | private function relativizePath(string $path): string 280 | { 281 | if (strpos($path, $this->cwd) === 0) { 282 | return (string) substr($path, strlen($this->cwd) + 1); 283 | } 284 | 285 | return $path; 286 | } 287 | 288 | private function escape(string $string): string 289 | { 290 | return htmlspecialchars($string, ENT_XML1 | ENT_COMPAT, 'UTF-8'); 291 | } 292 | 293 | private function prettyPrintXml(string $inputXml): string 294 | { 295 | if (!extension_loaded('dom') || !extension_loaded('libxml')) { 296 | return $inputXml; 297 | } 298 | 299 | $dom = new DOMDocument(); 300 | $dom->preserveWhiteSpace = false; 301 | $dom->formatOutput = true; 302 | $dom->loadXML($inputXml); 303 | 304 | $outputXml = $dom->saveXML(null, LIBXML_NOEMPTYTAG); 305 | 306 | if ($outputXml === false) { 307 | return $inputXml; 308 | } 309 | 310 | return trim($outputXml); 311 | } 312 | 313 | private function getUsagesComment(int $maxShownUsages): string 314 | { 315 | if ($maxShownUsages === PHP_INT_MAX) { 316 | return 'showing all failure usages'; 317 | } 318 | 319 | if ($maxShownUsages === 1) { 320 | return 'showing only first example failure usage'; 321 | } 322 | 323 | return sprintf('showing only first %d example failure usages', $maxShownUsages); 324 | } 325 | 326 | } 327 | -------------------------------------------------------------------------------- /src/Result/ResultFormatter.php: -------------------------------------------------------------------------------- 1 | filepath = $filepath; 31 | $this->lineNumber = $lineNumber; 32 | $this->kind = $kind; 33 | } 34 | 35 | public function getFilepath(): string 36 | { 37 | return $this->filepath; 38 | } 39 | 40 | public function getLineNumber(): int 41 | { 42 | return $this->lineNumber; 43 | } 44 | 45 | /** 46 | * @return SymbolKind::* 47 | */ 48 | public function getKind(): int 49 | { 50 | return $this->kind; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/Stopwatch.php: -------------------------------------------------------------------------------- 1 | startTime !== null) { 19 | throw new LogicException('Stopwatch was already started'); 20 | } 21 | 22 | $this->startTime = microtime(true); 23 | } 24 | 25 | public function stop(): float 26 | { 27 | if ($this->startTime === null) { 28 | throw new LogicException('Stopwatch was not started'); 29 | } 30 | 31 | $elapsed = microtime(true) - $this->startTime; 32 | $this->startTime = null; 33 | return $elapsed; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/SymbolKind.php: -------------------------------------------------------------------------------- 1 | 47 | */ 48 | private $tokens; 49 | 50 | /** 51 | * @var int 52 | */ 53 | private $numTokens; 54 | 55 | /** 56 | * @var int 57 | */ 58 | private $pointer = 0; 59 | 60 | public function __construct(string $code) 61 | { 62 | $this->tokens = token_get_all($code); 63 | $this->numTokens = count($this->tokens); 64 | } 65 | 66 | /** 67 | * It does not produce any local names in current namespace 68 | * - this results in very limited functionality in files without namespace 69 | * 70 | * @param array $knownSymbols 71 | * @return array>> 72 | * @license Inspired by https://github.com/doctrine/annotations/blob/2.0.0/lib/Doctrine/Common/Annotations/TokenParser.php 73 | */ 74 | public function parseUsedSymbols( 75 | array $knownSymbols 76 | ): array 77 | { 78 | $usedSymbols = []; 79 | $useStatements = []; 80 | $useStatementKinds = []; 81 | 82 | $level = 0; // {, }, {$, ${ 83 | $squareLevel = 0; // [, ], #[ 84 | $inGlobalScope = true; 85 | $inClassLevel = null; 86 | $inAttributeSquareLevel = null; 87 | 88 | $numTokens = $this->numTokens; 89 | $tokens = $this->tokens; 90 | 91 | while ($this->pointer < $numTokens) { 92 | $token = $tokens[$this->pointer++]; 93 | 94 | if (is_array($token)) { 95 | switch ($token[0]) { 96 | case T_CLASS: 97 | case T_INTERFACE: 98 | case T_TRAIT: 99 | case PHP_VERSION_ID >= 80100 ? T_ENUM : -1: 100 | $inClassLevel = $level + 1; 101 | break; 102 | 103 | case T_USE: 104 | if ($inClassLevel === null) { 105 | foreach ($this->parseUseStatement() as $alias => [$fullSymbolName, $symbolKind]) { 106 | $useStatements[$alias] = $this->normalizeBackslash($fullSymbolName); 107 | $useStatementKinds[$alias] = $symbolKind; 108 | } 109 | } 110 | 111 | break; 112 | 113 | case PHP_VERSION_ID > 80000 ? T_ATTRIBUTE : -1: 114 | $inAttributeSquareLevel = ++$squareLevel; 115 | break; 116 | 117 | case PHP_VERSION_ID >= 80000 ? T_NAMESPACE : -1: 118 | // namespace change 119 | $inGlobalScope = false; 120 | $useStatements = []; 121 | $useStatementKinds = []; 122 | break; 123 | 124 | case PHP_VERSION_ID >= 80000 ? T_NAME_FULLY_QUALIFIED : -1: 125 | $symbolName = $this->normalizeBackslash($token[1]); 126 | $lowerSymbolName = strtolower($symbolName); 127 | $kind = $knownSymbols[$lowerSymbolName] ?? $this->getFqnSymbolKind($this->pointer - 2, $this->pointer, $inAttributeSquareLevel !== null); 128 | $usedSymbols[$kind][$symbolName][] = $token[2]; 129 | break; 130 | 131 | case PHP_VERSION_ID >= 80000 ? T_NAME_QUALIFIED : -1: 132 | [$neededAlias] = explode('\\', $token[1], 2); 133 | 134 | if (isset($useStatements[$neededAlias])) { 135 | $symbolName = $useStatements[$neededAlias] . substr($token[1], strlen($neededAlias)); 136 | } elseif ($inGlobalScope) { 137 | $symbolName = $token[1]; 138 | } else { 139 | break; 140 | } 141 | 142 | $lowerSymbolName = strtolower($symbolName); 143 | $kind = $knownSymbols[$lowerSymbolName] ?? $this->getFqnSymbolKind($this->pointer - 2, $this->pointer, $inAttributeSquareLevel !== null); 144 | $usedSymbols[$kind][$symbolName][] = $token[2]; 145 | 146 | break; 147 | 148 | case PHP_VERSION_ID >= 80000 ? T_STRING : -1: 149 | $name = $token[1]; 150 | $lowerName = strtolower($name); 151 | $pointerBeforeName = $this->pointer - 2; 152 | $pointerAfterName = $this->pointer; 153 | 154 | if (!$this->canBeSymbolName($pointerBeforeName, $pointerAfterName)) { 155 | break; 156 | } 157 | 158 | if (isset($useStatements[$name])) { 159 | $symbolName = $useStatements[$name]; 160 | $kind = $useStatementKinds[$name]; 161 | $usedSymbols[$kind][$symbolName][] = $token[2]; 162 | 163 | } elseif (isset($knownSymbols[$lowerName])) { 164 | $symbolName = $name; 165 | $kind = $knownSymbols[$lowerName]; 166 | 167 | if (!$inGlobalScope && $kind === SymbolKind::CLASSLIKE) { 168 | break; // cannot use class-like symbols in non-global scope when not imported 169 | } 170 | 171 | $usedSymbols[$kind][$symbolName][] = $token[2]; 172 | } 173 | 174 | break; 175 | 176 | case PHP_VERSION_ID < 80000 ? T_NAMESPACE : -1: 177 | $this->pointer++; 178 | $nextName = $this->parseNameForOldPhp(); 179 | 180 | if (substr($nextName, 0, 1) !== '\\') { // not a namespace-relative name, but a new namespace declaration 181 | // namespace change 182 | $inGlobalScope = false; 183 | $useStatements = []; 184 | $useStatementKinds = []; 185 | } 186 | 187 | break; 188 | 189 | case PHP_VERSION_ID < 80000 ? T_NS_SEPARATOR : -1: 190 | $pointerBeforeName = $this->pointer - 2; 191 | $symbolName = $this->normalizeBackslash($this->parseNameForOldPhp()); 192 | $lowerSymbolName = strtolower($symbolName); 193 | 194 | if ($symbolName !== '') { // e.g. \array (NS separator followed by not-a-name) 195 | $kind = $knownSymbols[$lowerSymbolName] ?? $this->getFqnSymbolKind($pointerBeforeName, $this->pointer - 1, false); 196 | $usedSymbols[$kind][$symbolName][] = $token[2]; 197 | } 198 | 199 | break; 200 | 201 | case PHP_VERSION_ID < 80000 ? T_STRING : -1: 202 | $pointerBeforeName = $this->pointer - 2; 203 | $name = $this->parseNameForOldPhp(); 204 | $lowerName = strtolower($name); 205 | $pointerAfterName = $this->pointer - 1; 206 | 207 | if (!$this->canBeSymbolName($pointerBeforeName, $pointerAfterName)) { 208 | break; 209 | } 210 | 211 | if (isset($useStatements[$name])) { // unqualified name 212 | $symbolName = $useStatements[$name]; 213 | $kind = $useStatementKinds[$name]; 214 | $usedSymbols[$kind][$symbolName][] = $token[2]; 215 | 216 | } elseif (isset($knownSymbols[$lowerName])) { 217 | $symbolName = $name; 218 | $kind = $knownSymbols[$lowerName]; 219 | 220 | if (!$inGlobalScope && $kind === SymbolKind::CLASSLIKE) { 221 | break; // cannot use class-like symbols in non-global scope when not imported 222 | } 223 | 224 | $usedSymbols[$kind][$symbolName][] = $token[2]; 225 | 226 | } else { 227 | [$neededAlias] = explode('\\', $name, 2); 228 | 229 | if (isset($useStatements[$neededAlias])) { // qualified name 230 | $symbolName = $useStatements[$neededAlias] . substr($name, strlen($neededAlias)); 231 | $kind = $this->getFqnSymbolKind($pointerBeforeName, $pointerAfterName, false); 232 | $usedSymbols[$kind][$symbolName][] = $token[2]; 233 | 234 | } elseif ($inGlobalScope && strpos($name, '\\') !== false) { 235 | $symbolName = $name; 236 | $kind = $this->getFqnSymbolKind($pointerBeforeName, $pointerAfterName, false); 237 | $usedSymbols[$kind][$symbolName][] = $token[2]; 238 | } 239 | } 240 | 241 | break; 242 | 243 | case T_CURLY_OPEN: 244 | case T_DOLLAR_OPEN_CURLY_BRACES: 245 | $level++; 246 | break; 247 | } 248 | } elseif ($token === '{') { 249 | $level++; 250 | } elseif ($token === '}') { 251 | if ($level === $inClassLevel) { 252 | $inClassLevel = null; 253 | } 254 | 255 | $level--; 256 | } elseif ($token === '[') { 257 | $squareLevel++; 258 | } elseif ($token === ']') { 259 | if ($squareLevel === $inAttributeSquareLevel) { 260 | $inAttributeSquareLevel = null; 261 | } 262 | 263 | $squareLevel--; 264 | } 265 | } 266 | 267 | return $usedSymbols; 268 | } 269 | 270 | /** 271 | * See old behaviour: https://wiki.php.net/rfc/namespaced_names_as_token 272 | */ 273 | private function parseNameForOldPhp(): string 274 | { 275 | $this->pointer--; // we already detected start token above 276 | $name = ''; 277 | 278 | while ($this->pointer < $this->numTokens) { 279 | $token = $this->tokens[$this->pointer++]; 280 | 281 | if (!is_array($token) || ($token[0] !== T_STRING && $token[0] !== T_NS_SEPARATOR)) { 282 | break; 283 | } 284 | 285 | $name .= $token[1]; 286 | } 287 | 288 | return $name; 289 | } 290 | 291 | /** 292 | * @return array 293 | */ 294 | private function parseUseStatement(): array 295 | { 296 | $groupRoot = ''; 297 | $class = ''; 298 | $alias = ''; 299 | $statements = []; 300 | $kind = SymbolKind::CLASSLIKE; 301 | $explicitAlias = false; 302 | $kindFrozen = false; 303 | 304 | while ($this->pointer < $this->numTokens) { 305 | $token = $this->tokens[$this->pointer++]; 306 | 307 | if (is_array($token)) { 308 | switch ($token[0]) { 309 | case T_STRING: 310 | $alias = $token[1]; 311 | 312 | if (!$explicitAlias) { 313 | $class .= $alias; 314 | } 315 | 316 | break; 317 | 318 | case PHP_VERSION_ID >= 80000 ? T_NAME_QUALIFIED : -1: 319 | case PHP_VERSION_ID >= 80000 ? T_NAME_FULLY_QUALIFIED : -1: 320 | $class .= $token[1]; 321 | $classSplit = explode('\\', $token[1]); 322 | $alias = $classSplit[count($classSplit) - 1]; 323 | break; 324 | 325 | case T_FUNCTION: 326 | $kind = SymbolKind::FUNCTION; 327 | break; 328 | 329 | case T_CONST: 330 | $kind = SymbolKind::CONSTANT; 331 | break; 332 | 333 | case T_NS_SEPARATOR: 334 | $class .= '\\'; 335 | $alias = ''; 336 | break; 337 | 338 | case T_AS: 339 | $explicitAlias = true; 340 | $alias = ''; 341 | break; 342 | 343 | case T_WHITESPACE: 344 | case T_COMMENT: 345 | case T_DOC_COMMENT: 346 | break; 347 | 348 | default: 349 | break 2; 350 | } 351 | } elseif ($token === ',') { 352 | $statements[$alias] = [$groupRoot . $class, $kind]; 353 | 354 | if (!$kindFrozen) { 355 | $kind = SymbolKind::CLASSLIKE; 356 | } 357 | 358 | $class = ''; 359 | $alias = ''; 360 | $explicitAlias = false; 361 | } elseif ($token === ';') { 362 | $statements[$alias] = [$groupRoot . $class, $kind]; 363 | 364 | break; 365 | } elseif ($token === '{') { 366 | $kindFrozen = ($kind === SymbolKind::FUNCTION || $kind === SymbolKind::CONSTANT); 367 | $groupRoot = $class; 368 | $class = ''; 369 | } elseif ($token === '}') { 370 | continue; 371 | } else { 372 | break; 373 | } 374 | } 375 | 376 | return $statements; 377 | } 378 | 379 | private function normalizeBackslash(string $class): string 380 | { 381 | return ltrim($class, '\\'); 382 | } 383 | 384 | /** 385 | * @return SymbolKind::CLASSLIKE|SymbolKind::FUNCTION 386 | */ 387 | private function getFqnSymbolKind( 388 | int $pointerBeforeName, 389 | int $pointerAfterName, 390 | bool $inAttribute 391 | ): int 392 | { 393 | if ($inAttribute) { 394 | return SymbolKind::CLASSLIKE; 395 | } 396 | 397 | $tokenBeforeName = $this->getTokenBefore($pointerBeforeName); 398 | $tokenAfterName = $this->getTokenAfter($pointerAfterName); 399 | 400 | if ( 401 | $tokenAfterName === '(' 402 | && $tokenBeforeName[0] !== T_NEW // eliminate new \ClassName( 403 | ) { 404 | return SymbolKind::FUNCTION; 405 | } 406 | 407 | return SymbolKind::CLASSLIKE; // constant may fall here, this is eliminated later 408 | } 409 | 410 | private function canBeSymbolName( 411 | int $pointerBeforeName, 412 | int $pointerAfterName 413 | ): bool 414 | { 415 | $tokenBeforeName = $this->getTokenBefore($pointerBeforeName); 416 | $tokenAfterName = $this->getTokenAfter($pointerAfterName); 417 | 418 | if ( 419 | $tokenBeforeName[0] === T_DOUBLE_COLON 420 | || $tokenBeforeName[0] === T_INSTEADOF 421 | || $tokenBeforeName[0] === T_AS 422 | || $tokenBeforeName[0] === T_FUNCTION 423 | || $tokenBeforeName[0] === T_OBJECT_OPERATOR 424 | || $tokenBeforeName[0] === T_NAMESPACE 425 | || $tokenBeforeName[0] === (PHP_VERSION_ID > 80000 ? T_NULLSAFE_OBJECT_OPERATOR : -1) 426 | || $tokenAfterName[0] === T_INSTEADOF 427 | || $tokenAfterName[0] === T_AS 428 | || $tokenAfterName === ':' 429 | || $tokenAfterName === '=' 430 | ) { 431 | return false; 432 | } 433 | 434 | return true; 435 | } 436 | 437 | /** 438 | * @return array{int, string}|string 439 | */ 440 | private function getTokenBefore(int $pointer) 441 | { 442 | do { 443 | $token = $this->tokens[$pointer]; 444 | 445 | if (!is_array($token)) { 446 | break; 447 | } 448 | 449 | if ($token[0] === T_WHITESPACE || $token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) { 450 | $pointer--; 451 | continue; 452 | } 453 | 454 | break; 455 | } while ($pointer >= 0); 456 | 457 | return $token; 458 | } 459 | 460 | /** 461 | * @return array{int, string}|string 462 | */ 463 | private function getTokenAfter(int $pointer) 464 | { 465 | do { 466 | $token = $this->tokens[$pointer]; 467 | 468 | if (!is_array($token)) { 469 | break; 470 | } 471 | 472 | if ($token[0] === T_WHITESPACE || $token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) { 473 | $pointer++; 474 | continue; 475 | } 476 | 477 | break; 478 | } while ($pointer < $this->numTokens); 479 | 480 | return $token; 481 | } 482 | 483 | } 484 | --------------------------------------------------------------------------------