Skip to content

Commit af14c65

Browse files
exclude-from-classmap support (#140)
1 parent 98bceac commit af14c65

File tree

8 files changed

+202
-2
lines changed

8 files changed

+202
-2
lines changed

src/ComposerJson.php

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717
use function json_decode;
1818
use function json_last_error;
1919
use function json_last_error_msg;
20+
use function preg_quote;
21+
use function preg_replace;
22+
use function preg_replace_callback;
23+
use function realpath;
24+
use function str_replace;
2025
use function strpos;
26+
use function strtr;
27+
use function trim;
2128
use const ARRAY_FILTER_USE_KEY;
2229
use const JSON_ERROR_NONE;
2330

@@ -46,6 +53,14 @@ class ComposerJson
4653
*/
4754
public $autoloadPaths;
4855

56+
/**
57+
* Regex => isDev
58+
*
59+
* @readonly
60+
* @var array<string, bool>
61+
*/
62+
public $autoloadExcludeRegexes;
63+
4964
/**
5065
* @throws InvalidPathException
5166
* @throws InvalidConfigException
@@ -72,6 +87,10 @@ public function __construct(
7287
$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['files'] ?? [], true),
7388
$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['classmap'] ?? [], true)
7489
);
90+
$this->autoloadExcludeRegexes = array_merge(
91+
$this->extractAutoloadExcludeRegexes($basePath, $composerJsonData['autoload']['exclude-from-classmap'] ?? [], false),
92+
$this->extractAutoloadExcludeRegexes($basePath, $composerJsonData['autoload-dev']['exclude-from-classmap'] ?? [], true)
93+
);
7594

7695
$filterPackages = static function (string $package): bool {
7796
return strpos($package, '/') !== false;
@@ -125,6 +144,76 @@ private function extractAutoloadPaths(string $basePath, array $autoload, bool $i
125144
return $result;
126145
}
127146

147+
/**
148+
* @param array<string> $exclude
149+
* @return array<string, bool>
150+
* @throws InvalidPathException
151+
*/
152+
private function extractAutoloadExcludeRegexes(string $basePath, array $exclude, bool $isDev): array
153+
{
154+
$regexes = [];
155+
156+
foreach ($exclude as $path) {
157+
$regexes[$this->resolveAutoloadExclude($basePath, $path)] = $isDev;
158+
}
159+
160+
return $regexes;
161+
}
162+
163+
/**
164+
* Implementation copied from composer/composer.
165+
*
166+
* @license MIT https://github.com/composer/composer/blob/ee2c9afdc86ef3f06a4bd49b1fea7d1d636afc92/LICENSE
167+
* @see https://getcomposer.org/doc/04-schema.md#exclude-files-from-classmaps
168+
* @see https://github.com/composer/composer/blob/ee2c9afdc86ef3f06a4bd49b1fea7d1d636afc92/src/Composer/Autoload/AutoloadGenerator.php#L1256-L1286
169+
* @throws InvalidPathException
170+
*/
171+
private function resolveAutoloadExclude(string $basePath, string $pathPattern): string
172+
{
173+
// first escape user input
174+
$path = preg_replace('{/+}', '/', preg_quote(trim(strtr($pathPattern, '\\', '/'), '/')));
175+
176+
if ($path === null) {
177+
throw new InvalidPathException("Failure while globbing $pathPattern path.");
178+
}
179+
180+
// add support for wildcards * and **
181+
$path = strtr($path, ['\\*\\*' => '.+?', '\\*' => '[^/]+?']);
182+
183+
// add support for up-level relative paths
184+
$updir = null;
185+
$path = preg_replace_callback(
186+
'{^((?:(?:\\\\\\.){1,2}+/)+)}',
187+
static function ($matches) use (&$updir): string {
188+
if (isset($matches[1]) && $matches[1] !== '') {
189+
// undo preg_quote for the matched string
190+
$updir = str_replace('\\.', '.', $matches[1]);
191+
}
192+
193+
return '';
194+
},
195+
$path
196+
// note: composer also uses `PREG_UNMATCHED_AS_NULL` but the `$flags` arg supported since PHP v7.4
197+
);
198+
199+
if ($path === null) {
200+
throw new InvalidPathException("Failure while globbing $pathPattern path.");
201+
}
202+
203+
$resolvedPath = realpath($basePath . '/' . $updir);
204+
205+
if ($resolvedPath === false) {
206+
throw new InvalidPathException("Failure while globbing $pathPattern path.");
207+
}
208+
209+
// Finalize
210+
$delimiter = '#';
211+
$pattern = '^' . preg_quote(strtr($resolvedPath, '\\', '/'), $delimiter) . '/' . $path . '($|/)';
212+
$pattern = $delimiter . $pattern . $delimiter;
213+
214+
return $pattern;
215+
}
216+
128217
/**
129218
* @return array{
130219
* require?: array<string, string>,
@@ -136,13 +225,15 @@ private function extractAutoloadPaths(string $basePath, array $autoload, bool $i
136225
* psr-0?: array<string, string|string[]>,
137226
* psr-4?: array<string, string|string[]>,
138227
* files?: string[],
139-
* classmap?: string[]
228+
* classmap?: string[],
229+
* exclude-from-classmap?: string[]
140230
* },
141231
* autoload-dev?: array{
142232
* psr-0?: array<string, string|string[]>,
143233
* psr-4?: array<string, string|string[]>,
144234
* files?: string[],
145-
* classmap?: string[]
235+
* classmap?: string[],
236+
* exclude-from-classmap?: string[]
146237
* }
147238
* }
148239
* @throws InvalidPathException

src/Config/Configuration.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ class Configuration
5454
*/
5555
private $pathsToExclude = [];
5656

57+
/**
58+
* @var list<string>
59+
*/
60+
private $pathRegexesToExclude = [];
61+
5762
/**
5863
* @var array<string, list<ErrorType::*>>
5964
*/
@@ -203,6 +208,34 @@ public function addPathToExclude(string $path): self
203208
return $this;
204209
}
205210

