diff --git a/phpstan.neon b/phpstan.neon index 9208ceb..1666d2b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,16 @@ parameters: paths: - src ignoreErrors: + # A PHPStan release that knows no PHP 8.6 (phpVersion.max capped at 80599) folds the runtime + # check guarding the 8.6-specific boolean rendering to "always false"; newer releases know 8.6 + # and report nothing, hence reportUnmatched is disabled for these two entries. The code path is + # exercised by the experimental PHP 8.6 CI job. + - identifier: greaterOrEqual.alwaysFalse + path: src/ReflectionClassConstant.php + reportUnmatched: false + - identifier: booleanAnd.alwaysFalse + path: src/ReflectionClassConstant.php + reportUnmatched: false # Both classes are final, so "might have hooks in a subclass" is a false positive - identifier: unset.possiblyHookedProperty path: src/ReflectionFunction.php diff --git a/src/ReflectionClassConstant.php b/src/ReflectionClassConstant.php index 51e9f3c..c918c66 100644 --- a/src/ReflectionClassConstant.php +++ b/src/ReflectionClassConstant.php @@ -320,12 +320,22 @@ public function __toString(): string $valueType = $this->type ?? new ReflectionType('mixed', false); } + // Since PHP 8.6 the native reflection prints boolean values as "true"/"false" instead of + // the string cast "1"/"" used before + if (is_bool($value) && PHP_VERSION_ID >= 80600) { + $displayValue = $value ? 'true' : 'false'; + } elseif (is_object($value)) { + $displayValue = 'Object'; + } else { + $displayValue = is_scalar($value) || $value === null ? (string) $value : ''; + } + return sprintf( "Constant [ %s %s %s ] { %s }\n", implode(' ', Reflection::getModifierNames($this->getModifiers())), ReflectionType::convertToDisplayType($valueType), $this->getName(), - is_object($value) ? 'Object' : (is_scalar($value) || $value === null ? $value : '') + $displayValue ); } diff --git a/src/ReflectionEngine.php b/src/ReflectionEngine.php index 9bba8d4..fc02da2 100644 --- a/src/ReflectionEngine.php +++ b/src/ReflectionEngine.php @@ -42,9 +42,20 @@ class ReflectionEngine protected static ?int $maximumCachedFiles; - protected static Parser $parser; + /** + * Parser of the sources, created on the first parse + */ + protected static ?Parser $parser = null; - protected static NodeTraverser $traverser; + /** + * Traverser resolving the names of the parsed sources, created on the first parse + */ + protected static ?NodeTraverser $traverser = null; + + /** + * Grammar version of the parser, null for the newest grammar supported by PHP-Parser + */ + private static ?PhpVersion $phpVersion = null; private function __construct() {} @@ -54,26 +65,17 @@ private function __construct() {} * By default the newest grammar supported by PHP-Parser is used, so that sources written for a newer * PHP version than the host one can still be analysed statically. * + * The parser is created on the first parse: the composer bootstrap of this package initializes the engine on + * every request of an application, and most of them never parse anything. + * * @param PhpVersion|null $phpVersion Optional PHP version of the grammar to parse sources with */ public static function init(LocatorInterface $locator, ?PhpVersion $phpVersion = null): void { - $parserFactory = new ParserFactory(); - self::$parser = isset($phpVersion) - ? $parserFactory->createForVersion($phpVersion) - : $parserFactory->createForNewestSupportedVersion(); - - self::$traverser = $traverser = new NodeTraverser(); - $traverser->addVisitor(new NameResolver( - null, - [ - 'preserveOriginalNames' => true, - 'replaceNodes' => false, - ] - )); - $traverser->addVisitor(new RootNamespaceNormalizer()); - - self::$locator = $locator; + self::$locator = $locator; + self::$phpVersion = $phpVersion; + self::$parser = null; + self::$traverser = null; } /** @@ -277,8 +279,8 @@ public static function parseFile(string $fileName, ?string $fileContent = null): throw new ReflectionException("Could not read file: $fileName"); } } - $treeNodes = self::$parser->parse($fileContent) ?? []; - $treeNodes = self::$traverser->traverse($treeNodes); + $treeNodes = self::getParser()->parse($fileContent) ?? []; + $treeNodes = self::getTraverser()->traverse($treeNodes); self::$parsedFiles[$fileName] = $treeNodes; @@ -309,6 +311,33 @@ public static function parseFileNamespace(string $fileName, string $namespaceNam public static function getParser(): Parser { + if (self::$parser === null) { + $parserFactory = new ParserFactory(); + self::$parser = isset(self::$phpVersion) + ? $parserFactory->createForVersion(self::$phpVersion) + : $parserFactory->createForNewestSupportedVersion(); + } + return self::$parser; } + + /** + * Returns the traverser resolving the names of the parsed sources + */ + private static function getTraverser(): NodeTraverser + { + if (self::$traverser === null) { + self::$traverser = new NodeTraverser(); + self::$traverser->addVisitor(new NameResolver( + null, + [ + 'preserveOriginalNames' => true, + 'replaceNodes' => false, + ] + )); + self::$traverser->addVisitor(new RootNamespaceNormalizer()); + } + + return self::$traverser; + } } diff --git a/tests/ReflectionEngineTest.php b/tests/ReflectionEngineTest.php new file mode 100644 index 0000000..e19af33 --- /dev/null +++ b/tests/ReflectionEngineTest.php @@ -0,0 +1,73 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\ComposerLocator; +use PhpParser\Error; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Parser; +use PhpParser\PhpVersion; +use PHPUnit\Framework\TestCase; +use ReflectionProperty; + +class ReflectionEngineTest extends TestCase +{ + protected function tearDown(): void + { + // Restores the default locator and grammar for the following tests + ReflectionEngine::init(new ComposerLocator()); + } + + public function testInitDoesNotCreateTheParser(): void + { + ReflectionEngine::getParser(); + + ReflectionEngine::init(new ComposerLocator()); + + $this->assertNull(new ReflectionProperty(ReflectionEngine::class, 'parser')->getValue()); + $this->assertNull(new ReflectionProperty(ReflectionEngine::class, 'traverser')->getValue()); + } + + public function testParserIsCreatedOnceOnFirstUse(): void + { + ReflectionEngine::init(new ComposerLocator()); + + $parser = ReflectionEngine::getParser(); + + $this->assertInstanceOf(Parser::class, $parser); + $this->assertSame($parser, ReflectionEngine::getParser()); + } + + public function testParserUsesTheGrammarVersionGivenToInit(): void + { + ReflectionEngine::init(new ComposerLocator(), PhpVersion::fromComponents(8, 4)); + + // The pipe operator exists since PHP 8.5, the 8.4 grammar rejects it + $this->expectException(Error::class); + ReflectionEngine::getParser()->parse(' strlen(...);'); + } + + public function testParseFileCreatesTheParserAndResolvesNames(): void + { + ReflectionEngine::init(new ComposerLocator()); + + // A file name that does not exist, so the parsed tree cached for it serves no other test + $fileName = __DIR__ . '/virtual/ReflectionEngineTestFile.php'; + $nodes = ReflectionEngine::parseFile($fileName, 'assertNotEmpty($nodes); + $class = ReflectionEngine::parseFileNamespace($fileName, 'Foo')->stmts[0] ?? null; + $this->assertInstanceOf(Class_::class, $class); + $this->assertSame('Foo\Baz', $class->extends?->getAttribute('resolvedName')?->toString()); + } +}