|
9 | 9 | use OpenApi\Annotations as OA; |
10 | 10 | use OpenApi\Attributes as OAT; |
11 | 11 | 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; |
12 | 24 |
|
13 | 25 | trait DocblockTrait |
14 | 26 | { |
@@ -55,70 +67,101 @@ public function isDocblockRoot(OA\AbstractAnnotation $annotation): bool |
55 | 67 | return false; |
56 | 68 | } |
57 | 69 |
|
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 |
59 | 74 | { |
60 | | - if (null === $tags) { |
61 | | - return; |
| 75 | + if (!$docblock || Generator::isDefault($docblock)) { |
| 76 | + return null; |
62 | 77 | } |
63 | 78 |
|
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); |
72 | 81 |
|
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 | + } |
78 | 86 |
|
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)); |
84 | 111 | } |
| 112 | + |
| 113 | + if ($typeNode instanceof IntersectionTypeNode) { |
| 114 | + return implode('&', array_map(strval(...), $typeNode->types)); |
| 115 | + } |
| 116 | + |
| 117 | + return (string) $typeNode; |
85 | 118 | } |
86 | 119 |
|
87 | 120 | /** |
88 | 121 | * Parse a docblock and return the full content/text. |
89 | 122 | */ |
90 | 123 | public function parseDocblock(?string $docblock, ?array &$tags = null): string |
91 | 124 | { |
92 | | - if (Generator::isDefault($docblock)) { |
| 125 | + $docNode = $this->parsePhpDoc($docblock); |
| 126 | + if (!$docNode) { |
93 | 127 | return Generator::UNDEFINED; |
94 | 128 | } |
95 | 129 |
|
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'] = []; |
108 | 134 | } |
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; |
111 | 156 | } |
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; |
117 | 159 | } |
118 | | - $append = (str_ends_with((string) $line, '\\')); |
119 | 160 | } |
120 | 161 |
|
121 | 162 | $description = trim(implode("\n", $lines)); |
| 163 | + // Handle line continuation with trailing backslash |
| 164 | + $description = preg_replace('/\\\\\n/', '', $description); |
122 | 165 |
|
123 | 166 | return $description === '' |
124 | 167 | ? Generator::UNDEFINED |
@@ -153,7 +196,7 @@ public function extractCommentSummary(string $content): string |
153 | 196 | } |
154 | 197 |
|
155 | 198 | /** |
156 | | - * An optional longer piece of text providing more details on the associated element’s function. |
| 199 | + * An optional longer piece of text providing more details on the associated element's function. |
157 | 200 | * |
158 | 201 | * @param string $content The full docblock content |
159 | 202 | */ |
@@ -183,44 +226,54 @@ public function extractCommentDescription(string $content): string |
183 | 226 | */ |
184 | 227 | public function parseVarLine(?string $docblock): array |
185 | 228 | { |
186 | | - $comment = str_replace("\r\n", "\n", (string) $docblock); |
187 | | - $comment = preg_replace(['/[ \t]*\\/\*\*/', '/\*\/[ \t]*$/'], '', $comment); // '/**', '*/' |
| 229 | + $result = ['type' => null, 'description' => null]; |
188 | 230 |
|
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; |
191 | 234 | } |
192 | 235 |
|
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 | + } |
197 | 244 |
|
198 | | - return array_map(static fn (?string $value): ?string => null !== $value ? trim($value) : null, $result); |
| 245 | + return $result; |
199 | 246 | } |
200 | 247 |
|
201 | 248 | /** |
202 | 249 | * Extract example text from a <code>@example</code> dockblock line. |
203 | 250 | */ |
204 | 251 | public function extractExampleDescription(string $docblock): ?string |
205 | 252 | { |
206 | | - if (!$docblock || Generator::isDefault($docblock)) { |
| 253 | + $docNode = $this->parsePhpDoc($docblock); |
| 254 | + if (!$docNode) { |
207 | 255 | return null; |
208 | 256 | } |
209 | 257 |
|
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 | + } |
211 | 263 |
|
212 | | - return $matches['example'] ?? null; |
| 264 | + return null; |
213 | 265 | } |
214 | 266 |
|
215 | 267 | /** |
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. |
217 | 269 | */ |
218 | 270 | public function isDeprecated(?string $docblock): bool |
219 | 271 | { |
220 | | - if (!$docblock || Generator::isDefault($docblock)) { |
| 272 | + $docNode = $this->parsePhpDoc($docblock); |
| 273 | + if (!$docNode) { |
221 | 274 | return false; |
222 | 275 | } |
223 | 276 |
|
224 | | - return 1 === preg_match('/@deprecated\s+([ \t])?(?<deprecated>.+)?$/im', $docblock); |
| 277 | + return count($docNode->getDeprecatedTagValues()) > 0; |
225 | 278 | } |
226 | 279 | } |
0 commit comments