211+
/**
212+
* @param list<string> $regexes
213+
* @return $this
214+
* @throws InvalidConfigException
215+
*/
216+
public function addPathRegexesToExclude(array $regexes): self
217+
{
218+
foreach ($regexes as $regex) {
219+
$this->addPathRegexToExclude($regex);
220+
}
221+
222+
return $this;
223+
}
224+
225+
/**
226+
* @return $this
227+
* @throws InvalidConfigException
228+
*/
229+
public function addPathRegexToExclude(string $regex): self
230+
{
231+
if (@preg_match($regex, '') === false) {
232+
throw new InvalidConfigException("Invalid regex '$regex'");
233+
}
234+
235+
$this->pathRegexesToExclude[] = $regex;
236+
return $this;
237+
}
238+
206239
/**
207240
* @param list<ErrorType::*> $errorTypes
208241
* @return $this
@@ -429,6 +462,12 @@ public function isExcludedFilepath(string $filePath): bool
429462
}
430463
}
431464

465+
foreach ($this->pathRegexesToExclude as $pathRegexToExclude) {
466+
if ((bool) preg_match($pathRegexToExclude, $filePath)) {
467+
return true;
468+
}
469+
}
470+
432471
return false;
433472
}
434473

src/Initializer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ public function initConfiguration(
145145
foreach ($composerJson->autoloadPaths as $absolutePath => $isDevPath) {
146146
$config->addPathToScan($absolutePath, $isDevPath);
147147
}
148+
149+
foreach ($composerJson->autoloadExcludeRegexes as $excludeRegex => $isDevRegex) {
150+
$config->addPathRegexToExclude($excludeRegex);
151+
}
148152
} catch (InvalidPathException $e) {
149153
throw new InvalidConfigException('Error while processing composer.json autoload path: ' . $e->getMessage(), $e);
150154
}

tests/AnalyserTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,22 @@ static function (Configuration $config) use ($variousUsagesPath, $unknownClasses
212212
]),
213213
];
214214

215+
yield 'scan dir, exclude regex' => [
216+
static function (Configuration $config) use ($variousUsagesPath): void {
217+
$config->addPathToScan(dirname($variousUsagesPath), false);
218+
$config->addPathRegexToExclude('/unknown/');
219+
$config->addPathRegexesToExclude([
220+
'/^not match$/',
221+
]);
222+
},
223+
$this->createAnalysisResult(1, [
224+
ErrorType::UNKNOWN_CLASS => ['Unknown\Clazz' => [new SymbolUsage($variousUsagesPath, 11, SymbolKind::CLASSLIKE)]],
225+
ErrorType::DEV_DEPENDENCY_IN_PROD => ['dev/package' => ['Dev\Package\Clazz' => [new SymbolUsage($variousUsagesPath, 16, SymbolKind::CLASSLIKE)]]],
226+
ErrorType::SHADOW_DEPENDENCY => ['shadow/package' => ['Shadow\Package\Clazz' => [new SymbolUsage($variousUsagesPath, 24, SymbolKind::CLASSLIKE)]]],
227+
ErrorType::UNUSED_DEPENDENCY => ['regular/dead'],
228+
]),
229+
];
230+
215231
yield 'ignore on path' => [
216232
static function (Configuration $config) use ($variousUsagesPath, $unknownClassesPath): void {
217233
$config->addPathToScan(dirname($variousUsagesPath), false);

tests/ComposerJsonTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
use function file_put_contents;
99
use function json_encode;
1010
use function mkdir;
11+
use function preg_quote;
1112
use function realpath;
13+
use function str_replace;
1214
use function strtr;
1315
use function sys_get_temp_dir;
1416
use const DIRECTORY_SEPARATOR;
@@ -42,6 +44,20 @@ public function testComposerJson(): void
4244
],
4345
$composerJson->autoloadPaths
4446
);
47+
48+
$replacements = [
49+
'__DIR__' => preg_quote(str_replace(DIRECTORY_SEPARATOR, '/', __DIR__), '#'),
50+
];
51+
52+
self::assertSame(
53+
[
54+
strtr('#^__DIR__/data/not\-autoloaded/composer/dir2/[^/]+?\.php($|/)#', $replacements) => false,
55+
strtr('#^__DIR__/data/not\-autoloaded/composer/dir3/.+?/file1\.php($|/)#', $replacements) => false,
56+
strtr('#^__DIR__/data/not\-autoloaded/composer/tests($|/)#', $replacements) => false,
57+
strtr('#^__DIR__/data/not\-autoloaded/composer/dir1/file1\.php($|/)#', $replacements) => true,
58+
],
59+
$composerJson->autoloadExcludeRegexes
60+
);
4561
}
4662

4763
public function testAbsoluteCustomVendorDir(): void

tests/ConfigurationTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,13 @@ static function (Configuration $configuration): void {
241241
},
242242
"Invalid regex '~[~'",
243243
];
244+
245+
yield 'invalid regex to exclude' => [
246+
static function (Configuration $configuration): void {
247+
$configuration->addPathRegexToExclude('~[~');
248+
},
249+
"Invalid regex '~[~'",
250+
];
244251
}
245252

246253
/**
@@ -256,6 +263,18 @@ public function testInvalidPath(callable $configure, string $exceptionMessage):
256263
$configure($configuration);
257264
}
258265

266+
public function testIsExcludedFilepath(): void
267+
{
268+
$configuration = new Configuration();
269+
$configuration->addPathToExclude(__FILE__);
270+
$configuration->addPathRegexToExclude('{^/excluded$}');
271+
272+
self::assertFalse($configuration->isExcludedFilepath(__DIR__));
273+
self::assertTrue($configuration->isExcludedFilepath(__FILE__));
274+
self::assertTrue($configuration->isExcludedFilepath('/excluded'));
275+
self::assertFalse($configuration->isExcludedFilepath('/excluded/not/match'));
276+
}
277+
259278
/**
260279
* @return iterable<string, array{callable(Configuration): void, string}>
261280
*/

