Skip to content

Commit 6f1dd23

Browse files
authored
Handle parameter dockblocks (#1998)
Also refactor `DocblockTrait` to using the `phpstan` docblock parser
1 parent 2cc7177 commit 6f1dd23

14 files changed

Lines changed: 624 additions & 123 deletions

.php-cs-fixer.dist.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
&& !strpos($file->getPathname(), 'tests/Fixtures/TypedProperties.php')
2020
// FQDN in docblock
2121
&& !strpos($file->getPathname(), 'tests/Fixtures/PHP/DocblockAndTypehintTypes.php')
22+
// parameter docblock for PHP 8.6
23+
&& !strpos($file->getPathname(), 'tests/Fixtures/Scratch/Docblocks.php')
2224
;
2325
})
2426
->in(__DIR__);

phpstan-baseline.neon

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,6 @@ parameters:
3030
count: 1
3131
path: src/Annotations/Flow.php
3232

33-
-
34-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
35-
identifier: notIdentical.alwaysTrue
36-
count: 1
37-
path: src/Processors/AugmentParameters.php
38-
39-
-
40-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
41-
identifier: notIdentical.alwaysTrue
42-
count: 1
43-
path: src/Processors/AugmentProperties.php
44-
4533
-
4634
message: '#^Parameter \#1 \$annotation of method OpenApi\\Processors\\DocBlockDescriptions\:\:description\(\) expects OpenApi\\Annotations\\Operation\|OpenApi\\Annotations\\Parameter\|OpenApi\\Annotations\\Schema, OpenApi\\Annotations\\AbstractAnnotation given\.$#'
4735
identifier: argument.type
@@ -54,12 +42,6 @@ parameters:
5442
count: 1
5543
path: src/Processors/DocBlockDescriptions.php
5644

57-
-
58-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
59-
identifier: notIdentical.alwaysTrue
60-
count: 1
61-
path: src/Processors/DocBlockDescriptions.php
62-
6345
-
6446
message: '#^Parameter \#1 \$callback of function spl_autoload_register expects \(callable\(string\)\: void\)\|null, array\{Composer\\Autoload\\ClassLoader, ''findFile''\} given\.$#'
6547
identifier: argument.type
@@ -83,21 +65,3 @@ parameters:
8365
identifier: method.notFound
8466
count: 1
8567
path: tests/Annotations/AttributesSyncTest.php
86-
87-
-
88-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
89-
identifier: notIdentical.alwaysTrue
90-
count: 1
91-
path: tests/Processors/AugmentParametersTest.php
92-
93-
-
94-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
95-
identifier: notIdentical.alwaysTrue
96-
count: 1
97-
path: tests/Processors/AugmentRefsTest.php
98-
99-
-
100-
message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#'
101-
identifier: notIdentical.alwaysTrue
102-
count: 1
103-
path: tests/Processors/DocBlockDescriptionsTest.php

src/Analysers/AttributeAnnotationFactory.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,14 @@ public function build(\Reflector $reflector, Context $context): array
9595
} else {
9696
$instance->_context->property = $rp->getName();
9797
}
98+
} elseif ($instance instanceof OAT\Parameter) {
99+
if (method_exists($rp, 'getDocComment')) {
100+
if ($comment = $rp->getDocComment()) {
101+
$instance->_context->comment = $comment;
102+
}
103+
}
98104
}
105+
99106
$annotations[] = $instance;
100107
}
101108
}

