Skip to content

Commit c667e8b

Browse files
committed
Refactor DocblockTrait to using the phpstan docblock parser
1 parent 2c90851 commit c667e8b

3 files changed

Lines changed: 223 additions & 58 deletions

File tree

src/Processors/Concerns/DocblockTrait.php

Lines changed: 111 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;
101+
}
102+
}
103+
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));
84111
}
112+
113+
if ($typeNode instanceof IntersectionTypeNode) {
114+
return implode('&', array_map(strval(...), $typeNode->types));
115+
}
116+
117+
return (string) $typeNode;
85118
}
86119

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'] = [];
108134
}
109-
if ($skip) {
110-
continue;
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+
];
141+
}
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
*/
@@ -183,44 +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]*\\/\*\*/', '/\*\/[ \t]*$/'], '', $comment); // '/**', '*/'
229+
$result = ['type' => null, 'description' => null];
188230

189-
if (!preg_match('/@var[ \t]+(?<type>[^\s<>]+(?:<[^>]*>)?(?:\|[^\s<>]+(?:<[^>]*>)?)*)(?:[ \t]+\$(?<name>\w+))?(?:[ \t]+(?<description>.+))?$/im', (string) $comment, $matches)) {
190-
return ['type' => null, 'description' => null];
231+
$docNode = $this->parsePhpDoc($docblock);
232+
if (!$docNode) {
233+
return $result;
191234
}
192235

193-
$result = array_merge(
194-
['type' => null, 'description' => null],
195-
array_filter($matches, static fn ($key): bool => in_array($key, ['type', 'description']), ARRAY_FILTER_USE_KEY)
196-
);
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+
}
197244

198-
return array_map(static fn (?string $value): ?string => null !== $value ? trim($value) : null, $result);
245+
return $result;
199246
}
200247

201248
/**
202249
* Extract example text from a <code>@example</code> dockblock line.
203250
*/
204251
public function extractExampleDescription(string $docblock): ?string
205252
{
206-
if (!$docblock || Generator::isDefault($docblock)) {
253+
$docNode = $this->parsePhpDoc($docblock);
254+
if (!$docNode) {
207255
return null;
208256
}
209257

210-
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+
}
211263

212-
return $matches['example'] ?? null;
264+
return null;
213265
}
214266

215267
/**
216-
* 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.
217269
*/
218270
public function isDeprecated(?string $docblock): bool
219271
{
220-
if (!$docblock || Generator::isDefault($docblock)) {
272+
$docNode = $this->parsePhpDoc($docblock);
273+
if (!$docNode) {
221274
return false;
222275
}
223276

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

tests/Fixtures/ComplexVarTypes.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* @license Apache 2.0
5+
*/
6+
7+
namespace OpenApi\Tests\Fixtures;
8+
9+
use OpenApi\Attributes as OAT;
10+
11+
#[OAT\Schema]
12+
class ComplexVarTypes
13+
{
14+
/**
15+
* An associative array with string values.
16+
*
17+
* @var array<string, string>
18+
*/
19+
#[OAT\Property]
20+
public array $map;
21+
22+
/**
23+
* A map from int to user objects.
24+
*
25+
* @var array<int, User>
26+
*/
27+
#[OAT\Property]
28+
public array $userMap;
29+
30+
/** @var array<string, string> Inline generic with description */
31+
#[OAT\Property]
32+
public array $inlineGenericDesc;
33+
34+
/**
35+
* A map using namespaced class.
36+
*
37+
* @var array<int, Customer>
38+
*/
39+
#[OAT\Property]
40+
public array $namespacedMap;
41+
42+
/**
43+
* List of integer IDs.
44+
*
45+
* @var int[]
46+
*/
47+
#[OAT\Property]
48+
public array $intList;
49+
50+
/**
51+
* Either an array or a string list.
52+
*
53+
* @var array|string[]
54+
*/
55+
#[OAT\Property]
56+
public $arrayOrStringList;
57+
58+
/**
59+
* Nullable map of strings.
60+
*
61+
* @var array<string, string>|null
62+
*/
63+
#[OAT\Property]
64+
public ?array $nullableMap;
65+
66+
/**
67+
* A collection of users or a single user array.
68+
*
69+
* @var array<int, User>|User[]
70+
*/
71+
#[OAT\Property]
72+
public array $mixedUserList;
73+
74+
/** @var array<string, string>|null Nullable inline with description */
75+
#[OAT\Property]
76+
public ?array $nullableInlineDesc;
77+
}

tests/Processors/AugmentPropertiesTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,41 @@ public function testTypedProperties(): void
350350
);
351351
}
352352

353+
public function testComplexVarTypeDescription(): void
354+
{
355+
$analysis = $this->analysisFromFixtures([
356+
'ComplexVarTypes.php',
357+
], $this->processorPipeline([
358+
new MergeIntoOpenApi(),
359+
new MergeIntoComponents(),
360+
new AugmentSchemas(),
361+
new AugmentProperties(),
362+
]));
363+
364+
[
365+
$map,
366+
$userMap,
367+
$inlineGenericDesc,
368+
$namespacedMap,
369+
$intList,
370+
$arrayOrStringList,
371+
$nullableMap,
372+
$mixedUserList,
373+
$nullableInlineDesc
374+
] = $analysis->openapi->components->schemas[0]->properties;
375+
376+
// Description should come from docblock text, not the generic type fragment
377+
$this->assertSame('An associative array with string values.', $map->description);
378+
$this->assertSame('A map from int to user objects.', $userMap->description);
379+
$this->assertSame('Inline generic with description', $inlineGenericDesc->description);
380+
$this->assertSame('A map using namespaced class.', $namespacedMap->description);
381+
$this->assertSame('List of integer IDs.', $intList->description);
382+
$this->assertSame('Either an array or a string list.', $arrayOrStringList->description);
383+
$this->assertSame('Nullable map of strings.', $nullableMap->description);
384+
$this->assertSame('A collection of users or a single user array.', $mixedUserList->description);
385+
$this->assertSame('Nullable inline with description', $nullableInlineDesc->description);
386+
}
387+
353388
protected function assertName(OA\Property $property, array $expectedValues): void
354389
{
355390
foreach ($expectedValues as $key => $val) {

0 commit comments

Comments
 (0)