tests/InitializerTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function testInitConfiguration(): void
2222

2323
$composerJson = $this->createMock(ComposerJson::class);
2424
$composerJson->autoloadPaths = [__DIR__ => false]; // @phpstan-ignore-line ignore readonly
25+
$composerJson->autoloadExcludeRegexes = ['{/excluded}' => false, '{/excluded-dev}' => false]; // @phpstan-ignore-line ignore readonly
2526

2627
$options = new CliOptions();
2728
$options->ignoreUnknownClasses = true;
@@ -32,6 +33,9 @@ public function testInitConfiguration(): void
3233
self::assertEquals([new PathToScan(__DIR__, false)], $config->getPathsToScan());
3334
self::assertTrue($config->getIgnoreList()->shouldIgnoreUnknownClass('Any', 'any'));
3435
self::assertFalse($config->getIgnoreList()->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, null));
36+
self::assertFalse($config->isExcludedFilepath(__DIR__ . '/not-excluded.php'));
37+
self::assertTrue($config->isExcludedFilepath(__DIR__ . '/excluded.php'));
38+
self::assertTrue($config->isExcludedFilepath(__DIR__ . '/excluded-dev.php'));
3539
}
3640

3741
public function testInitComposerJson(): void

tests/data/not-autoloaded/composer/sample.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@
1212
],
1313
"files": [
1414
"dir2/file1.php"
15+
],
16+
"exclude-from-classmap": [
17+
"/dir2///*.php",
18+
"/dir3/**/file1.php",
19+
"/tests/",
20+
"../composer///dir1/file1.php"
21+
]
22+
},
23+
"autoload-dev": {
24+
"exclude-from-classmap": [
25+
"/dir1/file1.php"
1526
]
1627
},
1728
"config": {

0 commit comments

Comments
 (0)