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/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/Analysers/AttributeAnnotationFactory.php b/src/Analysers/AttributeAnnotationFactory.php index 5f27c087b..724714d21 100644 --- a/src/Analysers/AttributeAnnotationFactory.php +++ b/src/Analysers/AttributeAnnotationFactory.php @@ -95,7 +95,14 @@ public function build(\Reflector $reflector, Context $context): array } else { $instance->_context->property = $rp->getName(); } + } elseif ($instance instanceof OAT\Parameter) { + if (method_exists($rp, 'getDocComment')) { + if ($comment = $rp->getDocComment()) { + $instance->_context->comment = $comment; + } + } } + $annotations[] = $instance; } } diff --git a/src/Processors/AugmentParameters.php b/src/Processors/AugmentParameters.php index 5f4d5f717..7e2788b54 100644 --- a/src/Processors/AugmentParameters.php +++ b/src/Processors/AugmentParameters.php @@ -136,12 +136,19 @@ 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]; + $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]; if ($details['description']) { $parameter->description = $details['description']; } diff --git a/src/Processors/Concerns/DocblockTrait.php b/src/Processors/Concerns/DocblockTrait.php index 3b6cf797c..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,70 +67,101 @@ 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; + } + /** * Parse a docblock and return the full content/text. */ 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'] = []; + } + foreach ($docNode->getParamTagValues() as $param) { + $name = ltrim((string) $param->parameterName, '$'); + $tags['param'][$name] = [ + 'type' => (string) $param->type ?: null, + 'description' => $param->description !== '' ? $param->description : null, + ]; } - if ($skip) { - continue; + 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 */ @@ -169,7 +212,7 @@ public function extractCommentDescription(string $content): string } $description = ''; - if (false !== ($substr = substr($content, strlen((string) $summary)))) { + if (($substr = substr($content, strlen((string) $summary))) !== '') { $description = trim($substr); } @@ -183,17 +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]*$/', '', $comment); // strip '*/' + $result = ['type' => null, 'description' => null]; - preg_match('/@var\s+(?[^\s]+)([ \t])?(?.+)?+$/im', (string) $comment, $matches); + $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; } /** @@ -201,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/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/Docblocks.php b/tests/Fixtures/Scratch/Docblocks.php index c6ca693f7..44f98d8a5 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/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/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) { diff --git a/tests/Processors/DocBlockVarLineTest.php b/tests/Processors/DocBlockVarLineTest.php new file mode 100644 index 000000000..c642fe768 --- /dev/null +++ b/tests/Processors/DocBlockVarLineTest.php @@ -0,0 +1,62 @@ + [ + << '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, + ], + ]; + + yield 'single-full-line' => [ + '/* @var string|null $limit An optional limit */', + [ + 'type' => 'string|null', + 'description' => 'An optional limit', + ], + ]; + } + + #[DataProvider('varLineCases')] + public function testDocBlockVarLine(string $comment, array $expected): void + { + $this->assertSame($expected, $this->parseVarLine($comment)); + } +} 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) {