From 0ed2fe623f4eb085076de24cf736a162a35781f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:15:42 +0000 Subject: [PATCH] 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()); + } +}