Skip to content

Commit f7984a1

Browse files
committed
fix: Adjust docs and status code parsing for phpdoc-parser v2
Signed-off-by: provokateurin <[email protected]>
1 parent 29b30e6 commit f7984a1

File tree

6 files changed

+490
-42
lines changed

6 files changed

+490
-42
lines changed

generate-spec.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use PHPStan\PhpDocParser\Parser\PhpDocParser;
3636
use PHPStan\PhpDocParser\Parser\TokenIterator;
3737
use PHPStan\PhpDocParser\Parser\TypeParser;
38+
use PHPStan\PhpDocParser\ParserConfig;
3839
use RecursiveDirectoryIterator;
3940
use RecursiveIteratorIterator;
4041
use stdClass;
@@ -69,10 +70,11 @@
6970
$astParser = (new ParserFactory())->createForNewestSupportedVersion();
7071
$nodeFinder = new NodeFinder;
7172

72-
$lexer = new Lexer();
73-
$constExprParser = new ConstExprParser();
74-
$typeParser = new TypeParser($constExprParser);
75-
$phpDocParser = new PhpDocParser($typeParser, $constExprParser);
73+
$config = new ParserConfig(usedAttributes: ['lines' => true, 'indexes' => true]);
74+
$lexer = new Lexer($config);
75+
$constExprParser = new ConstExprParser($config);
76+
$typeParser = new TypeParser($config, $constExprParser);
77+
$phpDocParser = new PhpDocParser($config, $typeParser, $constExprParser);
7678

7779
$infoXMLPath = $dir . '/appinfo/info.xml';
7880

src/ControllerMethod.php

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
namespace OpenAPIExtractor;
99

1010
use PhpParser\Node\Stmt\ClassMethod;
11+
use PHPStan\PhpDocParser\Ast\PhpDoc\DeprecatedTagValueNode;
12+
use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode;
1113
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
1214
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
1315
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
@@ -16,6 +18,8 @@
1618
use PHPStan\PhpDocParser\Parser\TokenIterator;
1719

1820
class 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;

tests/appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,6 @@
7676
['name' => 'Settings#whitespace', 'url' => '/api/{apiVersion}/whitespace', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
7777
['name' => 'Settings#withCorsAnnotation', 'url' => '/api/{apiVersion}/cors/annotation', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
7878
['name' => 'Settings#withCorsAttribute', 'url' => '/api/{apiVersion}/cors/attribute', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
79+
['name' => 'Settings#docsParsingStatuscode', 'url' => '/api/{apiVersion}/docs-parsing-status-code', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
7980
],
8081
];

tests/lib/Controller/SettingsController.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,4 +596,43 @@ public function withCorsAnnotation(): DataResponse {
596596
public function withCorsAttribute(): DataResponse {
597597
return new DataResponse();
598598
}
599+
600+
/**
601+
* Summary.
602+
* More summary.
603+
*
604+
* Description.
605+
* More description.
606+
*
607+
* 200: OK
608+
*
609+
* @NoAdminRequired
610+
*
611+
* 201: CREATED
612+
*
613+
* @param string $param Some param
614+
*
615+
* 202: ACCEPTED
616+
*
617+
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_ACCEPTED|Http::STATUS_NON_AUTHORATIVE_INFORMATION|Http::STATUS_NO_CONTENT, list<empty>, array{}>
618+
*
619+
* 203: STATUS_NON_AUTHORATIVE_INFORMATION
620+
*
621+
* @throws \Exception STATUS_INTERNAL_SERVER_ERROR
622+
*
623+
* 204: STATUS_NO_CONTENT
624+
*
625+
* @license This is not actually a license
626+
*
627+
* @deprecated This is not deprecated
628+
*
629+
* @since Version 1.0.0
630+
*
631+
* @psalm-suppress some issue
632+
*
633+
* @suppress another issue
634+
*/
635+
public function docsParsingStatuscode(string $param): DataResponse {
636+
return new DataResponse();
637+
}
599638
}

0 commit comments

Comments
 (0)