diff --git a/README.md b/README.md index fe5ab82..edc0ed1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/PhpFileCleaner.php b/src/PhpFileCleaner.php index 731f698..fd11d80 100644 --- a/src/PhpFileCleaner.php +++ b/src/PhpFileCleaner.php @@ -20,7 +20,7 @@ */ class PhpFileCleaner { - /** @var array */ + /** @var array> */ private static $typeConfig; /** @var non-empty-string */ @@ -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, ]; } @@ -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]; + } } } diff --git a/src/PhpFileParser.php b/src/PhpFileParser.php index 73ed349..49eeb8d 100644 --- a/src/PhpFileParser.php +++ b/src/PhpFileParser.php @@ -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(?])(?Pextension) \s++ (?P[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+) \s++ on \s++ (?P\\\\?[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 * @@ -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 []; } @@ -71,6 +85,7 @@ public static function findClasses(string $path): array Preg::matchAll('{ (?: \b(?])(?Pclass|interface|trait'.$extraTypes.') \s++ (?P[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+) + | '.self::EXTENSION_REGEX.' | \b(?])(?Pnamespace) (?P\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); @@ -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)); @@ -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; diff --git a/tests/ClassMapGeneratorTest.php b/tests/ClassMapGeneratorTest.php index 5c0f7e6..7989128 100644 --- a/tests/ClassMapGeneratorTest.php +++ b/tests/ClassMapGeneratorTest.php @@ -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', @@ -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', "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 = [ diff --git a/tests/Fixtures/extensions/ExtensionAliasedTarget.php b/tests/Fixtures/extensions/ExtensionAliasedTarget.php new file mode 100644 index 0000000..523e678 --- /dev/null +++ b/tests/Fixtures/extensions/ExtensionAliasedTarget.php @@ -0,0 +1,9 @@ +modify('+1 day'); + } +} + +extension string $s +{ + public function shout(): string + { + return \strtoupper($s); + } +} diff --git a/tests/Fixtures/extensions/ExtensionFalsePositives.php b/tests/Fixtures/extensions/ExtensionFalsePositives.php new file mode 100644 index 0000000..2dc177b --- /dev/null +++ b/tests/Fixtures/extensions/ExtensionFalsePositives.php @@ -0,0 +1,25 @@ +extension($s); + + return 'extension Foo on Bar $b { }'; +} diff --git a/tests/Fixtures/extensions/ExtensionGlobal.php b/tests/Fixtures/extensions/ExtensionGlobal.php new file mode 100644 index 0000000..b856014 --- /dev/null +++ b/tests/Fixtures/extensions/ExtensionGlobal.php @@ -0,0 +1,9 @@ + $expected extension name => raw target token + */ + public function testExtensionTargetIsCapturedRaw(string $file, array $expected): void + { + PhpFileCleaner::setTypeConfig(['class', 'interface', 'trait', 'extension', 'enum']); + + $contents = php_strip_whitespace($file); + // maxMatches > 1 disables the cleaner's single-match early return so the whole file is cleaned + $cleaner = new PhpFileCleaner($contents, PHP_INT_MAX); + $contents = $cleaner->clean(); + + Preg::matchAllStrictGroups('{'.PhpFileParser::EXTENSION_REGEX.'}ix', $contents, $matches); + + $actual = []; + foreach ($matches['extname'] as $i => $name) { + $actual[$name] = $matches['exttarget'][$i]; + } + + self::assertSame($expected, $actual); + } + + /** + * @return array}> + */ + public static function extensionTargetProvider(): array + { + return [ + 'fully-qualified target' => [__DIR__ . '/Fixtures/extensions/ExtensionGlobal.php', [ + 'DomTraversal' => '\DOMElement', + ]], + 'fully-qualified target in namespace' => [__DIR__ . '/Fixtures/extensions/ExtensionNamespaced.php', [ + 'DomTraversal' => '\DOMElement', + ]], + 'scalar and array targets' => [__DIR__ . '/Fixtures/extensions/ExtensionScalarTargets.php', [ + 'StringHelpers' => 'string', + 'ArrayHelpers' => 'array', + ]], + 'relative qualified target stays unresolved' => [__DIR__ . '/Fixtures/extensions/ExtensionMultiline.php', [ + 'WidgetHelpers' => '\Acme\Widgets\Widget', + 'CaseInsensitive' => 'Widgets\Widget', + ]], + 'aliased target stays unresolved' => [__DIR__ . '/Fixtures/extensions/ExtensionAliasedTarget.php', [ + 'WidgetShortcuts' => 'W', + ]], + 'unqualified target' => [__DIR__ . '/Fixtures/extensions/ExtensionWithClass.php', [ + 'FormatterHelpers' => 'Formatter', + ]], + 'anonymous forms capture nothing' => [__DIR__ . '/Fixtures/extensions/ExtensionAnonymous.php', []], + 'false positives capture nothing' => [__DIR__ . '/Fixtures/extensions/ExtensionFalsePositives.php', []], + ]; + } }