From fe42e69446a452c9d6652d6584e6b2627c728fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:15:42 +0000 Subject: [PATCH 1/2] perf(engine): create the parser and the name resolver on the first parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer "files" bootstrap of this package calls ReflectionEngine::init() on every request of an application that installs it, and init() built the PHP-Parser parser, lexer and name resolving traverser right away. Most requests never parse anything: in goaop/framework, parsing only happens on a cache miss. init() now only stores the locator and the grammar version, getParser() and parseFile() create the parser and the traverser on first use. The ComposerLocator stays eager on purpose: it must find composer's own class loader before other loaders wrap it. Warm request with opcache (built-in server, median of 200): requiring vendor/autoload.php of goaop/framework takes 87-91 instead of 229-242 µs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019bYFRwMWKndKaj6SemwDn2 --- src/ReflectionEngine.php | 69 ++++++++++++++++++++++---------- tests/ReflectionEngineTest.php | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 tests/ReflectionEngineTest.php 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()); + } +} From 2c6b46bc2a1a0a9ede6c89595f976a507a0c6a3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:17:46 +0000 Subject: [PATCH 2/2] fix(constant): print boolean constants as true/false on PHP 8.6, like master PHP 8.6 native reflection prints boolean constant values as "true" and "false" instead of the string casts "1" and "", so the experimental PHP 8.6 jobs of 4.x fail ReflectionClassTest::testGetReflectionConstant. Ported from the same rendering on master, with its PHPStan ignores for releases that do not know PHP 8.6 yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019bYFRwMWKndKaj6SemwDn2 --- phpstan.neon | 10 ++++++++++ src/ReflectionClassConstant.php | 12 +++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) 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 ); }