From 77526e00cb6f926f6774057bf745b4c7af078c8c Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 10:05:54 +1200 Subject: [PATCH 01/11] . --- src/Processors/AugmentParameters.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Processors/AugmentParameters.php b/src/Processors/AugmentParameters.php index 5f4d5f717..45e3f894b 100644 --- a/src/Processors/AugmentParameters.php +++ b/src/Processors/AugmentParameters.php @@ -136,12 +136,12 @@ protected function augmentOperationParameters(Analysis $analysis): void if (!Generator::isDefault($operation->parameters)) { $tags = []; $this->parseDocblock($operation->_context->comment, $tags); - $docblockParams = $tags['param'] ?? []; + $operationDocblockParams = $tags['param'] ?? []; foreach ($operation->parameters as $parameter) { if (Generator::isDefault($parameter->description)) { - if (array_key_exists($parameter->name, $docblockParams)) { - $details = $docblockParams[$parameter->name]; + if (array_key_exists($parameter->name, $operationDocblockParams)) { + $details = $operationDocblockParams[$parameter->name]; if ($details['description']) { $parameter->description = $details['description']; } From d57bedb0bedd9dec699582039dfdb1a73f1bbc0d Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 10:06:02 +1200 Subject: [PATCH 02/11] Set parameter comment on context if available --- src/Analysers/AttributeAnnotationFactory.php | 4 ++++ tests/Fixtures/PHP/DocblockAndTypehintTypes.php | 1 + tests/Fixtures/Scratch/PromotedProperty.php | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Analysers/AttributeAnnotationFactory.php b/src/Analysers/AttributeAnnotationFactory.php index 5f27c087b..7023f7788 100644 --- a/src/Analysers/AttributeAnnotationFactory.php +++ b/src/Analysers/AttributeAnnotationFactory.php @@ -94,6 +94,10 @@ public function build(\Reflector $reflector, Context $context): array } } else { $instance->_context->property = $rp->getName(); + if (method_exists($rp, 'getDocComment')) { + $comment = $rp->getDocComment(); + $instance->_context->comment = false !== $comment ? $comment : null; + } } } $annotations[] = $instance; diff --git a/tests/Fixtures/PHP/DocblockAndTypehintTypes.php b/tests/Fixtures/PHP/DocblockAndTypehintTypes.php index afd5e7671..24bbf430b 100644 --- a/tests/Fixtures/PHP/DocblockAndTypehintTypes.php +++ b/tests/Fixtures/PHP/DocblockAndTypehintTypes.php @@ -210,6 +210,7 @@ public function paramMethod( * @param ?string[] $blah_values */ public function blah( + /** @var string|null The blah */ #[OAT\Property(example: 'My blah')] ?string $blah, #[OAT\Property(nullable: true, items: new OAT\Items(type: 'string', example: 'hello'))] diff --git a/tests/Fixtures/Scratch/PromotedProperty.php b/tests/Fixtures/Scratch/PromotedProperty.php index e21151c50..b12c774cd 100644 --- a/tests/Fixtures/Scratch/PromotedProperty.php +++ b/tests/Fixtures/Scratch/PromotedProperty.php @@ -56,7 +56,7 @@ public function __construct( #[OAT\Property] public string $different = '', - /* + /** * Intentionally not promoted! */ #[OAT\Property] From 135e3594c846ffdadf827512ddb5f5aaf4e495bc Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 11:44:05 +1200 Subject: [PATCH 03/11] . --- src/Analysers/AttributeAnnotationFactory.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Analysers/AttributeAnnotationFactory.php b/src/Analysers/AttributeAnnotationFactory.php index 7023f7788..724714d21 100644 --- a/src/Analysers/AttributeAnnotationFactory.php +++ b/src/Analysers/AttributeAnnotationFactory.php @@ -94,12 +94,15 @@ public function build(\Reflector $reflector, Context $context): array } } else { $instance->_context->property = $rp->getName(); - if (method_exists($rp, 'getDocComment')) { - $comment = $rp->getDocComment(); - $instance->_context->comment = false !== $comment ? $comment : null; + } + } elseif ($instance instanceof OAT\Parameter) { + if (method_exists($rp, 'getDocComment')) { + if ($comment = $rp->getDocComment()) { + $instance->_context->comment = $comment; } } } + $annotations[] = $instance; } } From 4b1ec561eaeec379d608324d848389d96df95ea0 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 14:01:28 +1200 Subject: [PATCH 04/11] Prepare for parameter (`@var`) docblocks --- src/Processors/AugmentParameters.php | 7 +++ src/Processors/Concerns/DocblockTrait.php | 4 +- tests/Fixtures/Scratch/Docblocks.php | 2 + tests/Fixtures/Scratch/PromotedProperty.php | 2 +- tests/Processors/DocBlockVarLineTest.php | 54 +++++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/Processors/DocBlockVarLineTest.php diff --git a/src/Processors/AugmentParameters.php b/src/Processors/AugmentParameters.php index 45e3f894b..7e2788b54 100644 --- a/src/Processors/AugmentParameters.php +++ b/src/Processors/AugmentParameters.php @@ -139,6 +139,13 @@ protected function augmentOperationParameters(Analysis $analysis): void $operationDocblockParams = $tags['param'] ?? []; foreach ($operation->parameters as $parameter) { + if (Generator::isDefault($parameter->description)) { + $typeAndDescription = $this->parseVarLine((string) $parameter->_context->comment); + if ($typeAndDescription['description']) { + $parameter->description = trim($typeAndDescription['description']); + } + } + if (Generator::isDefault($parameter->description)) { if (array_key_exists($parameter->name, $operationDocblockParams)) { $details = $operationDocblockParams[$parameter->name]; diff --git a/src/Processors/Concerns/DocblockTrait.php b/src/Processors/Concerns/DocblockTrait.php index 3b6cf797c..e3ea568cd 100644 --- a/src/Processors/Concerns/DocblockTrait.php +++ b/src/Processors/Concerns/DocblockTrait.php @@ -184,9 +184,9 @@ public function extractCommentDescription(string $content): string public function parseVarLine(?string $docblock): array { $comment = str_replace("\r\n", "\n", (string) $docblock); - $comment = preg_replace('/\*\/[ \t]*$/', '', $comment); // strip '*/' + $comment = preg_replace(['/[ \t]*\\/\*\*/', '/\*\/[ \t]*$/'], '', $comment); // '/**', '*/' - preg_match('/@var\s+(?[^\s]+)([ \t])?(?.+)?+$/im', (string) $comment, $matches); + preg_match('/@var[ \t]+(?[^\s]+)(?:[ \t]+\$(?\w+))?(?:[ \t]+(?.+))?$/im', (string) $comment, $matches); $result = array_merge( ['type' => null, 'description' => null], diff --git a/tests/Fixtures/Scratch/Docblocks.php b/tests/Fixtures/Scratch/Docblocks.php index c6ca693f7..c1aeed0cb 100644 --- a/tests/Fixtures/Scratch/Docblocks.php +++ b/tests/Fixtures/Scratch/Docblocks.php @@ -101,7 +101,9 @@ class DocblocksEndpoint )] #[OAT\Response(response: 200, description: 'successful operation')] public function endpoint( + /* @var string|null $filter An optional filter */ #[OAT\QueryParameter(description: null)] ?string $filter, + /* @var string|null $limit An optional limit */ #[OAT\QueryParameter] ?int $limit, ) { diff --git a/tests/Fixtures/Scratch/PromotedProperty.php b/tests/Fixtures/Scratch/PromotedProperty.php index b12c774cd..e21151c50 100644 --- a/tests/Fixtures/Scratch/PromotedProperty.php +++ b/tests/Fixtures/Scratch/PromotedProperty.php @@ -56,7 +56,7 @@ public function __construct( #[OAT\Property] public string $different = '', - /** + /* * Intentionally not promoted! */ #[OAT\Property] diff --git a/tests/Processors/DocBlockVarLineTest.php b/tests/Processors/DocBlockVarLineTest.php new file mode 100644 index 000000000..a8f28e40f --- /dev/null +++ b/tests/Processors/DocBlockVarLineTest.php @@ -0,0 +1,54 @@ + [ + << 'null|string', + 'description' => 'the second name of the customer', + ], + ]; + + yield 'split-description' => [ + <<< END +/** + * The unique identifier of a product in our catalog. + * + * @var int + * + * @OA\Property(format="int64", example=1) + */ +END, + [ + 'type' => 'int', + 'description' => null, + ], + ]; + } + + #[DataProvider('varLineCases')] + public function testDocBlockVarLine(string $comment, array $expected): void + { + $this->assertSame($expected, $this->parseVarLine($comment)); + } +} From 9bef50c00e33f117d6f2792e5d0ae569db2f416f Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 14:06:12 +1200 Subject: [PATCH 05/11] Fix phpstan warnings --- phpstan-baseline.neon | 36 ----------------------- src/Processors/Concerns/DocblockTrait.php | 2 +- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2417c1aa9..5f8500b9a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -30,18 +30,6 @@ parameters: count: 1 path: src/Annotations/Flow.php - - - message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/Processors/AugmentParameters.php - - - - message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/Processors/AugmentProperties.php - - message: '#^Parameter \#1 \$annotation of method OpenApi\\Processors\\DocBlockDescriptions\:\:description\(\) expects OpenApi\\Annotations\\Operation\|OpenApi\\Annotations\\Parameter\|OpenApi\\Annotations\\Schema, OpenApi\\Annotations\\AbstractAnnotation given\.$#' identifier: argument.type @@ -54,12 +42,6 @@ parameters: count: 1 path: src/Processors/DocBlockDescriptions.php - - - message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/Processors/DocBlockDescriptions.php - - message: '#^Parameter \#1 \$callback of function spl_autoload_register expects \(callable\(string\)\: void\)\|null, array\{Composer\\Autoload\\ClassLoader, ''findFile''\} given\.$#' identifier: argument.type @@ -83,21 +65,3 @@ parameters: identifier: method.notFound count: 1 path: tests/Annotations/AttributesSyncTest.php - - - - message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: tests/Processors/AugmentParametersTest.php - - - - message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: tests/Processors/AugmentRefsTest.php - - - - message: '#^Strict comparison using \!\=\= between false and string will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: tests/Processors/DocBlockDescriptionsTest.php diff --git a/src/Processors/Concerns/DocblockTrait.php b/src/Processors/Concerns/DocblockTrait.php index e3ea568cd..1d5b1de67 100644 --- a/src/Processors/Concerns/DocblockTrait.php +++ b/src/Processors/Concerns/DocblockTrait.php @@ -169,7 +169,7 @@ public function extractCommentDescription(string $content): string } $description = ''; - if (false !== ($substr = substr($content, strlen((string) $summary)))) { + if (!empty($substr = substr($content, strlen($summary)))) { $description = trim($substr); } From df285ab69a7ce57160c5e07fd3d88b5023e88a1f Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 14:10:01 +1200 Subject: [PATCH 06/11] CS --- src/Processors/Concerns/DocblockTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Processors/Concerns/DocblockTrait.php b/src/Processors/Concerns/DocblockTrait.php index 1d5b1de67..13a9695d7 100644 --- a/src/Processors/Concerns/DocblockTrait.php +++ b/src/Processors/Concerns/DocblockTrait.php @@ -169,7 +169,7 @@ public function extractCommentDescription(string $content): string } $description = ''; - if (!empty($substr = substr($content, strlen($summary)))) { + if (($substr = substr($content, strlen((string) $summary))) !== '') { $description = trim($substr); } From 3e5d649d6821faddd2214e848c9e7f824787d4e7 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 14:32:53 +1200 Subject: [PATCH 07/11] Add another testcase --- tests/Processors/DocBlockVarLineTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Processors/DocBlockVarLineTest.php b/tests/Processors/DocBlockVarLineTest.php index a8f28e40f..c642fe768 100644 --- a/tests/Processors/DocBlockVarLineTest.php +++ b/tests/Processors/DocBlockVarLineTest.php @@ -44,6 +44,14 @@ public static function varLineCases(): iterable 'description' => null, ], ]; + + yield 'single-full-line' => [ + '/* @var string|null $limit An optional limit */', + [ + 'type' => 'string|null', + 'description' => 'An optional limit', + ], + ]; } #[DataProvider('varLineCases')] From 920debca3c9f5c34f85b2c5fc7481d264af48692 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 14:43:37 +1200 Subject: [PATCH 08/11] Make tests fail as expected --- .php-cs-fixer.dist.php | 2 ++ tests/Fixtures/Scratch/Docblocks.php | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index cf85c2438..a26596e6e 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -19,6 +19,8 @@ && !strpos($file->getPathname(), 'tests/Fixtures/TypedProperties.php') // FQDN in docblock && !strpos($file->getPathname(), 'tests/Fixtures/PHP/DocblockAndTypehintTypes.php') + // parameter docblock for PHP 8.6 + && !strpos($file->getPathname(), 'tests/Fixtures/Scratch/Docblocks.php') ; }) ->in(__DIR__); diff --git a/tests/Fixtures/Scratch/Docblocks.php b/tests/Fixtures/Scratch/Docblocks.php index c1aeed0cb..44f98d8a5 100644 --- a/tests/Fixtures/Scratch/Docblocks.php +++ b/tests/Fixtures/Scratch/Docblocks.php @@ -101,9 +101,9 @@ class DocblocksEndpoint )] #[OAT\Response(response: 200, description: 'successful operation')] public function endpoint( - /* @var string|null $filter An optional filter */ + /** @var string|null $filter An optional filter */ #[OAT\QueryParameter(description: null)] ?string $filter, - /* @var string|null $limit An optional limit */ + /** @var string|null $limit An optional limit */ #[OAT\QueryParameter] ?int $limit, ) { From 6a37ea9fbe2fa390aba71a5a671db39e6cca3860 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 22 Apr 2026 15:34:52 +1200 Subject: [PATCH 09/11] Allow PHP version specific scratch fixtures --- .../Fixtures/Scratch/Docblocks3.0.0-8.6.yaml | 91 +++++++++++++++++++ .../Fixtures/Scratch/Docblocks3.1.0-8.6.yaml | 90 ++++++++++++++++++ .../Fixtures/Scratch/Docblocks3.2.0-8.6.yaml | 90 ++++++++++++++++++ tests/ScratchTest.php | 70 ++++++++------ 4 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 tests/Fixtures/Scratch/Docblocks3.0.0-8.6.yaml create mode 100644 tests/Fixtures/Scratch/Docblocks3.1.0-8.6.yaml create mode 100644 tests/Fixtures/Scratch/Docblocks3.2.0-8.6.yaml diff --git a/tests/Fixtures/Scratch/Docblocks3.0.0-8.6.yaml b/tests/Fixtures/Scratch/Docblocks3.0.0-8.6.yaml new file mode 100644 index 000000000..e7a70533d --- /dev/null +++ b/tests/Fixtures/Scratch/Docblocks3.0.0-8.6.yaml @@ -0,0 +1,91 @@ +openapi: 3.0.0 +info: + title: Docblocks + version: '1.0' +paths: + /api/endpoint: + get: + operationId: 4ca93475da117d3dea32e60d75f92fec + parameters: + - + name: filter + in: query + required: false + schema: + type: string + - + name: limit + in: query + description: 'An optional limit' + required: false + schema: + type: integer + responses: + '200': + description: 'successful operation' +components: + schemas: + DocblocksSchema: + properties: + name: + description: 'The name' + type: string + oldName: + description: 'The name (old)' + type: string + deprecated: true + rangeInt: + description: 'The range integer' + type: integer + maximum: 25 + minimum: 5 + minRangeInt: + description: 'The minimum range integer' + type: integer + maximum: 9223372036854775807 + minimum: 2 + maxRangeInt: + description: 'The maximum range integer' + type: integer + maximum: 10 + minimum: -9223372036854775808 + positiveInt: + description: 'The positive integer' + type: integer + maximum: 9223372036854775807 + minimum: 1 + negativeInt: + description: 'The negative integer' + type: integer + maximum: -1 + minimum: -9223372036854775808 + nonPositiveInt: + description: 'The non-positive integer' + type: integer + maximum: 0 + minimum: -9223372036854775808 + nonNegativeInt: + description: 'The non-negative integer' + type: integer + maximum: 9223372036854775807 + minimum: 0 + nonZeroInt: + description: 'The non-zero integer' + type: integer + not: + enum: + - 0 + type: object + DocblockSchemaChild: + type: object + allOf: + - + $ref: '#/components/schemas/DocblocksSchema' + - + properties: + id: + description: 'The id' + type: integer + someOtherName: + type: string + type: object diff --git a/tests/Fixtures/Scratch/Docblocks3.1.0-8.6.yaml b/tests/Fixtures/Scratch/Docblocks3.1.0-8.6.yaml new file mode 100644 index 000000000..d2bfa18b4 --- /dev/null +++ b/tests/Fixtures/Scratch/Docblocks3.1.0-8.6.yaml @@ -0,0 +1,90 @@ +openapi: 3.1.0 +info: + title: Docblocks + version: '1.0' +paths: + /api/endpoint: + get: + operationId: 4ca93475da117d3dea32e60d75f92fec + parameters: + - + name: filter + in: query + required: false + schema: + type: string + - + name: limit + in: query + description: 'An optional limit' + required: false + schema: + type: integer + responses: + '200': + description: 'successful operation' +components: + schemas: + DocblocksSchema: + properties: + name: + description: 'The name' + type: string + oldName: + description: 'The name (old)' + type: string + deprecated: true + rangeInt: + description: 'The range integer' + type: integer + maximum: 25 + minimum: 5 + minRangeInt: + description: 'The minimum range integer' + type: integer + maximum: 9223372036854775807 + minimum: 2 + maxRangeInt: + description: 'The maximum range integer' + type: integer + maximum: 10 + minimum: -9223372036854775808 + positiveInt: + description: 'The positive integer' + type: integer + maximum: 9223372036854775807 + minimum: 1 + negativeInt: + description: 'The negative integer' + type: integer + maximum: -1 + minimum: -9223372036854775808 + nonPositiveInt: + description: 'The non-positive integer' + type: integer + maximum: 0 + minimum: -9223372036854775808 + nonNegativeInt: + description: 'The non-negative integer' + type: integer + maximum: 9223372036854775807 + minimum: 0 + nonZeroInt: + description: 'The non-zero integer' + type: integer + not: + const: 0 + type: object + DocblockSchemaChild: + type: object + allOf: + - + $ref: '#/components/schemas/DocblocksSchema' + - + properties: + id: + description: 'The id' + type: integer + someOtherName: + type: string + type: object diff --git a/tests/Fixtures/Scratch/Docblocks3.2.0-8.6.yaml b/tests/Fixtures/Scratch/Docblocks3.2.0-8.6.yaml new file mode 100644 index 000000000..caf791660 --- /dev/null +++ b/tests/Fixtures/Scratch/Docblocks3.2.0-8.6.yaml @@ -0,0 +1,90 @@ +openapi: 3.2.0 +info: + title: Docblocks + version: '1.0' +paths: + /api/endpoint: + get: + operationId: 4ca93475da117d3dea32e60d75f92fec + parameters: + - + name: filter + in: query + required: false + schema: + type: string + - + name: limit + in: query + description: 'An optional limit' + required: false + schema: + type: integer + responses: + '200': + description: 'successful operation' +components: + schemas: + DocblocksSchema: + properties: + name: + description: 'The name' + type: string + oldName: + description: 'The name (old)' + type: string + deprecated: true + rangeInt: + description: 'The range integer' + type: integer + maximum: 25 + minimum: 5 + minRangeInt: + description: 'The minimum range integer' + type: integer + maximum: 9223372036854775807 + minimum: 2 + maxRangeInt: + description: 'The maximum range integer' + type: integer + maximum: 10 + minimum: -9223372036854775808 + positiveInt: + description: 'The positive integer' + type: integer + maximum: 9223372036854775807 + minimum: 1 + negativeInt: + description: 'The negative integer' + type: integer + maximum: -1 + minimum: -9223372036854775808 + nonPositiveInt: + description: 'The non-positive integer' + type: integer + maximum: 0 + minimum: -9223372036854775808 + nonNegativeInt: + description: 'The non-negative integer' + type: integer + maximum: 9223372036854775807 + minimum: 0 + nonZeroInt: + description: 'The non-zero integer' + type: integer + not: + const: 0 + type: object + DocblockSchemaChild: + type: object + allOf: + - + $ref: '#/components/schemas/DocblocksSchema' + - + properties: + id: + description: 'The id' + type: integer + someOtherName: + type: string + type: object diff --git a/tests/ScratchTest.php b/tests/ScratchTest.php index 60e5f1872..5307286a3 100644 --- a/tests/ScratchTest.php +++ b/tests/ScratchTest.php @@ -13,9 +13,10 @@ final class ScratchTest extends OpenApiTestCase { - public static function scratchTestProvider(): iterable + public static function scratchTestCases(): iterable { - foreach (self::getTypeResolvers() as $resolverName => $typeResolver) { + // scratch (.php) iterator + $scratchIterator = function (): iterable { foreach (glob(self::fixture('Scratch/*.php')) as $fixture) { $name = pathinfo($fixture, PATHINFO_FILENAME); @@ -23,40 +24,57 @@ public static function scratchTestProvider(): iterable continue; } - $scratch = self::fixture("Scratch/{$name}.php"); - $specs = [ - self::fixture("Scratch/{$name}3.2.0.yaml") => OA\OpenApi::VERSION_3_2_0, - self::fixture("Scratch/{$name}3.2.0-{$resolverName}.yaml") => OA\OpenApi::VERSION_3_2_0, - self::fixture("Scratch/{$name}3.1.0.yaml") => OA\OpenApi::VERSION_3_1_0, - self::fixture("Scratch/{$name}3.1.0-{$resolverName}.yaml") => OA\OpenApi::VERSION_3_1_0, - self::fixture("Scratch/{$name}3.0.0.yaml") => OA\OpenApi::VERSION_3_0_0, - self::fixture("Scratch/{$name}3.0.0-{$resolverName}.yaml") => OA\OpenApi::VERSION_3_0_0, - ]; - - $expectedLogs = [ - 'Examples-3.0.0' => ['@OA\Schema() is only allowed as of 3.1.0'], - ]; + yield $name => self::fixture("Scratch/{$name}.php"); + } + }; - foreach ($specs as $spec => $version) { - if (file_exists($spec)) { - $dataSet = "{$resolverName}-{$name}-{$version}"; - yield $dataSet => [ - $typeResolver, - $scratch, - $spec, - $version, - array_key_exists($dataSet, $expectedLogs) ? $expectedLogs[$dataSet] : [], - ]; + // spec iterator (most specific) for a given scratch name + $specIterator = function (string $scratchName): iterable { + foreach ([OA\OpenApi::VERSION_3_2_0, OA\OpenApi::VERSION_3_1_0, OA\OpenApi::VERSION_3_0_0] as $version) { + foreach (self::getTypeResolvers() as $resolverName => $typeResolver) { + $phpVersion = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + $caseName = "{$resolverName}-{$scratchName}-{$version}-{$phpVersion}"; + $specs = [ + self::fixture("Scratch/{$scratchName}{$version}{$resolverName}-{$phpVersion}.yaml"), + self::fixture("Scratch/{$scratchName}{$version}-{$phpVersion}.yaml"), + self::fixture("Scratch/{$scratchName}{$version}{$resolverName}.yaml"), + self::fixture("Scratch/{$scratchName}{$version}.yaml"), + ]; + foreach ($specs as $spec) { + if (file_exists($spec)) { + yield $caseName => [ + 'spec' => $spec, + 'typeResolver' => $typeResolver, + 'version' => $version, + ]; + break; + } } } } + }; + + $expectedLogs = [ + 'Examples-3.0.0' => ['@OA\Schema() is only allowed as of 3.1.0'], + ]; + + foreach ($scratchIterator() as $scratchName => $scratch) { + foreach ($specIterator($scratchName) as $caseName => $details) { + yield $caseName => [ + $details['typeResolver'], + $scratch, + $details['spec'], + $details['version'], + array_key_exists($caseName, $expectedLogs) ? $expectedLogs[$caseName] : [], + ]; + } } } /** * Test scratch fixtures. */ - #[DataProvider('scratchTestProvider')] + #[DataProvider('scratchTestCases')] public function testScratch(TypeResolverInterface $typeResolver, string $scratch, string $spec, string $version, array $expectedLogs): void { foreach ($expectedLogs as $logLine) { From 2c90851849ef08ddf3a8e363c39cb26f1e1dd479 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Thu, 23 Apr 2026 08:34:51 +1200 Subject: [PATCH 10/11] Improve regexp to handle generics and array shapes --- src/Processors/Concerns/DocblockTrait.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Processors/Concerns/DocblockTrait.php b/src/Processors/Concerns/DocblockTrait.php index 13a9695d7..4fdda3fc7 100644 --- a/src/Processors/Concerns/DocblockTrait.php +++ b/src/Processors/Concerns/DocblockTrait.php @@ -186,7 +186,9 @@ public function parseVarLine(?string $docblock): array $comment = str_replace("\r\n", "\n", (string) $docblock); $comment = preg_replace(['/[ \t]*\\/\*\*/', '/\*\/[ \t]*$/'], '', $comment); // '/**', '*/' - preg_match('/@var[ \t]+(?[^\s]+)(?:[ \t]+\$(?\w+))?(?:[ \t]+(?.+))?$/im', (string) $comment, $matches); + if (!preg_match('/@var[ \t]+(?[^\s<>]+(?:<[^>]*>)?(?:\|[^\s<>]+(?:<[^>]*>)?)*)(?:[ \t]+\$(?\w+))?(?:[ \t]+(?.+))?$/im', (string) $comment, $matches)) { + return ['type' => null, 'description' => null]; + } $result = array_merge( ['type' => null, 'description' => null], From c667e8b34f8bd106e2d2b2f16dbc807c8078e9bc Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Thu, 23 Apr 2026 11:02:02 +1200 Subject: [PATCH 11/11] Refactor `DocblockTrait` to using the `phpstan` docblock parser --- src/Processors/Concerns/DocblockTrait.php | 169 ++++++++++++++------- tests/Fixtures/ComplexVarTypes.php | 77 ++++++++++ tests/Processors/AugmentPropertiesTest.php | 35 +++++ 3 files changed, 223 insertions(+), 58 deletions(-) create mode 100644 tests/Fixtures/ComplexVarTypes.php diff --git a/src/Processors/Concerns/DocblockTrait.php b/src/Processors/Concerns/DocblockTrait.php index 4fdda3fc7..45f1c7c3a 100644 --- a/src/Processors/Concerns/DocblockTrait.php +++ b/src/Processors/Concerns/DocblockTrait.php @@ -9,6 +9,18 @@ use OpenApi\Annotations as OA; use OpenApi\Attributes as OAT; use OpenApi\Generator; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use PHPStan\PhpDocParser\ParserConfig; trait DocblockTrait { @@ -55,33 +67,54 @@ public function isDocblockRoot(OA\AbstractAnnotation $annotation): bool return false; } - protected function handleTag(string $line, ?array &$tags = null): void + /** + * Parse a docblock string into a PhpDocNode. + */ + protected function parsePhpDoc(?string $docblock): ?PhpDocNode { - if (null === $tags) { - return; + if (!$docblock || Generator::isDefault($docblock)) { + return null; } - // split of tag name - $token = preg_split("@[\s+ ]@u", $line, 2); - if (2 == count($token)) { - $tag = substr($token[0], 1); - $tail = $token[1]; - if (!array_key_exists($tag, $tags)) { - $tags[$tag] = []; - } + // Normalize single-star comments to PHPDoc format + $normalized = preg_replace('#^/\*(?!\*)#', '/**', $docblock); - if (false !== ($dpos = strpos($tail, '$'))) { - $type = trim(substr($tail, 0, $dpos)); - $token = preg_split("@[\s+ ]@u", substr($tail, $dpos), 2); - $name = trim(substr($token[0], 1)); - $description = 2 == count($token) ? trim($token[1]) : null; + // Ensure docblock has proper closing + if (!str_contains((string) $normalized, '*/')) { + $normalized = rtrim((string) $normalized) . '/'; + } - $tags[$tag][$name] = [ - 'type' => $type, - 'description' => $description, - ]; - } + $config = new ParserConfig([]); + $lexer = new Lexer($config); + $phpDocParser = new PhpDocParser( + $config, + new TypeParser($config, $constExprParser = new ConstExprParser($config)), + $constExprParser, + ); + + try { + $tokens = new TokenIterator($lexer->tokenize($normalized)); + + return $phpDocParser->parse($tokens); + } catch (\Throwable) { + return null; + } + } + + /** + * Format a type node as a compact string (without wrapping parentheses for union/intersection types). + */ + protected function formatType(TypeNode $typeNode): string + { + if ($typeNode instanceof UnionTypeNode) { + return implode('|', array_map(strval(...), $typeNode->types)); } + + if ($typeNode instanceof IntersectionTypeNode) { + return implode('&', array_map(strval(...), $typeNode->types)); + } + + return (string) $typeNode; } /** @@ -89,36 +122,46 @@ protected function handleTag(string $line, ?array &$tags = null): void */ public function parseDocblock(?string $docblock, ?array &$tags = null): string { - if (Generator::isDefault($docblock)) { + $docNode = $this->parsePhpDoc($docblock); + if (!$docNode) { return Generator::UNDEFINED; } - $comment = preg_split('/(\n|\r\n)/', (string) $docblock); - $comment[0] = preg_replace('/[ \t]*\\/\*\*/', '', $comment[0]); // strip '/**' - $ii = count($comment) - 1; - $comment[$ii] = preg_replace('/\*\/[ \t]*$/', '', (string) $comment[$ii]); // strip '*/' - $lines = []; - $append = false; - $skip = false; - foreach ($comment as $line) { - $line = preg_replace('/^\s+\* ?/', '', (string) $line); - if (str_starts_with($tagline = trim((string) $line), '@')) { - $this->handleTag($tagline, $tags); - $skip = true; + // Extract @param tags if requested + if (null !== $tags) { + if (!array_key_exists('param', $tags)) { + $tags['param'] = []; } - if ($skip) { - continue; + foreach ($docNode->getParamTagValues() as $param) { + $name = ltrim((string) $param->parameterName, '$'); + $tags['param'][$name] = [ + 'type' => (string) $param->type ?: null, + 'description' => $param->description !== '' ? $param->description : null, + ]; + } + foreach ($docNode->getTypelessParamTagValues() as $param) { + $name = ltrim((string) $param->parameterName, '$'); + $tags['param'][$name] = [ + 'type' => null, + 'description' => $param->description !== '' ? $param->description : null, + ]; + } + } + + // Extract description from text nodes before first tag + $lines = []; + foreach ($docNode->children as $child) { + if ($child instanceof PhpDocTagNode) { + break; } - if ($append) { - $ii = count($lines) - 1; - $lines[$ii] = substr((string) $lines[$ii], 0, -1) . $line; - } else { - $lines[] = $line; + if ($child instanceof PhpDocTextNode && $child->text !== '') { + $lines[] = $child->text; } - $append = (str_ends_with((string) $line, '\\')); } $description = trim(implode("\n", $lines)); + // Handle line continuation with trailing backslash + $description = preg_replace('/\\\\\n/', '', $description); return $description === '' ? Generator::UNDEFINED @@ -153,7 +196,7 @@ public function extractCommentSummary(string $content): string } /** - * An optional longer piece of text providing more details on the associated element’s function. + * An optional longer piece of text providing more details on the associated element's function. * * @param string $content The full docblock content */ @@ -183,19 +226,23 @@ public function extractCommentDescription(string $content): string */ public function parseVarLine(?string $docblock): array { - $comment = str_replace("\r\n", "\n", (string) $docblock); - $comment = preg_replace(['/[ \t]*\\/\*\*/', '/\*\/[ \t]*$/'], '', $comment); // '/**', '*/' + $result = ['type' => null, 'description' => null]; - if (!preg_match('/@var[ \t]+(?[^\s<>]+(?:<[^>]*>)?(?:\|[^\s<>]+(?:<[^>]*>)?)*)(?:[ \t]+\$(?\w+))?(?:[ \t]+(?.+))?$/im', (string) $comment, $matches)) { - return ['type' => null, 'description' => null]; + $docNode = $this->parsePhpDoc($docblock); + if (!$docNode) { + return $result; } - $result = array_merge( - ['type' => null, 'description' => null], - array_filter($matches, static fn ($key): bool => in_array($key, ['type', 'description']), ARRAY_FILTER_USE_KEY) - ); + $varTags = $docNode->getVarTagValues(); + if ($varTags) { + $varTag = reset($varTags); + $type = $this->formatType($varTag->type); + + $result['type'] = $type !== '' ? $type : null; + $result['description'] = $varTag->description !== '' ? trim((string) $varTag->description) : null; + } - return array_map(static fn (?string $value): ?string => null !== $value ? trim($value) : null, $result); + return $result; } /** @@ -203,24 +250,30 @@ public function parseVarLine(?string $docblock): array */ public function extractExampleDescription(string $docblock): ?string { - if (!$docblock || Generator::isDefault($docblock)) { + $docNode = $this->parsePhpDoc($docblock); + if (!$docNode) { return null; } - preg_match('/@example\s+([ \t])?(?.+)?$/im', $docblock, $matches); + foreach ($docNode->getTagsByName('@example') as $tag) { + $value = (string) $tag->value; + + return $value !== '' ? trim($value) : null; + } - return $matches['example'] ?? null; + return null; } /** - * Returns true if the \@deprecated tag is present, false otherwise. + * Returns true if the @deprecated tag is present, false otherwise. */ public function isDeprecated(?string $docblock): bool { - if (!$docblock || Generator::isDefault($docblock)) { + $docNode = $this->parsePhpDoc($docblock); + if (!$docNode) { return false; } - return 1 === preg_match('/@deprecated\s+([ \t])?(?.+)?$/im', $docblock); + return count($docNode->getDeprecatedTagValues()) > 0; } } diff --git a/tests/Fixtures/ComplexVarTypes.php b/tests/Fixtures/ComplexVarTypes.php new file mode 100644 index 000000000..8ef350842 --- /dev/null +++ b/tests/Fixtures/ComplexVarTypes.php @@ -0,0 +1,77 @@ + + */ + #[OAT\Property] + public array $map; + + /** + * A map from int to user objects. + * + * @var array + */ + #[OAT\Property] + public array $userMap; + + /** @var array Inline generic with description */ + #[OAT\Property] + public array $inlineGenericDesc; + + /** + * A map using namespaced class. + * + * @var array + */ + #[OAT\Property] + public array $namespacedMap; + + /** + * List of integer IDs. + * + * @var int[] + */ + #[OAT\Property] + public array $intList; + + /** + * Either an array or a string list. + * + * @var array|string[] + */ + #[OAT\Property] + public $arrayOrStringList; + + /** + * Nullable map of strings. + * + * @var array|null + */ + #[OAT\Property] + public ?array $nullableMap; + + /** + * A collection of users or a single user array. + * + * @var array|User[] + */ + #[OAT\Property] + public array $mixedUserList; + + /** @var array|null Nullable inline with description */ + #[OAT\Property] + public ?array $nullableInlineDesc; +} diff --git a/tests/Processors/AugmentPropertiesTest.php b/tests/Processors/AugmentPropertiesTest.php index fbac44bbb..ad429a031 100644 --- a/tests/Processors/AugmentPropertiesTest.php +++ b/tests/Processors/AugmentPropertiesTest.php @@ -350,6 +350,41 @@ public function testTypedProperties(): void ); } + public function testComplexVarTypeDescription(): void + { + $analysis = $this->analysisFromFixtures([ + 'ComplexVarTypes.php', + ], $this->processorPipeline([ + new MergeIntoOpenApi(), + new MergeIntoComponents(), + new AugmentSchemas(), + new AugmentProperties(), + ])); + + [ + $map, + $userMap, + $inlineGenericDesc, + $namespacedMap, + $intList, + $arrayOrStringList, + $nullableMap, + $mixedUserList, + $nullableInlineDesc + ] = $analysis->openapi->components->schemas[0]->properties; + + // Description should come from docblock text, not the generic type fragment + $this->assertSame('An associative array with string values.', $map->description); + $this->assertSame('A map from int to user objects.', $userMap->description); + $this->assertSame('Inline generic with description', $inlineGenericDesc->description); + $this->assertSame('A map using namespaced class.', $namespacedMap->description); + $this->assertSame('List of integer IDs.', $intList->description); + $this->assertSame('Either an array or a string list.', $arrayOrStringList->description); + $this->assertSame('Nullable map of strings.', $nullableMap->description); + $this->assertSame('A collection of users or a single user array.', $mixedUserList->description); + $this->assertSame('Nullable inline with description', $nullableInlineDesc->description); + } + protected function assertName(OA\Property $property, array $expectedValues): void { foreach ($expectedValues as $key => $val) {