Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove use statements preconditions #4

Merged
merged 5 commits into from
Jan 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 4 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,6 @@ See comparison with existing projects:
composer require --dev shipmonk/composer-dependency-analyser
```

## Preconditions:
- To achieve such performance, your project needs follow some `use statements` limitations
- Disallowed approaches:
- Partial use statements, `use Doctrine\ORM\Mapping as ORM;` + `#[ORM\Entity]`
- Multiple use statements `use Foo, Bar;`
- Bracketed use statements `use Foo\{Bar, Baz};`
- Bracketed namespaces `namespace Foo { ... }`

All this can be ensured by [slevomat/coding-standard](https://github.com/slevomat/coding-standard) with following config:

```xml
<?xml version="1.0"?>
<ruleset>
<rule ref="SlevomatCodingStandard.Namespaces.DisallowGroupUse"/>
<rule ref="SlevomatCodingStandard.Namespaces.NamespaceDeclaration"/>
<rule ref="SlevomatCodingStandard.Namespaces.MultipleUsesPerLine"/>
<rule ref="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly">
<properties>
<property name="allowPartialUses" value="false"/>
</properties>
</rule>
</ruleset>
```

Basically, this tool extracts used symbols just from use statements and compare those with your composer dependencies.

## Usage:

```sh
Expand Down Expand Up @@ -69,7 +43,10 @@ Every used class should be listed in your `require` (or `require-dev`) section o
## Future scope:
- Detecting dead dependencies
- Detecting dev dependencies used in production code
- Lowering number of preconditions

## Limitations:
- Files without namespace has limited support
- Only classes with use statements and FQNs are detected

## Contributing:
- Check your code by `composer check`
Expand Down
31 changes: 22 additions & 9 deletions src/ComposerDependencyAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
use ShipMonk\Composer\Error\SymbolError;
use UnexpectedValueException;
use function class_exists;
use function defined;
use function explode;
use function file_get_contents;
use function function_exists;
use function interface_exists;
use function is_file;
use function ksort;
Expand Down Expand Up @@ -85,17 +87,20 @@ public function scan(array $scanPaths): array

foreach ($scanPaths as $scanPath) {
foreach ($this->listPhpFilesIn($scanPath) as $filePath) {
foreach ($this->getUsesInFile($filePath) as $usedClass) {
if ($this->isInternalClass($usedClass)) {
foreach ($this->getUsedSymbolsInFile($filePath) as $usedSymbol) {
if ($this->isInternalClass($usedSymbol)) {
continue;
}

if (!isset($this->optimizedClassmap[$usedClass])) {
$errors[$usedClass] = new ClassmapEntryMissingError($usedClass, $filePath);
if (!isset($this->optimizedClassmap[$usedSymbol])) {
if (!$this->isConstOrFunction($usedSymbol)) {
$errors[$usedSymbol] = new ClassmapEntryMissingError($usedSymbol, $filePath);
}

continue;
}

$classmapPath = $this->optimizedClassmap[$usedClass];
$classmapPath = $this->optimizedClassmap[$usedSymbol];

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

if ($this->isShadowDependency($packageName)) {
$errors[$usedClass] = new ShadowDependencyError($usedClass, $packageName, $filePath);
$errors[$usedSymbol] = new ShadowDependencyError($usedSymbol, $packageName, $filePath);
}
}
}
Expand All @@ -130,16 +135,15 @@ private function getPackageNameFromVendorPath(string $realPath): string
/**
* @return list<string>
*/
private function getUsesInFile(string $filePath): array
private function getUsedSymbolsInFile(string $filePath): array
{
$code = file_get_contents($filePath);

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

$extractor = new UsedSymbolExtractor($code);
return $extractor->parseUsedSymbols();
return (new UsedSymbolExtractor($code))->parseUsedClasses();
}

/**
Expand Down Expand Up @@ -201,4 +205,13 @@ private function realPath(string $filePath): string
return $realPath;
}

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

}
186 changes: 147 additions & 39 deletions src/UsedSymbolExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

namespace ShipMonk\Composer;

use function array_merge;
use function count;
use function explode;
use function is_array;
use function ltrim;
use function strlen;
use function substr;
use function token_get_all;
use const PHP_VERSION_ID;
use const T_AS;
use const T_COMMENT;
use const T_DOC_COMMENT;
use const T_NAME_FULLY_QUALIFIED;
use const T_NAME_QUALIFIED;
use const T_NAMESPACE;
use const T_NS_SEPARATOR;
use const T_STRING;
use const T_USE;
Expand All @@ -34,35 +40,92 @@ class UsedSymbolExtractor
*/
private $pointer = 0;

/**
* @var int
*/
private $level = 0;

public function __construct(string $code)
{
$this->tokens = token_get_all($code);
$this->numTokens = count($this->tokens);
}

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

while ($token = $this->getNextEffectiveToken()) {
if ($token[0] === T_USE && $this->level === 0) {
$usedClass = $this->parseSimpleUseStatement();
if ($token[0] === T_USE) {
$usedClass = $this->parseUseStatement();

if ($usedClass !== null) {
$statements[] = $usedClass;
$useStatements = array_merge($useStatements, $usedClass);
}
}

if (PHP_VERSION_ID >= 80000) {
if ($token[0] === T_NAMESPACE) {
$useStatements = []; // reset use statements on namespace change
}

if ($token[0] === T_NAME_FULLY_QUALIFIED) {
$usedSymbols[] = $this->normalizeBackslash($token[1]);
}

if ($token[0] === T_NAME_QUALIFIED) {
[$neededAlias] = explode('\\', $token[1], 2);

if (isset($useStatements[$neededAlias])) {
$usedSymbols[] = $this->normalizeBackslash($useStatements[$neededAlias] . substr($token[1], strlen($neededAlias)));
}
}

if ($token[0] === T_STRING) {
$symbolName = $token[1];

if (isset($useStatements[$symbolName])) {
$usedSymbols[] = $this->normalizeBackslash($useStatements[$symbolName]);
}
}
} else {
if ($token[0] === T_NAMESPACE) {
$this->pointer++;
$nextName = $this->parseNameForOldPhp();

if (substr($nextName, 0, 1) !== '\\') { // not a namespace-relative name, but a new namespace declaration
$useStatements = []; // reset use statements on namespace change
}
}

if ($token[0] === T_NS_SEPARATOR) { // fully qualified name
$usedSymbols[] = $this->normalizeBackslash($this->parseNameForOldPhp());
}

if ($token[0] === T_STRING) {
$symbolName = $this->parseNameForOldPhp();

if (isset($useStatements[$symbolName])) { // unqualified name
$usedSymbols[] = $this->normalizeBackslash($useStatements[$symbolName]);

} else {
[$neededAlias] = explode('\\', $symbolName, 2);

if (isset($useStatements[$neededAlias])) { // qualified name
$usedSymbols[] = $this->normalizeBackslash($useStatements[$neededAlias] . substr($symbolName, strlen($neededAlias)));
}
}
}
}
}

return $statements;
return $usedSymbols;
}

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

if (
$token[0] === T_WHITESPACE ||
$token[0] === T_COMMENT ||
$token[0] === T_DOC_COMMENT
) {
if ($this->isNonEffectiveToken($token)) {
continue;
}

if ($token === '{') {
$this->level++;
} elseif ($token === '}') {
$this->level--;
}

return $token;
}

return null;
}

/**
* Parses simple use statement like:
*
* use Foo\Bar;
* use Foo\Bar as Alias;
*
* Does not support bracket syntax nor comma-separated statements:
*
* use Foo\{ Bar, Baz };
* use Foo\Bar, Foo\Baz;
* @param array{int, string, int}|string $token
*/
private function isNonEffectiveToken($token): bool
{
if (!is_array($token)) {
return false;
}

return $token[0] === T_WHITESPACE ||
$token[0] === T_COMMENT ||
$token[0] === T_DOC_COMMENT;
}

/**
* See old behaviour: https://wiki.php.net/rfc/namespaced_names_as_token
*/
private function parseSimpleUseStatement(): ?string
private function parseNameForOldPhp(): string
{
$this->pointer--; // we already detected start token above

$name = '';

do {
$token = $this->getNextEffectiveToken();
$isNamePart = is_array($token) && ($token[0] === T_STRING || $token[0] === T_NS_SEPARATOR);

if (!$isNamePart) {
break;
}

$name .= $token[1];

} while (true);

return $name;
}

/**
* @return array<string, string>|null
*/
public function parseUseStatement(): ?array
{
$groupRoot = '';
$class = '';
$alias = '';
$statements = [];
$explicitAlias = false;

while ($token = $this->getNextEffectiveToken()) {
if ($token[0] === T_STRING) {
while (($token = $this->getNextEffectiveToken())) {
if (!$explicitAlias && $token[0] === T_STRING) {
$class .= $token[1];
$alias = $token[1];
} elseif ($explicitAlias && $token[0] === T_STRING) {
$alias = $token[1];
} elseif (
PHP_VERSION_ID >= 80000 &&
($token[0] === T_NAME_QUALIFIED || $token[0] === T_NAME_FULLY_QUALIFIED)
PHP_VERSION_ID >= 80000
&& ($token[0] === T_NAME_QUALIFIED || $token[0] === T_NAME_FULLY_QUALIFIED)
) {
$class .= $token[1];

$classSplit = explode('\\', $token[1]);
$alias = $classSplit[count($classSplit) - 1];
} elseif ($token[0] === T_NS_SEPARATOR) {
$class .= '\\';
} elseif ($token[0] === T_AS || $token === ';') {
return $this->normalizeBackslash($class);
$alias = '';
} elseif ($token[0] === T_AS) {
$explicitAlias = true;
$alias = '';
} elseif ($token === ',') {
$statements[$alias] = $groupRoot . $class;
$class = '';
$alias = '';
$explicitAlias = false;
} elseif ($token === ';') {
$statements[$alias] = $groupRoot . $class;
break;
} elseif ($token === '{') {
$groupRoot = $class;
$class = '';
} elseif ($token === '}') {
continue;
} else {
break;
}
}

return null;
return $statements === [] ? null : $statements;
}

private function normalizeBackslash(string $class): string
Expand Down
Loading
Loading