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
34 changes: 33 additions & 1 deletion docs/reflection_parameter.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,37 @@ ReflectionParameter
==============

The `ReflectionParameter` class reports an information about an parameter. This class is available in the standard PHP, so for any questions, please look at documentation for [`ReflectionParameter`][0]


Parameter doc comments
---------

PHP 8.6 allows doc comments on parameters and exposes them via `ReflectionParameter::getDocComment(): string|false`.
This method is implemented statically from the AST, so it is available on every supported PHP version, not only on 8.6:

```php
function store(
/** @param Book[] $books */
array $books,
): void {}
```

The engine returns the last doc comment that belongs to the parameter declaration in source order, which matches the
native behaviour for doc comments placed before the parameter, before or after its attributes, before its type and
inside its default value expression.

Known limitation: a doc comment written *after* the parameter it documents is reported by native reflection as
belonging to that parameter, but PHP-Parser attaches every comment to the node that *follows* it and drops a comment
that sits before the separating comma altogether. Such a trailing doc comment is therefore always lost:

```php
function lastParameter(string $a /** doc */) {}
// native: $a => '/** doc */' engine: $a => false

function nextParameter(string $a /** doc */, string $b) {}
// native: $a => '/** doc */', $b => false engine: $a => false, $b => false
```

The comment is not mis-attributed to the following parameter, it simply never reaches the AST. Recovering it is not
possible from the AST alone, because the position of the separating comma is not available on the parameter nodes.

[0]: http://php.net/manual/en/class.reflectionparameter.php
36 changes: 36 additions & 0 deletions src/ReflectionParameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use Go\ParserReflection\Traits\InternalPropertiesEmulationTrait;
use Go\ParserReflection\Resolver\NodeExpressionResolver;
use Go\ParserReflection\Resolver\TypeExpressionResolver;
use PhpParser\Comment\Doc;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\BinaryOp\Concat;
Expand All @@ -24,6 +26,7 @@
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Param;
use PhpParser\NodeFinder;
use PhpParser\PrettyPrinter\Standard;
use ReflectionFunctionAbstract;
use ReflectionParameter as BaseReflectionParameter;
Expand Down Expand Up @@ -329,6 +332,39 @@ public function getDefaultValueExpression(): ?string
return $this->defaultValueConstExpr;
}

/**
* Returns the doc comment attached to this parameter or false if there is no doc comment.
*
* Doc comments on parameters are a PHP 8.6 feature, exposed natively by
* \ReflectionParameter::getDocComment(). PHP-Parser attaches a doc comment to the `Param`
* node itself only when the comment directly precedes the parameter declaration. When the
* comment sits inside the declaration (for example after an attribute group, after the type
* or inside a default value expression) it ends up on a nested node instead. Therefore the
* whole parameter sub-tree is scanned and the last doc comment in source order wins, which is
* exactly how PHP itself resolves the doc comment of a parameter.
*/
public function getDocComment(): string|false
{
$lastDocComment = null;
$lastDocCommentPos = -1;

$parameterSubTree = (new NodeFinder())->findInstanceOf($this->parameterNode, Node::class);
foreach ($parameterSubTree as $node) {
foreach ($node->getComments() as $comment) {
if (!$comment instanceof Doc) {
continue;
}
$commentPosition = $comment->getStartFilePos();
if ($commentPosition >= $lastDocCommentPos) {
$lastDocCommentPos = $commentPosition;
$lastDocComment = $comment;
}
}
}

return $lastDocComment?->getText() ?? false;
}

/**
* @inheritDoc
*/
Expand Down
188 changes: 187 additions & 1 deletion tests/ReflectionParameterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -342,15 +342,201 @@ public function testParameterWithStaticMethodFccDefaultValue(): void
$this->assertStringContainsString('ReflectionEngine::locateClassFile(...)', (string) $parsedParameter);
}

/**
* Doc comments on parameters are a PHP 8.6 feature (native ReflectionParameter::getDocComment()),
* but the reflection engine resolves them statically on every supported PHP version.
*
* @param string|array{0: string, 1: string} $functionReference Function name or [class, method] pair
*/
#[DataProvider('parameterDocCommentsDataProvider')]
public function testGetDocComment(
ReflectionParameter $parsedParameter,
string|array $functionReference,
string|false $expectedDocComment
): void {
$this->assertSame(
$expectedDocComment,
$parsedParameter->getDocComment(),
"getDocComment() for parameter \${$parsedParameter->getName()} should be equal"
);

if (PHP_VERSION_ID >= 80600) {
$originalRefParameter = new \ReflectionParameter($functionReference, $parsedParameter->getName());
$this->assertSame(
$originalRefParameter->getDocComment(),
$parsedParameter->getDocComment(),
"getDocComment() for parameter \${$parsedParameter->getName()} should match native reflection"
);
}
}

/**
* A doc comment written *after* the parameter it belongs to is a known parity gap.
*
* PHP itself remembers the last doc comment token seen while the parameter rule is reduced,
* therefore a trailing comment still belongs to the preceding parameter. PHP-Parser instead
* attaches every comment to the node that *follows* it, so a trailing comment never reaches
* the `Param` node and the engine reports `false`.
*/
public function testTrailingDocCommentIsAKnownParityGap(): void
{
$parsedFunction = self::getStub86Namespace()->getFunction('parameterWithTrailingDocComment86');
$parsedParameter = $parsedFunction->getParameters()[0];

$this->assertSame('trailing', $parsedParameter->getName());
$this->assertFalse($parsedParameter->getDocComment());

if (PHP_VERSION_ID >= 80600) {
$originalRefParameter = new \ReflectionParameter($parsedFunction->getName(), 'trailing');
$this->assertSame(
'/** trailing doc comment on the last parameter of the list */',
$originalRefParameter->getDocComment(),
'Native reflection is expected to still report the trailing doc comment'
);
}
}

