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
2 changes: 2 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
&& !strpos($file->getPathname(), 'tests/Fixtures/TypedProperties.php')
// FQDN in docblock
&& !strpos($file->getPathname(), 'tests/Fixtures/PHP/DocblockAndTypehintTypes.php')
// parameter docblock for PHP 8.6
&& !strpos($file->getPathname(), 'tests/Fixtures/Scratch/Docblocks.php')
;
})
->in(__DIR__);
Expand Down
36 changes: 0 additions & 36 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,6 @@ parameters:
count: 1
path: src/Annotations/Flow.php

-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Processors/AugmentParameters.php

-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Processors/AugmentProperties.php

-
message: '#^Parameter \#1 \$annotation of method OpenApi\\Processors\\DocBlockDescriptions\:\:description\(\) expects OpenApi\\Annotations\\Operation\|OpenApi\\Annotations\\Parameter\|OpenApi\\Annotations\\Schema, OpenApi\\Annotations\\AbstractAnnotation given\.$#'
identifier: argument.type
Expand All @@ -54,12 +42,6 @@ parameters:
count: 1
path: src/Processors/DocBlockDescriptions.php

-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Processors/DocBlockDescriptions.php

-
message: '#^Parameter \#1 \$callback of function spl_autoload_register expects \(callable\(string\)\: void\)\|null, array\{Composer\\Autoload\\ClassLoader, ''findFile''\} given\.$#'
identifier: argument.type
Expand All @@ -83,21 +65,3 @@ parameters:
identifier: method.notFound
count: 1
path: tests/Annotations/AttributesSyncTest.php

-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: tests/Processors/AugmentParametersTest.php

-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: tests/Processors/AugmentRefsTest.php

-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: tests/Processors/DocBlockDescriptionsTest.php
7 changes: 7 additions & 0 deletions src/Analysers/AttributeAnnotationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,14 @@ public function build(\Reflector $reflector, Context $context): array
} else {
$instance->_context->property = $rp->getName();
}
} elseif ($instance instanceof OAT\Parameter) {
if (method_exists($rp, 'getDocComment')) {
if ($comment = $rp->getDocComment()) {
$instance->_context->comment = $comment;
}
}
}

