99use OpenApi \Annotations as OA ;
1010use OpenApi \Attributes as OAT ;
1111use 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
1325trait 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 element’ s 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