88namespace OpenAPIExtractor ;
99
1010use PhpParser \Node \Stmt \ClassMethod ;
11+ use PHPStan \PhpDocParser \Ast \PhpDoc \DeprecatedTagValueNode ;
12+ use PHPStan \PhpDocParser \Ast \PhpDoc \GenericTagValueNode ;
1113use PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode ;
1214use PHPStan \PhpDocParser \Ast \PhpDoc \PhpDocTagNode ;
1315use PHPStan \PhpDocParser \Ast \PhpDoc \PhpDocTextNode ;
1618use PHPStan \PhpDocParser \Parser \TokenIterator ;
1719
1820class ControllerMethod {
21+ private const STATUS_CODE_DESCRIPTION_PATTERN = '/^(\d{3}): (.+)$/ ' ;
22+
1923 /**
2024 * @param ControllerMethodParameter[] $parameters
2125 * @param list<ControllerMethodResponse|null> $responses
@@ -55,37 +59,55 @@ public static function parse(string $context,
5559 $ docParameters = [];
5660
5761 $ doc = $ method ->getDocComment ()?->getText();
58- if ($ doc != null ) {
62+ if ($ doc !== null ) {
5963 $ docNodes = $ phpDocParser ->parse (new TokenIterator ($ lexer ->tokenize ($ doc )))->children ;
6064
6165 foreach ($ docNodes as $ docNode ) {
6266 if ($ docNode instanceof PhpDocTextNode) {
63- $ block = Helpers::cleanDocComment ($ docNode ->text );
64- if ($ block === '' ) {
65- continue ;
66- }
67- $ pattern = '/(\d{3}): / ' ;
68- if (preg_match ($ pattern , $ block )) {
69- $ parts = preg_split ($ pattern , $ block , -1 , PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
70- $ counter = count ($ parts );
71- for ($ i = 0 ; $ i < $ counter ; $ i += 2 ) {
72- $ statusCode = intval ($ parts [$ i ]);
73- $ responseDescriptions [$ statusCode ] = trim ($ parts [$ i + 1 ]);
67+ $ nodeDescription = (string )$ docNode ->text ;
68+ } elseif ($ docNode ->value instanceof GenericTagValueNode) {
69+ $ nodeDescription = (string )$ docNode ->value ;
70+ } else {
71+ $ nodeDescription = (string )$ docNode ->value ->description ;
72+ }
73+
74+ $ nodeDescriptionLines = array_filter (explode ("\n" , $ nodeDescription ), static fn (string $ line ) => trim ($ line ) !== '' );
75+
76+ // Parse in blocks (separate by double newline) to preserve newlines within a block.
77+ $ nodeDescriptionBlocks = preg_split ("/ \n\s* \n/ " , $ nodeDescription );
78+ foreach ($ nodeDescriptionBlocks as $ nodeDescriptionBlock ) {
79+ $ methodDescriptionBlockLines = [];
80+ foreach (array_filter (explode ("\n" , $ nodeDescriptionBlock ), static fn (string $ line ) => trim ($ line ) !== '' ) as $ line ) {
81+ if (preg_match (self ::STATUS_CODE_DESCRIPTION_PATTERN , $ line )) {
82+ $ parts = preg_split (self ::STATUS_CODE_DESCRIPTION_PATTERN , $ line , -1 , PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
83+ $ responseDescriptions [(int )$ parts [0 ]] = trim ($ parts [1 ]);
84+ } elseif ($ docNode instanceof PhpDocTextNode) {
85+ $ methodDescriptionBlockLines [] = $ line ;
86+ } elseif (
87+ $ docNode instanceof PhpDocTagNode && (
88+ $ docNode ->value instanceof ParamTagValueNode ||
89+ $ docNode ->value instanceof ThrowsTagValueNode ||
90+ $ docNode ->value instanceof DeprecatedTagValueNode ||
91+ $ docNode ->name === '@license ' ||
92+ $ docNode ->name === '@since ' ||
93+ $ docNode ->name === '@psalm-suppress ' ||
94+ $ docNode ->name === '@suppress '
95+ )) {
96+ // Only add lines from other node types, as these have special handling (e.g. @param or @throws) or should be ignored entirely (e.g. @deprecated or @license).
97+ continue ;
98+ } else {
99+ $ methodDescriptionBlockLines [] = $ line ;
74100 }
75- } else {
76- $ methodDescription [] = $ block ;
101+ }
102+ if ($ methodDescriptionBlockLines !== []) {
103+ $ methodDescription [] = Helpers::cleanDocComment (implode (' ' , $ methodDescriptionBlockLines ));
77104 }
78105 }
79- }
80106
81- foreach ($ docNodes as $ docNode ) {
82107 if ($ docNode instanceof PhpDocTagNode) {
83108 if ($ docNode ->value instanceof ParamTagValueNode) {
84- if (array_key_exists ($ docNode ->name , $ docParameters )) {
85- $ docParameters [$ docNode ->name ][] = $ docNode ->value ;
86- } else {
87- $ docParameters [$ docNode ->name ] = [$ docNode ->value ];
88- }
109+ $ docParameters [$ docNode ->name ] ??= [];
110+ $ docParameters [$ docNode ->name ][] = $ docNode ->value ;
89111 }
90112
91113 if ($ docNode ->value instanceof ReturnTagValueNode) {
@@ -97,11 +119,12 @@ public static function parse(string $context,
97119 if ($ docNode ->value instanceof ThrowsTagValueNode) {
98120 $ type = $ docNode ->value ->type ;
99121 $ statusCode = StatusCodes::resolveException ($ context . ': @throws ' , $ type );
100- if ($ statusCode != null ) {
101- if (!$ allowMissingDocs && $ docNode -> value -> description == '' && $ statusCode < 500 ) {
122+ if ($ statusCode !== null ) {
123+ if (!$ allowMissingDocs && $ nodeDescriptionLines === [] && $ statusCode < 500 ) {
102124 Logger::error ($ context , "Missing description for exception ' " . $ type . "' " );
103125 } else {
104- $ responseDescriptions [$ statusCode ] = $ docNode ->value ->description ;
126+ // Only add lines that don't match the status code pattern to the description
127+ $ responseDescriptions [$ statusCode ] = implode ("\n" , array_filter ($ nodeDescriptionLines , static fn (string $ line ) => !preg_match (self ::STATUS_CODE_DESCRIPTION_PATTERN , $ line )));
105128 }
106129
107130 if (str_starts_with ($ type ->name , 'OCS ' ) && str_ends_with ($ type ->name , 'Exception ' )) {
@@ -144,17 +167,19 @@ public static function parse(string $context,
144167 }
145168 }
146169
147- if ($ paramTag instanceof \PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode && $ psalmParamTag instanceof \PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode) {
148- // Use all the type information from @psalm-param because it is more specific,
149- // but pull the description from @param and @psalm-param because usually only one of them has it.
150- if ($ psalmParamTag ->description !== '' ) {
151- $ description = $ psalmParamTag ->description ;
152- } elseif ($ paramTag ->description !== '' ) {
153- $ description = $ paramTag ->description ;
154- } else {
155- $ description = '' ;
156- }
170+ // Use all the type information from @psalm-param because it is more specific,
171+ // but pull the description from @param and @psalm-param because usually only one of them has it.
172+ if (($ psalmParamTag ?->description ?? '' ) !== '' ) {
173+ $ description = $ psalmParamTag ->description ;
174+ } elseif (($ paramTag ?->description ?? '' ) !== '' ) {
175+ $ description = $ paramTag ->description ;
176+ } else {
177+ $ description = '' ;
178+ }
179+ // Only keep lines that don't match the status code pattern in the description
180+ $ description = implode ("\n" , array_filter (array_filter (explode ("\n" , $ description ), static fn (string $ line ) => trim ($ line ) !== '' ), static fn (string $ line ) => !preg_match (self ::STATUS_CODE_DESCRIPTION_PATTERN , $ line )));
157181
182+ if ($ paramTag instanceof \PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode && $ psalmParamTag instanceof \PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode) {
158183 try {
159184 $ type = OpenApiType::resolve (
160185 $ context . ': @param: ' . $ psalmParamTag ->parameterName ,
@@ -183,20 +208,23 @@ public static function parse(string $context,
183208 );
184209 }
185210
186- $ param = new ControllerMethodParameter ($ context , $ definitions , $ methodParameterName , $ methodParameter , $ type );
187211 } elseif ($ psalmParamTag instanceof \PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode) {
188212 $ type = OpenApiType::resolve ($ context . ': @param: ' . $ methodParameterName , $ definitions , $ psalmParamTag );
189- $ param = new ControllerMethodParameter ($ context , $ definitions , $ methodParameterName , $ methodParameter , $ type );
190213 } elseif ($ paramTag instanceof \PHPStan \PhpDocParser \Ast \PhpDoc \ParamTagValueNode) {
191214 $ type = OpenApiType::resolve ($ context . ': @param: ' . $ methodParameterName , $ definitions , $ paramTag );
192- $ param = new ControllerMethodParameter ($ context , $ definitions , $ methodParameterName , $ methodParameter , $ type );
193215 } elseif ($ allowMissingDocs ) {
194- $ param = new ControllerMethodParameter ( $ context , $ definitions , $ methodParameterName , $ methodParameter , null ) ;
216+ $ type = null ;
195217 } else {
196218 Logger::error ($ context , "Missing doc parameter for ' " . $ methodParameterName . "' " );
197219 continue ;
198220 }
199221
222+ if ($ type !== null ) {
223+ $ type ->description = $ description ;
224+ }
225+
226+ $ param = new ControllerMethodParameter ($ context , $ definitions , $ methodParameterName , $ methodParameter , $ type );
227+
200228 if (!$ allowMissingDocs && $ param ->type ->description == '' ) {
201229 Logger::error ($ context . ': @param: ' . $ methodParameterName , 'Missing description ' );
202230 continue ;
0 commit comments