Skip to content

Commit de84a32

Browse files
authored
Remove use statements preconditions (#4)
1 parent 9070c9d commit de84a32

14 files changed

+361
-142
lines changed

README.md

+4-27
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,6 @@ See comparison with existing projects:
1414
composer require --dev shipmonk/composer-dependency-analyser
1515
```
1616

17-
## Preconditions:
18-
- To achieve such performance, your project needs follow some `use statements` limitations
19-
- Disallowed approaches:
20-
- Partial use statements, `use Doctrine\ORM\Mapping as ORM;` + `#[ORM\Entity]`
21-
- Multiple use statements `use Foo, Bar;`
22-
- Bracketed use statements `use Foo\{Bar, Baz};`
23-
- Bracketed namespaces `namespace Foo { ... }`
24-
25-
All this can be ensured by [slevomat/coding-standard](https://github.com/slevomat/coding-standard) with following config:
26-
27-
```xml
28-
<?xml version="1.0"?>
29-
<ruleset>
30-
<rule ref="SlevomatCodingStandard.Namespaces.DisallowGroupUse"/>
31-
<rule ref="SlevomatCodingStandard.Namespaces.NamespaceDeclaration"/>
32-
<rule ref="SlevomatCodingStandard.Namespaces.MultipleUsesPerLine"/>
33-
<rule ref="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly">
34-
<properties>
35-
<property name="allowPartialUses" value="false"/>
36-
</properties>
37-
</rule>
38-
</ruleset>
39-
```
40-
41-
Basically, this tool extracts used symbols just from use statements and compare those with your composer dependencies.
42-
4317
## Usage:
4418

4519
```sh
@@ -69,7 +43,10 @@ Every used class should be listed in your `require` (or `require-dev`) section o
6943
## Future scope:
7044
- Detecting dead dependencies
7145
- Detecting dev dependencies used in production code
72-
- Lowering number of preconditions
46+
47+
## Limitations:
48+
- Files without namespace has limited support
49+
- Only classes with use statements and FQNs are detected
7350

7451
## Contributing:
7552
- Check your code by `composer check`

src/ComposerDependencyAnalyser.php

+22-9
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
use ShipMonk\Composer\Error\SymbolError;
1414
use UnexpectedValueException;
1515
use function class_exists;
16+
use function defined;
1617
use function explode;
1718
use function file_get_contents;
19+
use function function_exists;
1820
use function interface_exists;
1921
use function is_file;
2022
use function ksort;
@@ -85,17 +87,20 @@ public function scan(array $scanPaths): array
8587

8688
foreach ($scanPaths as $scanPath) {
8789
foreach ($this->listPhpFilesIn($scanPath) as $filePath) {
88-
foreach ($this->getUsesInFile($filePath) as $usedClass) {
89-
if ($this->isInternalClass($usedClass)) {
90+
foreach ($this->getUsedSymbolsInFile($filePath) as $usedSymbol) {
91+
if ($this->isInternalClass($usedSymbol)) {
9092
continue;
9193
}
9294

93-
if (!isset($this->optimizedClassmap[$usedClass])) {
94-
$errors[$usedClass] = new ClassmapEntryMissingError($usedClass, $filePath);
95+
if (!isset($this->optimizedClassmap[$usedSymbol])) {
96+
if (!$this->isConstOrFunction($usedSymbol)) {
97+
$errors[$usedSymbol] = new ClassmapEntryMissingError($usedSymbol, $filePath);
98+
}
99+
95100
continue;
96101
}
97102

98-
$classmapPath = $this->optimizedClassmap[$usedClass];
103+
$classmapPath = $this->optimizedClassmap[$usedSymbol];
99104

100105
if (!$this->isVendorPath($classmapPath)) {
101106
continue; // local class
@@ -104,7 +109,7 @@ public function scan(array $scanPaths): array
104109
$packageName = $this->getPackageNameFromVendorPath($classmapPath);
105110

106111
if ($this->isShadowDependency($packageName)) {
107-
$errors[$usedClass] = new ShadowDependencyError($usedClass, $packageName, $filePath);
112+
$errors[$usedSymbol] = new ShadowDependencyError($usedSymbol, $packageName, $filePath);
108113
}
109114
}
110115
}
@@ -130,16 +135,15 @@ private function getPackageNameFromVendorPath(string $realPath): string
130135
/**
131136
* @return list<string>
132137
*/
133-
private function getUsesInFile(string $filePath): array
138+
private function getUsedSymbolsInFile(string $filePath): array
134139
{
135140
$code = file_get_contents($filePath);
136141

137142
if ($code === false) {
138143
throw new LogicException("Unable to get contents of $filePath");
139144
}
140145

141-
$extractor = new UsedSymbolExtractor($code);
142-
return $extractor->parseUsedSymbols();
146+
return (new UsedSymbolExtractor($code))->parseUsedClasses();
143147
}
144148

145149
/**
@@ -201,4 +205,13 @@ private function realPath(string $filePath): string
201205
return $realPath;
202206
}
203207

208+
/**
209+
* Since UsedSymbolExtractor cannot reliably tell if FQN usages are classes or other symbols,
210+
* we verify those edgecases only when such classname is not found in classmap.
211+
*/
212+
private function isConstOrFunction(string $usedClass): bool
213+
{
214+
return defined($usedClass) || function_exists($usedClass);
215+
}
216+
204217
}

src/UsedSymbolExtractor.php

+147-39
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@
22

33
namespace ShipMonk\Composer;
44

5+
use function array_merge;
56
use function count;
7+
use function explode;
8+
use function is_array;
69
use function ltrim;
10+
use function strlen;
11+
use function substr;
712
use function token_get_all;
813
use const PHP_VERSION_ID;
914
use const T_AS;
1015
use const T_COMMENT;
1116
use const T_DOC_COMMENT;
1217
use const T_NAME_FULLY_QUALIFIED;
1318
use const T_NAME_QUALIFIED;
19+
use const T_NAMESPACE;
1420
use const T_NS_SEPARATOR;
1521
use const T_STRING;
1622
use const T_USE;
@@ -34,35 +40,92 @@ class UsedSymbolExtractor
3440
*/
3541
private $pointer = 0;
3642

37-
/**
38-
* @var int
39-
*/
40-
private $level = 0;
41-
4243
public function __construct(string $code)
4344
{
4445
$this->tokens = token_get_all($code);
4546
$this->numTokens = count($this->tokens);
4647
}
4748

4849
/**
50+
* As we do not verify if the resulting name are classes, it can return even used functions or constants (due to FQNs).
51+
* - elimination of those is solved in ComposerDependencyAnalyser::isConstOrFunction
52+
*
53+
* It does not produce any local names in current namespace
54+
* - this results in very limited functionality in files without namespace
55+
*
4956
* @return list<string>
57+
* @license Inspired by https://github.com/doctrine/annotations/blob/2.0.0/lib/Doctrine/Common/Annotations/TokenParser.php
5058
*/
51-
public function parseUsedSymbols(): array
59+
public function parseUsedClasses(): array
5260
{
53-
$statements = [];
61+
$usedSymbols = [];
62+
$useStatements = [];
5463

5564
while ($token = $this->getNextEffectiveToken()) {
56-
if ($token[0] === T_USE && $this->level === 0) {
57-
$usedClass = $this->parseSimpleUseStatement();
65+
if ($token[0] === T_USE) {
66+
$usedClass = $this->parseUseStatement();
5867

5968
if ($usedClass !== null) {
60-
$statements[] = $usedClass;
69+
$useStatements = array_merge($useStatements, $usedClass);
70+
}
71+
}
72+
73+
if (PHP_VERSION_ID >= 80000) {
74+
if ($token[0] === T_NAMESPACE) {
75+
$useStatements = []; // reset use statements on namespace change
76+
}
77+
78+
if ($token[0] === T_NAME_FULLY_QUALIFIED) {
79+
$usedSymbols[] = $this->normalizeBackslash($token[1]);
80+
}
81+
82+
if ($token[0] === T_NAME_QUALIFIED) {
83+
[$neededAlias] = explode('\\', $token[1], 2);
84+
85+
if (isset($useStatements[$neededAlias])) {
86+
$usedSymbols[] = $this->normalizeBackslash($useStatements[$neededAlias] . substr($token[1], strlen($neededAlias)));
87+
}
88+
}
89+
90+
if ($token[0] === T_STRING) {
91+
$symbolName = $token[1];
92+
93+
if (isset($useStatements[$symbolName])) {
94+
$usedSymbols[] = $this->normalizeBackslash($useStatements[$symbolName]);
95+
}
96+
}
97+
} else {
98+
if ($token[0] === T_NAMESPACE) {
99+
$this->pointer++;
100+
$nextName = $this->parseNameForOldPhp();
101+
102+
if (substr($nextName, 0, 1) !== '\\') { // not a namespace-relative name, but a new namespace declaration
103+
$useStatements = []; // reset use statements on namespace change
104+
}
105+
}
106+
107+
if ($token[0] === T_NS_SEPARATOR) { // fully qualified name
108+
$usedSymbols[] = $this->normalizeBackslash($this->parseNameForOldPhp());
109+
}
110+
111+
if ($token[0] === T_STRING) {
112+
$symbolName = $this->parseNameForOldPhp();
113+
114+
if (isset($useStatements[$symbolName])) { // unqualified name
115+
$usedSymbols[] = $this->normalizeBackslash($useStatements[$symbolName]);
116+
117+
} else {
118+
[$neededAlias] = explode('\\', $symbolName, 2);
119+
120+
if (isset($useStatements[$neededAlias])) { // qualified name
121+
$usedSymbols[] = $this->normalizeBackslash($useStatements[$neededAlias] . substr($symbolName, strlen($neededAlias)));
122+
}
123+
}
61124
}
62125
}
63126
}
64127

65-
return $statements;
128+
return $usedSymbols;
66129
}
67130

68131
/**
@@ -74,59 +137,104 @@ private function getNextEffectiveToken()
74137
$this->pointer++;
75138
$token = $this->tokens[$i];
76139

77-
if (
78-
$token[0] === T_WHITESPACE ||
79-
$token[0] === T_COMMENT ||
80-
$token[0] === T_DOC_COMMENT
81-
) {
140+
if ($this->isNonEffectiveToken($token)) {
82141
continue;
83142
}
84143

85-
if ($token === '{') {
86-
$this->level++;
87-
} elseif ($token === '}') {
88-
$this->level--;
89-
}
90-
91144
return $token;
92145
}
93146

94147
return null;
95148
}
96149

97150
/**
98-
* Parses simple use statement like:
99-
*
100-
* use Foo\Bar;
101-
* use Foo\Bar as Alias;
102-
*
103-
* Does not support bracket syntax nor comma-separated statements:
104-
*
105-
* use Foo\{ Bar, Baz };
106-
* use Foo\Bar, Foo\Baz;
151+
* @param array{int, string, int}|string $token
152+
*/
153+
private function isNonEffectiveToken($token): bool
154+
{
155+
if (!is_array($token)) {
156+
return false;
157+
}
158+
159+
return $token[0] === T_WHITESPACE ||
160+
$token[0] === T_COMMENT ||
161+
$token[0] === T_DOC_COMMENT;
162+
}
163+
164+
/**
165+
* See old behaviour: https://wiki.php.net/rfc/namespaced_names_as_token
107166
*/
108-
private function parseSimpleUseStatement(): ?string
167+
private function parseNameForOldPhp(): string
109168
{
169+
$this->pointer--; // we already detected start token above
170+
171+
$name = '';
172+
173+
do {
174+
$token = $this->getNextEffectiveToken();
175+
$isNamePart = is_array($token) && ($token[0] === T_STRING || $token[0] === T_NS_SEPARATOR);
176+
177+
if (!$isNamePart) {
178+
break;
179+
}
180+
181+
$name .= $token[1];
182+
183+
} while (true);
184+
185+
return $name;
186+
}
187+
188+
/**
189+
* @return array<string, string>|null
190+
*/
191+
public function parseUseStatement(): ?array
192+
{
193+
$groupRoot = '';
110194
$class = '';
195+
$alias = '';
196+
$statements = [];
197+
$explicitAlias = false;
111198

112-
while ($token = $this->getNextEffectiveToken()) {
113-
if ($token[0] === T_STRING) {
199+
while (($token = $this->getNextEffectiveToken())) {
200+
if (!$explicitAlias && $token[0] === T_STRING) {
114201
$class .= $token[1];
202+
$alias = $token[1];
203+
} elseif ($explicitAlias && $token[0] === T_STRING) {
204+
$alias = $token[1];
115205
} elseif (
116-
PHP_VERSION_ID >= 80000 &&
117-
($token[0] === T_NAME_QUALIFIED || $token[0] === T_NAME_FULLY_QUALIFIED)
206+
PHP_VERSION_ID >= 80000
207+
&& ($token[0] === T_NAME_QUALIFIED || $token[0] === T_NAME_FULLY_QUALIFIED)
118208
) {
119209
$class .= $token[1];
210+
211+
$classSplit = explode('\\', $token[1]);
212+
$alias = $classSplit[count($classSplit) - 1];
120213
} elseif ($token[0] === T_NS_SEPARATOR) {
121214
$class .= '\\';
122-
} elseif ($token[0] === T_AS || $token === ';') {
123-
return $this->normalizeBackslash($class);
215+
$alias = '';
216+
} elseif ($token[0] === T_AS) {
217+
$explicitAlias = true;
218+
$alias = '';
219+
} elseif ($token === ',') {
220+
$statements[$alias] = $groupRoot . $class;
221+
$class = '';
222+
$alias = '';
223+
$explicitAlias = false;
224+
} elseif ($token === ';') {
225+
$statements[$alias] = $groupRoot . $class;
226+
break;
227+
} elseif ($token === '{') {
228+
$groupRoot = $class;
229+
$class = '';
230+
} elseif ($token === '}') {
231+
continue;
124232
} else {
125233
break;
126234
}
127235
}
128236

129-
return null;
237+
return $statements === [] ? null : $statements;
130238
}
131239

132240
private function normalizeBackslash(string $class): string

0 commit comments

Comments
 (0)