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
36 changes: 36 additions & 0 deletions .github/workflows/phpstan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: "PHPStan analysis"

permissions:
contents: read

on:
pull_request:
push:
branches:
- master

jobs:
build:
name: "PHPStan analysis - PHP8.4"
runs-on: ubuntu-latest
steps:
- name: "Checkout"
uses: actions/checkout@v4
- name: "Install PHP"
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
ini-values: memory_limit=-1
tools: composer:v2
- name: "Cache dependencies"
uses: actions/cache@v4
with:
path: |
~/.composer/cache
vendor
key: "php-8.4"
restore-keys: "php-8.4"
- name: "Install dependencies"
run: "composer install --no-interaction --no-progress"
- name: "Static analysis"
run: "vendor/bin/phpstan analyze --memory-limit=1G"
3 changes: 1 addition & 2 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ jobs:
- "lowest"
- "highest"
php-version:
- "8.5"
- "8.4"
- "8.3"
- "8.2"
operating-system:
- "ubuntu-latest"

Expand Down
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

Parser Reflection is a **deprecated** PHP library (deprecated in favor of [BetterReflection](https://github.com/Roave/BetterReflection)) that extends PHP's internal reflection classes using nikic/PHP-Parser for static analysis. It reflects PHP code without loading classes into memory by parsing source files into an AST.

Requires PHP >=8.4. Namespace: `Go\ParserReflection\`.

## Commands

```bash
# Install dependencies (slow locally — see note below)
composer install --prefer-source --no-interaction

# Run tests (~6 seconds, ~10,500 tests)
vendor/bin/phpunit

# Run a single test file
vendor/bin/phpunit tests/ReflectionClassTest.php

# Run a specific test method
vendor/bin/phpunit --filter testMethodName

# Static analysis (~5 seconds, 18 known existing errors are normal)
vendor/bin/phpstan analyse src --no-progress
```

> **Note on `composer install` locally**: due to GitHub API rate limits, use `--prefer-source` and set a long timeout: `composer config --global process-timeout 2000`. In CI, standard `composer install` works fine with GitHub tokens.

## Architecture

### Request flow

When you call `new ReflectionClass('SomeClass')`:
1. `ReflectionClass` asks `ReflectionEngine` for the class's AST node
2. `ReflectionEngine` uses the registered `LocatorInterface` to find the file
3. The file is parsed by PHP-Parser into an AST
4. Two node visitors run: `NameResolver` (resolves FQCNs) and `RootNamespaceNormalizer` (normalizes global namespace)
5. The resulting `ClassLike` AST node is stored in `ReflectionEngine::$parsedFiles` (in-memory LRU cache)
6. The node is wrapped in the appropriate reflection class

### Key components

- **`ReflectionEngine`** (`src/ReflectionEngine.php`) — static class; central hub. Owns the PHP-Parser instance, AST cache, and locator. Entry points: `parseFile()`, `parseClass()`, `parseClassMethod()`, etc.
- **`LocatorInterface`** / **`ComposerLocator`** — pluggable class file finder. `ComposerLocator` delegates to Composer's classmap/autoloader. `bootstrap.php` auto-registers `ComposerLocator` on load.
- **Reflection classes** (`src/Reflection*.php`) — each extends its PHP internal counterpart (e.g. `ReflectionClass extends \ReflectionClass`) and holds an AST node. Methods that require a live object (e.g. `invoke()`) trigger actual class loading and fall back to native reflection.
- **Traits** (`src/Traits/`) — shared logic extracted to avoid duplication:
- `ReflectionClassLikeTrait` — used by `ReflectionClass`; implements most class inspection methods against the AST
- `ReflectionFunctionLikeTrait` — shared by `ReflectionMethod` and `ReflectionFunction`
- `InitializationTrait` — lazy initialization of AST node from engine
- `InternalPropertiesEmulationTrait` — makes `var_dump`/serialization look like native reflection
- `AttributeResolverTrait` — resolves PHP 8 attributes from AST nodes
- **Resolvers** (`src/Resolver/`) — `NodeExpressionResolver` evaluates constant expressions in the AST (used for default values, constants). `TypeExpressionResolver` resolves type AST nodes into reflection type objects.
- **`ReflectionFile` / `ReflectionFileNamespace`** — library-specific (not in native PHP reflection). Allow reflecting arbitrary PHP files and iterating their namespaces, classes, functions without knowing class names in advance.

### Test structure

Tests in `tests/` mirror the reflection class names (e.g. `ReflectionClassTest.php`). PHP version-specific stub files in `tests/Stub/` (e.g. `FileWithClasses84.php`) contain the PHP code being reflected. Tests extend `AbstractTestCase` which sets up the `ReflectionEngine` with a `ComposerLocator`.

### CI

GitHub Actions (`.github/workflows/phpunit.yml`) runs PHPUnit on PHP 8.2, 8.3, 8.4 with both lowest and highest dependency versions.
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@
}
},
"require": {
"php": ">=8.2",
"php": ">=8.4",
"nikic/php-parser": "^5.4"
},
"require-dev": {
"phpunit/phpunit": "^11.0.7",
"phpstan/phpstan": "^2.0",
"tracy/tracy": "^2.10",
"rector/rector": "^1.0",
"rector/rector-php-parser": "^0.14.0"
"rector/rector": "^2.0"
},
"extra": {
"branch-alias": {
Expand Down
19 changes: 19 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
parameters:
level: 10
paths:
- src
ignoreErrors:
# Both classes are final, so "might have hooks in a subclass" is a false positive
- identifier: unset.possiblyHookedProperty
path: src/ReflectionFunction.php
- identifier: unset.possiblyHookedProperty
path: src/ReflectionMethod.php
# Class names from the AST are semantically class-strings by construction (they come from
# parsed PHP class declarations), but PHPStan cannot verify this without autoloading, which
# would violate the library's contract of reflecting code without loading classes.
- identifier: return.type
path: src/Traits/ReflectionClassLikeTrait.php
message: '#resolveAsClassString#'
- identifier: return.type
path: src/Traits/AttributeResolverTrait.php
message: '#resolveAttributeClassName#'
11 changes: 6 additions & 5 deletions src/Instrument/PathResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ class PathResolver
/**
* Custom replacement for realpath() and stream_resolve_include_path()
*
* @param string|array $somePath Path without normalization or array of paths
* @param bool $shouldCheckExistence Flag for checking existence of resolved filename
* @param string|array<int, string> $somePath Path without normalization or array of paths
* @param bool $shouldCheckExistence Flag for checking existence of resolved filename
*
* @return array|bool|string
* @return ($somePath is array ? array<int, string|false> : string|false)
*/
public static function realpath($somePath, $shouldCheckExistence = false)
{
Expand All @@ -50,13 +50,14 @@ public static function realpath($somePath, $shouldCheckExistence = false)
return $fastPath;
}

$isRelative = !$pathScheme && ($path[0] !== '/') && ($path[1] !== ':');
$isWindowsAbsolutePath = $path !== null && strlen($path) > 1 && preg_match('/^[A-Za-z]:/', $path) === 1;
$isRelative = !$pathScheme && $path !== null && !str_starts_with($path, '/') && !$isWindowsAbsolutePath;
if ($isRelative) {
$path = getcwd() . DIRECTORY_SEPARATOR . $path;
}

// resolve path parts (single dot, double dot and double delimiters)
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path ?? '');
if (strpos($path, '.') !== false) {
$parts = explode(DIRECTORY_SEPARATOR, $path);
$absolutes = [];
Expand Down
14 changes: 6 additions & 8 deletions src/Locator/CallableLocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,18 @@

namespace Go\ParserReflection\Locator;

use Closure;
use Go\ParserReflection\LocatorInterface;

/**
* Locator, that can find a file for the given class name by asking composer
* @see \Go\ParserReflection\Locator\CallableLocatorTest
*/
class CallableLocator implements LocatorInterface
final readonly class CallableLocator implements LocatorInterface
{
/**
* @var callable
*/
private $callable;

public function __construct(callable $callable)
public function __construct(private Closure $callable)
{
$this->callable = $callable;
}

/**
Expand All @@ -37,6 +33,8 @@ public function __construct(callable $callable)
*/
public function locateClass(string $className): false|string
{
return call_user_func($this->callable, ltrim($className, '\\'));
$result = ($this->callable)(ltrim($className, '\\'));

return is_string($result) ? $result : false;
}
}
9 changes: 4 additions & 5 deletions src/Locator/ComposerLocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
*/
class ComposerLocator implements LocatorInterface
{
/**
* @var ClassLoader
*/
private $loader;

private ClassLoader $loader;

public function __construct(?ClassLoader $composerLoader = null)
{
Expand Down Expand Up @@ -54,7 +52,8 @@ public function locateClass(string $className): false|string
{
$filePath = $this->loader->findFile(ltrim($className, '\\'));
if (!empty($filePath)) {
$filePath = PathResolver::realpath($filePath);
$resolvedPath = PathResolver::realpath($filePath);
$filePath = is_string($resolvedPath) ? $resolvedPath : false;
}

return $filePath;
Expand Down
7 changes: 6 additions & 1 deletion src/NodeVisitor/RootNamespaceNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace Go\ParserReflection\NodeVisitor;

use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Declare_;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\NodeVisitorAbstract;
Expand Down Expand Up @@ -45,7 +46,11 @@ public function beforeTraverse(array $nodes)
}
}
// Wrap all statements into the namespace block
$globalNamespaceNode = new Namespace_(null, array_slice($nodes, $lastDeclareOffset));
$stmts = array_values(array_filter(
array_slice($nodes, $lastDeclareOffset),
static fn ($node) => $node instanceof Stmt
));
$globalNamespaceNode = new Namespace_(null, $stmts);
// Replace top-level nodes with namespaced node
array_splice($nodes, $lastDeclareOffset, count($nodes), [$globalNamespaceNode]);

Expand Down
20 changes: 16 additions & 4 deletions src/NodeVisitor/StaticVariablesCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace Go\ParserReflection\NodeVisitor;

use Go\ParserReflection\ReflectionFileNamespace;
use Go\ParserReflection\Resolver\NodeExpressionResolver;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
Expand All @@ -23,17 +24,22 @@ class StaticVariablesCollector extends NodeVisitorAbstract
{
/**
* Reflection context, eg. ReflectionClass, ReflectionMethod, etc
*
* @var \ReflectionClass<object>|\ReflectionFunction|\ReflectionMethod|\ReflectionClassConstant|\ReflectionParameter|\ReflectionAttribute<object>|\ReflectionProperty|ReflectionFileNamespace|null
*/
private mixed $context;
private \ReflectionClass|\ReflectionFunction|\ReflectionMethod|\ReflectionClassConstant|\ReflectionParameter|\ReflectionAttribute|\ReflectionProperty|ReflectionFileNamespace|null $context;

/**
* @var array<string, mixed>
*/
private array $staticVariables = [];

/**
* Default constructor
*
* @param mixed $context Reflection context, eg. ReflectionClass, ReflectionMethod, etc
* @param \ReflectionClass<object>|\ReflectionFunction|\ReflectionMethod|\ReflectionClassConstant|\ReflectionParameter|\ReflectionAttribute<object>|\ReflectionProperty|ReflectionFileNamespace|null $context Reflection context, eg. ReflectionClass, ReflectionMethod, etc
*/
public function __construct(mixed $context)
public function __construct(\ReflectionClass|\ReflectionFunction|\ReflectionMethod|\ReflectionClassConstant|\ReflectionParameter|\ReflectionAttribute|\ReflectionProperty|ReflectionFileNamespace|null $context)
{
$this->context = $context;
}
Expand Down Expand Up @@ -62,7 +68,11 @@ public function enterNode(Node $node)

if ($staticVariable->var->name instanceof Node\Expr) {
$expressionSolver->process($staticVariable->var->name);
$name = $expressionSolver->getValue();
$resolvedName = $expressionSolver->getValue();
if (!is_string($resolvedName)) {
throw new \InvalidArgumentException("Unknown value for the key, " . gettype($resolvedName) . " has given, but string is expected");
}
$name = $resolvedName;
} else {
$name = $staticVariable->var->name;
}
Expand All @@ -75,6 +85,8 @@ public function enterNode(Node $node)

/**
* Returns an associative map of static variables in the method/function body
*
* @return array<string, mixed>
*/
public function getStaticVariables(): array
{
Expand Down
36 changes: 30 additions & 6 deletions src/ReflectionAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,44 @@

/**
* ref original usage https://3v4l.org/duaQI
*
* @extends \ReflectionAttribute<object>
*/
class ReflectionAttribute extends BaseReflectionAttribute
{
/**
* Fully-qualified attribute class name.
*
* @var class-string<object>
*/
private string $attributeName;

/**
* @param class-string<object> $attributeName
* @param array<int, mixed> $arguments
*/
public function __construct(
private string $attributeName,
string $attributeName,
private ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionClassConstant|ReflectionFunction|ReflectionParameter $reflector,
private array $arguments,
private bool $isRepeated,
) {
$this->attributeName = $attributeName;
}

public function getNode(): Node\Attribute
{
/** @var Class_|ClassMethod|PropertyItem|ClassConst|Function_|Param $node */
$node = $this->reflector->getNode();
$reflectorNode = $this->reflector->getNode();

// attrGroups only exists in Property Stmt
if ($node instanceof PropertyItem) {
// attrGroups only exists in Property Stmt (not PropertyItem), so switch to the type node
if ($reflectorNode instanceof PropertyItem && $this->reflector instanceof ReflectionProperty) {
$node = $this->reflector->getTypeNode();
} else {
$node = $reflectorNode;
}

if ($node instanceof PropertyItem) {
throw new ReflectionException('ReflectionAttribute cannot resolve attrGroups from a PropertyItem node');
}

$nodeExpressionResolver = new NodeExpressionResolver($this);
Expand All @@ -52,7 +71,10 @@ public function getNode(): Node\Attribute
$attributeNodeName = $attr->name;
// Unpack fully-resolved class name from attribute if we have it
if ($attributeNodeName->hasAttribute('resolvedName')) {
$attributeNodeName = $attributeNodeName->getAttribute('resolvedName');
$resolvedName = $attributeNodeName->getAttribute('resolvedName');
if ($resolvedName instanceof \PhpParser\Node\Name) {
$attributeNodeName = $resolvedName;
}
}
if ($attributeNodeName->toString() !== $this->attributeName) {
continue;
Expand Down Expand Up @@ -82,6 +104,8 @@ public function isRepeated(): bool

/**
* {@inheritDoc}
*
* @return array<int, mixed>
*/
public function getArguments(): array
{
Expand Down
Loading
Loading