$annotations[] = $instance;
}
}
Expand Down
13 changes: 10 additions & 3 deletions src/Processors/AugmentParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,19 @@ protected function augmentOperationParameters(Analysis $analysis): void
if (!Generator::isDefault($operation->parameters)) {
$tags = [];
$this->parseDocblock($operation->_context->comment, $tags);
$docblockParams = $tags['param'] ?? [];
$operationDocblockParams = $tags['param'] ?? [];

foreach ($operation->parameters as $parameter) {
if (Generator::isDefault($parameter->description)) {
if (array_key_exists($parameter->name, $docblockParams)) {
$details = $docblockParams[$parameter->name];
$typeAndDescription = $this->parseVarLine((string) $parameter->_context->comment);
if ($typeAndDescription['description']) {
$parameter->description = trim($typeAndDescription['description']);
}
}

if (Generator::isDefault($parameter->description)) {
if (array_key_exists($parameter->name, $operationDocblockParams)) {
$details = $operationDocblockParams[$parameter->name];
if ($details['description']) {
$parameter->description = $details['description'];
}
Expand Down
171 changes: 113 additions & 58 deletions src/Processors/Concerns/DocblockTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
use OpenApi\Annotations as OA;
use OpenApi\Attributes as OAT;
use OpenApi\Generator;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\ConstExprParser;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\PhpDocParser\Parser\TokenIterator;
use PHPStan\PhpDocParser\Parser\TypeParser;
use PHPStan\PhpDocParser\ParserConfig;

trait DocblockTrait
{
Expand Down Expand Up @@ -55,70 +67,101 @@ public function isDocblockRoot(OA\AbstractAnnotation $annotation): bool
return false;
}

protected function handleTag(string $line, ?array &$tags = null): void
/**
* Parse a docblock string into a PhpDocNode.
*/
protected function parsePhpDoc(?string $docblock): ?PhpDocNode
{
if (null === $tags) {
return;
if (!$docblock || Generator::isDefault($docblock)) {
return null;
}

// split of tag name
$token = preg_split("@[\s+ ]@u", $line, 2);
if (2 == count($token)) {
$tag = substr($token[0], 1);
$tail = $token[1];
if (!array_key_exists($tag, $tags)) {
$tags[$tag] = [];
}
// Normalize single-star comments to PHPDoc format
$normalized = preg_replace('#^/\*(?!\*)#', '/**', $docblock);

if (false !== ($dpos = strpos($tail, '$'))) {
$type = trim(substr($tail, 0, $dpos));
$token = preg_split("@[\s+ ]@u", substr($tail, $dpos), 2);
$name = trim(substr($token[0], 1));
$description = 2 == count($token) ? trim($token[1]) : null;
// Ensure docblock has proper closing
if (!str_contains((string) $normalized, '*/')) {
$normalized = rtrim((string) $normalized) . '/';
}

$tags[$tag][$name] = [
'type' => $type,
'description' => $description,
];
}
$config = new ParserConfig([]);
$lexer = new Lexer($config);
$phpDocParser = new PhpDocParser(
$config,
new TypeParser($config, $constExprParser = new ConstExprParser($config)),
$constExprParser,
);

try {
$tokens = new TokenIterator($lexer->tokenize($normalized));

return $phpDocParser->parse($tokens);
} catch (\Throwable) {
return null;
}
}

/**
* Format a type node as a compact string (without wrapping parentheses for union/intersection types).
*/
protected function formatType(TypeNode $typeNode): string
{
if ($typeNode instanceof UnionTypeNode) {
return implode('|', array_map(strval(...), $typeNode->types));
}

if ($typeNode instanceof IntersectionTypeNode) {
return implode('&', array_map(strval(...), $typeNode->types));
}

return (string) $typeNode;
}

/**
* Parse a docblock and return the full content/text.
*/
public function parseDocblock(?string $docblock, ?array &$tags = null): string
{
if (Generator::isDefault($docblock)) {
$docNode = $this->parsePhpDoc($docblock);
if (!$docNode) {
return Generator::UNDEFINED;
}

$comment = preg_split('/(\n|\r\n)/', (string) $docblock);
$comment[0] = preg_replace('/[ \t]*\\/\*\*/', '', $comment[0]); // strip '/**'
$ii = count($comment) - 1;
$comment[$ii] = preg_replace('/\*\/[ \t]*$/', '', (string) $comment[$ii]); // strip '*/'
$lines = [];
$append = false;
$skip = false;
foreach ($comment as $line) {
$line = preg_replace('/^\s+\* ?/', '', (string) $line);
if (str_starts_with($tagline = trim((string) $line), '@')) {
$this->handleTag($tagline, $tags);
$skip = true;
// Extract @param tags if requested
if (null !== $tags) {
if (!array_key_exists('param', $tags)) {
$tags['param'] = [];
}
foreach ($docNode->getParamTagValues() as $param) {
$name = ltrim((string) $param->parameterName, '$');
$tags['param'][$name] = [
'type' => (string) $param->type ?: null,
'description' => $param->description !== '' ? $param->description : null,
];
}
if ($skip) {
continue;
foreach ($docNode->getTypelessParamTagValues() as $param) {
$name = ltrim((string) $param->parameterName, '$');
$tags['param'][$name] = [
'type' => null,
'description' => $param->description !== '' ? $param->description : null,
];
}
}

// Extract description from text nodes before first tag
$lines = [];
foreach ($docNode->children as $child) {
if ($child instanceof PhpDocTagNode) {
break;
}
if ($append) {
$ii = count($lines) - 1;
$lines[$ii] = substr((string) $lines[$ii], 0, -1) . $line;
} else {
$lines[] = $line;
if ($child instanceof PhpDocTextNode && $child->text !== '') {
$lines[] = $child->text;
}
$append = (str_ends_with((string) $line, '\\'));
}

$description = trim(implode("\n", $lines));
// Handle line continuation with trailing backslash
$description = preg_replace('/\\\\\n/', '', $description);

return $description === ''
? Generator::UNDEFINED
Expand Down Expand Up @@ -153,7 +196,7 @@ public function extractCommentSummary(string $content): string
}

/**
* An optional longer piece of text providing more details on the associated elements function.
* An optional longer piece of text providing more details on the associated element's function.
*
* @param string $content The full docblock content
*/
Expand All @@ -169,7 +212,7 @@ public function extractCommentDescription(string $content): string
}

$description = '';
if (false !== ($substr = substr($content, strlen((string) $summary)))) {
if (($substr = substr($content, strlen((string) $summary))) !== '') {
$description = trim($substr);
}

Expand All @@ -183,42 +226,54 @@ public function extractCommentDescription(string $content): string
*/
public function parseVarLine(?string $docblock): array
{
$comment = str_replace("\r\n", "\n", (string) $docblock);
$comment = preg_replace('/\*\/[ \t]*$/', '', $comment); // strip '*/'
$result = ['type' => null, 'description' => null];

preg_match('/@var\s+(?<type>[^\s]+)([ \t])?(?<description>.+)?+$/im', (string) $comment, $matches);
$docNode = $this->parsePhpDoc($docblock);
if (!$docNode) {
return $result;
}

$result = array_merge(
['type' => null, 'description' => null],
array_filter($matches, static fn ($key): bool => in_array($key, ['type', 'description']), ARRAY_FILTER_USE_KEY)
);
$varTags = $docNode->getVarTagValues();
if ($varTags) {
$varTag = reset($varTags);
$type = $this->formatType($varTag->type);

$result['type'] = $type !== '' ? $type : null;
$result['description'] = $varTag->description !== '' ? trim((string) $varTag->description) : null;
}

return array_map(static fn (?string $value): ?string => null !== $value ? trim($value) : null, $result);
return $result;
}

/**
* Extract example text from a <code>@example</code> dockblock line.
*/
public function extractExampleDescription(string $docblock): ?string
{
if (!$docblock || Generator::isDefault($docblock)) {
$docNode = $this->parsePhpDoc($docblock);
if (!$docNode) {
return null;
}

preg_match('/@example\s+([ \t])?(?<example>.+)?$/im', $docblock, $matches);
foreach ($docNode->getTagsByName('@example') as $tag) {
$value = (string) $tag->value;

return $value !== '' ? trim($value) : null;
}

return $matches['example'] ?? null;
return null;
}

/**
* Returns true if the <code>\@deprecated</code> tag is present, false otherwise.
* Returns true if the <code>@deprecated</code> tag is present, false otherwise.
*/
public function isDeprecated(?string $docblock): bool
{
if (!$docblock || Generator::isDefault($docblock)) {
$docNode = $this->parsePhpDoc($docblock);
if (!$docNode) {
return false;
}

return 1 === preg_match('/@deprecated\s+([ \t])?(?<deprecated>.+)?$/im', $docblock);
return count($docNode->getDeprecatedTagValues()) > 0;
}
}
Loading