Skip to content
Merged
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
69 changes: 49 additions & 20 deletions src/ReflectionEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
73 changes: 73 additions & 0 deletions tests/ReflectionEngineTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);
/**
* Parser Reflection API
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* 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('<?php $length = "value" |> 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, '<?php namespace Foo; class Bar extends Baz {}');

$this->assertNotEmpty($nodes);
$class = ReflectionEngine::parseFileNamespace($fileName, 'Foo')->stmts[0] ?? null;
$this->assertInstanceOf(Class_::class, $class);
$this->assertSame('Foo\Baz', $class->extends?->getAttribute('resolvedName')?->toString());
}
}
Loading