Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ Basic usage
-----------

If all you want is to scan a directory and extract a classmap with all
classes/interfaces/traits/enums mapped to their paths, you can simply use:
classes/interfaces/traits/enums (and named `extension` declarations from the
proposed Extension Methods RFC) mapped to their paths, you can simply use:


```php
Expand Down
30 changes: 21 additions & 9 deletions src/PhpFileCleaner.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*/
class PhpFileCleaner
{
/** @var array<array{name: string, length: int, pattern: non-empty-string}> */
/** @var array<string, list<array{name: string, length: int, pattern: non-empty-string}>> */
private static $typeConfig;

/** @var non-empty-string */
Expand Down Expand Up @@ -52,11 +52,22 @@ class PhpFileCleaner
*/
public static function setTypeConfig(array $types): void
{
self::$typeConfig = [];

foreach ($types as $type) {
self::$typeConfig[$type[0]] = [
if ('extension' === $type) {
// extension declarations are only recognized with the full "Name on Target $var" tail, both to
// rule out false positives ("extension" is a valid symbol name) and because the early-return
// in clean() must keep everything the parser regex needs to see
$pattern = '{.\b(?<![\$:>])extension\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\s++on\s++\\\\?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+\s*+\$[a-zA-Z_\x7f-\xff]}Ais';
} else {
$pattern = '{.\b(?<![\$:>])'.$type.'\s++[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+}Ais';
}

self::$typeConfig[$type[0]][] = [
'name' => $type,
'length' => \strlen($type),
'pattern' => '{.\b(?<![\$:>])'.$type.'\s++[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+}Ais',
'pattern' => $pattern,
];
}

Expand Down Expand Up @@ -118,12 +129,13 @@ public function clean(): string
}

if ($this->maxMatches === 1 && isset(self::$typeConfig[$char])) {
$type = self::$typeConfig[$char];
if (
substr($this->contents, $this->index, $type['length']) === $type['name']
&& Preg::isMatch($type['pattern'], $this->contents, $match, 0, $this->index - 1)
) {
return $clean . $match[0];
foreach (self::$typeConfig[$char] as $type) {
if (
substr($this->contents, $this->index, $type['length']) === $type['name']
&& Preg::isMatch($type['pattern'], $this->contents, $match, 0, $this->index - 1)
) {
return $clean . $match[0];
}
}
}

Expand Down
29 changes: 27 additions & 2 deletions src/PhpFileParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@
*/
class PhpFileParser
{
/**
* Regex fragment matching a named extension declaration (proposed "Extension Methods" RFC)
*
* Captures the declared name (extname) and the raw target token after "on" (exttarget). The
* target is captured exactly as written in the source — possibly a relative name or an alias
* from a "use" import, unresolved — in anticipation of the target-hint optimization described
* in the RFC's Future Scope. It is not used yet and is not part of the class map output.
*
* Must remain embeddable in a case-insensitive, whitespace-extended ("ix") pattern.
*
* @internal
*/
public const EXTENSION_REGEX = '\b(?<![\\\\$:>])(?P<ext>extension) \s++ (?P<extname>[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+) \s++ on \s++ (?P<exttarget>\\\\?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+) \s*+ \$[a-zA-Z_\x7f-\xff]';

/**
* Extract the classes in the given file
*
Expand Down Expand Up @@ -59,7 +73,7 @@ public static function findClasses(string $path): array
}

// return early if there is no chance of matching anything in this file
Preg::matchAllStrictGroups('{\b(?:class|interface|trait'.$extraTypes.')\s}i', $contents, $matches);
Preg::matchAllStrictGroups('{\b(?:class|interface|trait|extension'.$extraTypes.')\s}i', $contents, $matches);
if ([] === $matches[0]) {
return [];
}
Expand All @@ -71,6 +85,7 @@ public static function findClasses(string $path): array
Preg::matchAll('{
(?:
\b(?<![\\\\$:>])(?P<type>class|interface|trait'.$extraTypes.') \s++ (?P<name>[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+)
| '.self::EXTENSION_REGEX.'
| \b(?<![\\\\$:>])(?P<ns>namespace) (?P<nsname>\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+)? \s*+ [\{;]
)
}ix', $contents, $matches);
Expand All @@ -81,6 +96,16 @@ public static function findClasses(string $path): array
for ($i = 0, $len = \count($matches['type']); $i < $len; ++$i) {
if (isset($matches['ns'][$i]) && $matches['ns'][$i] !== '') {
$namespace = str_replace([' ', "\t", "\r", "\n"], '', (string) $matches['nsname'][$i]) . '\\';
} elseif (isset($matches['ext'][$i]) && $matches['ext'][$i] !== '') {
// named extension declarations (proposed "Extension Methods" RFC) declare the identifier
// before "on"; the target type after "on" and the receiver variable are not part of the name,
// and the anonymous form ("extension Target $var {") declares no autoloadable symbol at all
$name = $matches['extname'][$i];
\assert(\is_string($name));

/** @var class-string */
$className = ltrim($namespace . $name, '\\');
$classes[] = $className;
} else {
$name = $matches['name'][$i];
\assert(\is_string($name));
Expand Down Expand Up @@ -131,7 +156,7 @@ private static function getExtraTypes(): string
$extraTypesArray = ['enum'];
}

PhpFileCleaner::setTypeConfig(array_merge(['class', 'interface', 'trait'], $extraTypesArray));
PhpFileCleaner::setTypeConfig(array_merge(['class', 'interface', 'trait', 'extension'], $extraTypesArray));
}

return $extraTypes;
Expand Down
53 changes: 53 additions & 0 deletions tests/ClassMapGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ public function getTestCreateMapTests(): array
'Dummy\Test\AnonClassHolder' => __DIR__ . '/Fixtures/php7.0/anonclass.php',
]];

// proposed "Extension Methods" RFC, recognized on any PHP version as parsing is purely text-based
$data[] = [__DIR__ . '/Fixtures/extensions', [
'DomTraversal' => __DIR__ . '/Fixtures/extensions/ExtensionGlobal.php',
'Acme\DomKit\DomTraversal' => __DIR__ . '/Fixtures/extensions/ExtensionNamespaced.php',
'Acme\Scalars\StringHelpers' => __DIR__ . '/Fixtures/extensions/ExtensionScalarTargets.php',
'Acme\Scalars\ArrayHelpers' => __DIR__ . '/Fixtures/extensions/ExtensionScalarTargets.php',
'Acme\Aliased\WidgetShortcuts' => __DIR__ . '/Fixtures/extensions/ExtensionAliasedTarget.php',
'Acme\Multiline\WidgetHelpers' => __DIR__ . '/Fixtures/extensions/ExtensionMultiline.php',
'Acme\Multiline\CaseInsensitive' => __DIR__ . '/Fixtures/extensions/ExtensionMultiline.php',
'Acme\Tricky\extension' => __DIR__ . '/Fixtures/extensions/ExtensionFalsePositives.php',
'Acme\Mixed\Formatter' => __DIR__ . '/Fixtures/extensions/ExtensionWithClass.php',
'Acme\Mixed\FormatterHelpers' => __DIR__ . '/Fixtures/extensions/ExtensionWithClass.php',
]];

if (PHP_VERSION_ID >= 80100) {
$data[] = [__DIR__ . '/Fixtures/php8.1', [
'RolesBasicEnum' => __DIR__ . '/Fixtures/php8.1/enum_basic.php',
Expand Down Expand Up @@ -414,6 +428,45 @@ public function testGetRawPSR4Violations(): void
self::assertSame('UnexpectedNamespace\ClassWithNameSpaceOutsideConfiguredScope', $rawViolations[$classWithNameSpaceOutsideConfiguredScopeFilepath][0]['className']);
}

public function testExtensionDeclarationsFollowPsr4Rules(): void
{
$this->generator->scanPaths(__DIR__ . '/Fixtures/psr4Extensions', null, 'psr-4', 'ExpectedNamespace\\');
$classMap = $this->generator->getClassMap();

// a named extension declaration matching the file path is accepted as the file's expected symbol
self::assertArrayHasKey('ExpectedNamespace\\DomTraversal', $classMap->getMap());

self::assertSame(
[
'Class ExpectedNamespace\SomeOtherName located in ./tests/Fixtures/psr4Extensions/ExtensionWithIncorrectName.php does not comply with psr-4 autoloading standard (rule: ExpectedNamespace\ => ./tests/Fixtures/psr4Extensions). Skipping.',
],
$classMap->getPsrViolations()
);
}

/**
* Extension names live in the same name=>file map as class-like symbols, so
* duplicates produce the usual ambiguous class warnings
*/
public function testExtensionNamesShareNamespaceWithClasses(): void
{
$tempDir = self::getUniqueTmpDirectory();
mkdir($tempDir.'/other');

file_put_contents($tempDir . '/A.php', "<?php\nclass A {}");
file_put_contents($tempDir . '/other/A.php', "<?php\nextension A on \\ArrayObject \$a {}");

$this->generator->scanPaths($tempDir);
$classMap = $this->generator->getClassMap();

self::assertCount(1, $classMap->getAmbiguousClasses());
self::assertArrayHasKey('A', $classMap->getAmbiguousClasses());
self::assertCount(1, $classMap->getAmbiguousClasses()['A']);

$fs = new Filesystem();
$fs->remove($tempDir);
}

public function testCreateMapWithDirectoryExcluded(): void
{
$expected = [
Expand Down
9 changes: 9 additions & 0 deletions tests/Fixtures/extensions/ExtensionAliasedTarget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Acme\Aliased;

use Acme\Widgets\Widget as W;

extension WidgetShortcuts on W $w
{
}
21 changes: 21 additions & 0 deletions tests/Fixtures/extensions/ExtensionAnonymous.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Acme\Anon;

// anonymous extensions have no name and are not autoloadable, they must not end up in the class map

extension \DateTimeImmutable $date
{
public function tomorrow(): \DateTimeImmutable
{
return $date->modify('+1 day');
}
}

extension string $s
{
public function shout(): string
{
return \strtoupper($s);
}
}
25 changes: 25 additions & 0 deletions tests/Fixtures/extensions/ExtensionFalsePositives.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Acme\Tricky;

// "extension" is a valid symbol name in PHP today, and the RFC's import
// statement ("use extension ...") declares nothing; none of the usages below
// may produce a class map entry except the class actually named "extension"

use extension Acme\DomKit\DomTraversal;

class extension extends \ArrayObject
{
public function extension(string $x): string
{
return \pathinfo($x, \PATHINFO_EXTENSION);
}
}

function extension(string $s): string
{
$obj = new extension();
$obj->extension($s);

return 'extension Foo on Bar $b { }';
}
9 changes: 9 additions & 0 deletions tests/Fixtures/extensions/ExtensionGlobal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

extension DomTraversal on \DOMElement $el
{
public function firstByClass(string $class): ?\DOMElement
{
return null;
}
}
17 changes: 17 additions & 0 deletions tests/Fixtures/extensions/ExtensionMultiline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Acme\Multiline;

extension
// the declaration may be spread over several lines
WidgetHelpers
/* with comments between any two tokens */
on
\Acme\Widgets\Widget
$widget
{
}

EXTENSION CaseInsensitive ON Widgets\Widget $w
{
}
11 changes: 11 additions & 0 deletions tests/Fixtures/extensions/ExtensionNamespaced.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Acme\DomKit;

extension DomTraversal on \DOMElement $el
{
public function firstByClass(string $class): ?\DOMElement
{
return null;
}
}
19 changes: 19 additions & 0 deletions tests/Fixtures/extensions/ExtensionScalarTargets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Acme\Scalars;

extension StringHelpers on string $s
{
public function isBlank(): bool
{
return \trim($s) === '';
}
}

extension ArrayHelpers on array $items
{
public function isEmpty(): bool
{
return $items === [];
}
}
11 changes: 11 additions & 0 deletions tests/Fixtures/extensions/ExtensionWithClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Acme\Mixed;

class Formatter
{
}

extension FormatterHelpers on Formatter $formatter
{
}
11 changes: 11 additions & 0 deletions tests/Fixtures/psr4Extensions/DomTraversal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace ExpectedNamespace;

extension DomTraversal on \DOMElement $el
{
public function firstByClass(string $class): ?\DOMElement
{
return null;
}
}
7 changes: 7 additions & 0 deletions tests/Fixtures/psr4Extensions/ExtensionWithIncorrectName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace ExpectedNamespace;

extension SomeOtherName on string $s
{
}
Loading