src/Processors/AugmentParameters.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,19 @@ protected function augmentOperationParameters(Analysis $analysis): void
136136
if (!Generator::isDefault($operation->parameters)) {
137137
$tags = [];
138138
$this->parseDocblock($operation->_context->comment, $tags);
139-
$docblockParams = $tags['param'] ?? [];
139+
$operationDocblockParams = $tags['param'] ?? [];
140140

141141
foreach ($operation->parameters as $parameter) {
142142
if (Generator::isDefault($parameter->description)) {
143-
if (array_key_exists($parameter->name, $docblockParams)) {
144-
$details = $docblockParams[$parameter->name];
143+
$typeAndDescription = $this->parseVarLine((string) $parameter->_context->comment);
144+
if ($typeAndDescription['description']) {
145+
$parameter->description = trim($typeAndDescription['description']);
146+
}
147+
}
148+
149+
if (Generator::isDefault($parameter->description)) {
150+
if (array_key_exists($parameter->name, $operationDocblockParams)) {
151+
$details = $operationDocblockParams[$parameter->name];
145152
if ($details['description']) {
146153
$parameter->description = $details['description'];
147154
}

src/Processors/Concerns/DocblockTrait.php

Lines changed: 113 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@
99
use OpenApi\Annotations as OA;
1010
use OpenApi\Attributes as OAT;
1111
use OpenApi\Generator;
12+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
13+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
14+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
15+
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
16+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
17+
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
18+
use PHPStan\PhpDocParser\Lexer\Lexer;
19+
use PHPStan\PhpDocParser\Parser\ConstExprParser;
20+
use PHPStan\PhpDocParser\Parser\PhpDocParser;
21+
use PHPStan\PhpDocParser\Parser\TokenIterator;
22+
use PHPStan\PhpDocParser\Parser\TypeParser;
23+
use PHPStan\PhpDocParser\ParserConfig;
1224

1325
trait DocblockTrait
1426
{
@@ -55,70 +67,101 @@ public function isDocblockRoot(OA\AbstractAnnotation $annotation): bool
5567
return false;
5668
}
5769

58-
protected function handleTag(string $line, ?array &$tags = null): void
70+
/**
71+
* Parse a docblock string into a PhpDocNode.
72+
*/
73+
protected function parsePhpDoc(?string $docblock): ?PhpDocNode
5974
{
60-
if (null === $tags) {
61-
return;
75+
if (!$docblock || Generator::isDefault($docblock)) {
76+
return null;
6277
}
6378

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

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

79-
$tags[$tag][$name] = [
80-
'type' => $type,
81-
'description' => $description,
82-
];
83-
}
87+
$config = new ParserConfig([]);
88+
$lexer = new Lexer($config);
89+
$phpDocParser = new PhpDocParser(
90+
$config,
91+
new TypeParser($config, $constExprParser = new ConstExprParser($config)),
92+
$constExprParser,
93+
);
94+
95+
try {
96+
$tokens = new TokenIterator($lexer->tokenize($normalized));
97+
98+
return $phpDocParser->parse($tokens);
99+
} catch (\Throwable) {
100+
return null;
84101
}
85102
}
86103

104+
/**
105+
* Format a type node as a compact string (without wrapping parentheses for union/intersection types).
106+
*/
107+
protected function formatType(TypeNode $typeNode): string
108+
{
109+
if ($typeNode instanceof UnionTypeNode) {
110+
return implode('|', array_map(strval(...), $typeNode->types));
111+
}
112+
113+
if ($typeNode instanceof IntersectionTypeNode) {
114+
return implode('&', array_map(strval(...), $typeNode->types));
115+
}
116+
117+
return (string) $typeNode;
118+
}
119+
87120
/**
88121
* Parse a docblock and return the full content/text.
89122
*/
90123
public function parseDocblock(?string $docblock, ?array &$tags = null): string
91124
{
92-
if (Generator::isDefault($docblock)) {
125+
$docNode = $this->parsePhpDoc($docblock);
126+
if (!$docNode) {
93127
return Generator::UNDEFINED;
94128
}
95129

96-
$comment = preg_split('/(\n|\r\n)/', (string) $docblock);
97-
$comment[0] = preg_replace('/[ \t]*\\/\*\*/', '', $comment[0]); // strip '/**'
98-
$ii = count($comment) - 1;
99-
$comment[$ii] = preg_replace('/\*\/[ \t]*$/', '', (string) $comment[$ii]); // strip '*/'
100-
$lines = [];
101-
$append = false;
102-
$skip = false;
103-
foreach ($comment as $line) {
104-
$line = preg_replace('/^\s+\* ?/', '', (string) $line);
105-
if (str_starts_with($tagline = trim((string) $line), '@')) {
106-
$this->handleTag($tagline, $tags);
107-
$skip = true;
130+
// Extract @param tags if requested
131+
if (null !== $tags) {
132+
if (!array_key_exists('param', $tags)) {
133+
$tags['param'] = [];
134+
}
135+
foreach ($docNode->getParamTagValues() as $param) {
136+
$name = ltrim((string) $param->parameterName, '$');
137+
$tags['param'][$name] = [
138+
'type' => (string) $param->type ?: null,
139+
'description' => $param->description !== '' ? $param->description : null,
140+
];
108141
}
109-
if ($skip) {
110-
continue;
142+
foreach ($docNode->getTypelessParamTagValues() as $param) {
143+
$name = ltrim((string) $param->parameterName, '$');
144+
$tags['param'][$name] = [
145+
'type' => null,
146+
'description' => $param->description !== '' ? $param->description : null,
147+
];
148+
}
149+
}
150+
151+
// Extract description from text nodes before first tag
152+
$lines = [];
153+
foreach ($docNode->children as $child) {
154+
if ($child instanceof PhpDocTagNode) {
155+
break;
111156
}
112-
if ($append) {
113-
$ii = count($lines) - 1;
114-
$lines[$ii] = substr((string) $lines[$ii], 0, -1) . $line;
115-
} else {
116-
$lines[] = $line;
157+
if ($child instanceof PhpDocTextNode && $child->text !== '') {
158+
$lines[] = $child->text;
117159
}
118-
$append = (str_ends_with((string) $line, '\\'));
119160
}
120161

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

123166
return $description === ''
124167
? Generator::UNDEFINED
@@ -153,7 +196,7 @@ public function extractCommentSummary(string $content): string
153196
}
154197

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

171214
$description = '';
172-
if (false !== ($substr = substr($content, strlen((string) $summary)))) {
215+
if (($substr = substr($content, strlen((string) $summary))) !== '') {
173216
$description = trim($substr);
174217
}
175218

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

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

191-
$result = array_merge(
192-
['type' => null, 'description' => null],
193-
array_filter($matches, static fn ($key): bool => in_array($key, ['type', 'description']), ARRAY_FILTER_USE_KEY)
194-
);
236+
$varTags = $docNode->getVarTagValues();
237+
if ($varTags) {
238+
$varTag = reset($varTags);
239+
$type = $this->formatType($varTag->type);
240+
241+
$result['type'] = $type !== '' ? $type : null;
242+
$result['description'] = $varTag->description !== '' ? trim((string) $varTag->description) : null;
243+
}
195244

196-
return array_map(static fn (?string $value): ?string => null !== $value ? trim($value) : null, $result);
245+
return $result;
197246
}
198247

199248
/**
200249
* Extract example text from a <code>@example</code> dockblock line.
201250
*/
202251
public function extractExampleDescription(string $docblock): ?string
203252
{
204-
if (!$docblock || Generator::isDefault($docblock)) {
253+
$docNode = $this->parsePhpDoc($docblock);
254+
if (!$docNode) {
205255
return null;
206256
}
207257

208-
preg_match('/@example\s+([ \t])?(?<example>.+)?$/im', $docblock, $matches);
258+
foreach ($docNode->getTagsByName('@example') as $tag) {
259+
$value = (string) $tag->value;
260+
261+
return $value !== '' ? trim($value) : null;
262+
}
209263

210-
return $matches['example'] ?? null;
264+
return null;
211265
}
212266

213267
/**
214-
* Returns true if the <code>\@deprecated</code> tag is present, false otherwise.
268+
* Returns true if the <code>@deprecated</code> tag is present, false otherwise.
215269
*/
216270
public function isDeprecated(?string $docblock): bool
217271
{
218-
if (!$docblock || Generator::isDefault($docblock)) {
272+
$docNode = $this->parsePhpDoc($docblock);
273+
if (!$docNode) {
219274
return false;
220275
}
221276

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

0 commit comments

Comments
 (0)