├── 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 |
--------------------------------------------------------------------------------