/**
* The same known parity gap when another parameter follows the trailing doc comment.
*
* PHP-Parser discards a comment that sits between the end of a parameter and the separating
* comma altogether: it reaches neither the documented parameter nor the following one, so the
* engine reports `false` for both. Native reflection instead reports the comment for the
* parameter it follows. Both sides are pinned so a future change in php-src or in PHP-Parser
* is noticed immediately.
*/
public function testTrailingDocCommentIsLostWhenAnotherParameterFollows(): void
{
$parsedFunction = self::getStub86Namespace()->getFunction('twoParametersWithTrailingDocComment86');
[$parsedFirst, $parsedSecond] = $parsedFunction->getParameters();

$this->assertSame('first', $parsedFirst->getName());
$this->assertSame('second', $parsedSecond->getName());

// The comment is dropped by PHP-Parser, so it is not mis-attributed to the next parameter
$this->assertFalse($parsedFirst->getDocComment());
$this->assertFalse($parsedSecond->getDocComment());

if (PHP_VERSION_ID >= 80600) {
$originalFirst = new \ReflectionParameter($parsedFunction->getName(), 'first');
$originalSecond = new \ReflectionParameter($parsedFunction->getName(), 'second');

// Native reflection reports the comment for the parameter that precedes it
$this->assertSame(
'/** trailing doc comment written after the first parameter */',
$originalFirst->getDocComment()
);
$this->assertFalse($originalSecond->getDocComment());
}
}

/**
* Provides list in the form [ReflectionParameter, function reference, expected doc comment]
*/
public static function parameterDocCommentsDataProvider(): \Generator
{
$expectedDocComments = [
'parametersWithDocComments86' => [
'documented' => '/** @param string $documented simple leading doc comment */',
'undocumented' => false,
'blockCommented' => false,
'lineCommented' => false,
'variadic' => '/** @param array<int> $variadic variadic doc comment */',
],
'parametersWithReferencesAndAttributes86' => [
'byReference' => '/** @param array<string> $byReference by-reference doc comment */',
'docBeforeAttribute' => '/** doc comment placed before the attribute */',
'docAfterAttribute' => '/** doc comment placed after the attribute */',
'lastDocCommentWins' => '/** last doc comment wins */',
'docCommentThenBlockComment' => '/** doc comment followed by a regular comment */',
],
'parametersWithDefaultsAndTypes86' => [
'nullableWithDefault' => '/** @param string|null $nullableWithDefault nullable doc comment */',
'unionTyped' => '/** @param int|float $unionTyped union type doc comment */',
'arrayDefault' => '/** @param array<mixed> $arrayDefault doc comment for array default */',
],
];

$expectedMethodDocComments = [
'ClassWithDocumentedParameters86::__construct' => [
'promoted' => '/** @var string promoted property doc comment */',
'promotedProtected' => '/** @var int promoted protected property doc comment */',
'promotedUndocumented' => false,
],
'ClassWithDocumentedParameters86::documentedMethod' => [
'first' => false,
'second' => '/** @param string $second method parameter doc comment */',
],
'ClassWithDocumentedParameters86::documentedStaticMethod' => [
'instance' => '/** @param self $instance static method parameter doc comment */',
],
];

$parsedNamespace = self::getStub86Namespace();

foreach ($expectedDocComments as $functionName => $expectedParameters) {
$parsedFunction = $parsedNamespace->getFunction($functionName);
foreach ($parsedFunction->getParameters() as $parsedParameter) {
$parameterName = $parsedParameter->getName();
if (!array_key_exists($parameterName, $expectedParameters)) {
throw new \LogicException("Missing expectation for {$functionName}(\${$parameterName})");
}
yield "{$functionName}(\${$parameterName})" => [
$parsedParameter,
$parsedFunction->getName(),
$expectedParameters[$parameterName],
];
}
}

foreach ($expectedMethodDocComments as $methodReference => $expectedParameters) {
[$className, $methodName] = explode('::', $methodReference);
$parsedClass = $parsedNamespace->getClass('Go\ParserReflection\Stub\\' . $className);
$parsedMethod = $parsedClass->getMethod($methodName);
foreach ($parsedMethod->getParameters() as $parsedParameter) {
$parameterName = $parsedParameter->getName();
if (!array_key_exists($parameterName, $expectedParameters)) {
throw new \LogicException("Missing expectation for {$methodReference}(\${$parameterName})");
}
yield "{$methodReference}(\${$parameterName})" => [
$parsedParameter,
[$parsedClass->getName(), $methodName],
$expectedParameters[$parameterName],
];
}
}
}

/**
* Parses (and loads) the stub file with documented parameters
*/
private static function getStub86Namespace(): ReflectionFileNamespace
{
$fileName = __DIR__ . '/Stub/FileWithParameters86.php';
$reflectionFile = new ReflectionFile($fileName);

// The file only contains ordinary comments, so it can be safely loaded on any PHP version
include_once $fileName;

return $reflectionFile->getFileNamespace('Go\ParserReflection\Stub');
}

/**
* @inheritDoc
*/
static protected function getGettersToCheck(): array
{
return [
$getters = [
'isOptional', 'isPassedByReference', 'isDefaultValueAvailable',
'getPosition', 'canBePassedByValue', 'allowsNull', 'getDefaultValue', 'getDefaultValueConstantName',
'isDefaultValueConstant', 'isVariadic', 'isPromoted', 'hasType', '__toString'
];

if (PHP_VERSION_ID >= 80600) {
// Native ReflectionParameter::getDocComment() only exists since PHP 8.6
$getters[] = 'getDocComment';
}

return $getters;
}
}
Loading