From bd80f29f6112417fa6a07775308905ecf36b5793 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Wed, 3 Jul 2024 10:55:35 +0200 Subject: [PATCH] [PropertyInfo] Add `PropertyDescriptionExtractorInterface` to `PhpStanExtractor` --- .../Component/PropertyInfo/CHANGELOG.md | 1 + .../Extractor/PhpStanExtractor.php | 152 ++++++++++++++++-- .../Tests/Extractor/PhpDocExtractorTest.php | 4 +- .../Tests/Extractor/PhpStanExtractorTest.php | 18 +++ .../PropertyInfo/Tests/Fixtures/Dummy.php | 8 + 5 files changed, 167 insertions(+), 16 deletions(-) diff --git a/src/Symfony/Component/PropertyInfo/CHANGELOG.md b/src/Symfony/Component/PropertyInfo/CHANGELOG.md index 0ef7643e8e236..78803e270751f 100644 --- a/src/Symfony/Component/PropertyInfo/CHANGELOG.md +++ b/src/Symfony/Component/PropertyInfo/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support for `non-positive-int`, `non-negative-int` and `non-zero-int` PHPStan types to `PhpStanExtractor` + * Add `PropertyDescriptionExtractorInterface` to `PhpStanExtractor` 7.1 --- diff --git a/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php index cbf634933511a..07c29fa0a1864 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php @@ -14,8 +14,10 @@ use phpDocumentor\Reflection\Types\ContextFactory; use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\ConstExprParser; use PHPStan\PhpDocParser\Parser\PhpDocParser; @@ -24,6 +26,7 @@ use PHPStan\PhpDocParser\ParserConfig; use Symfony\Component\PropertyInfo\PhpStan\NameScope; use Symfony\Component\PropertyInfo\PhpStan\NameScopeFactory; +use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\PropertyInfo\Util\PhpStanTypeHelper; @@ -37,7 +40,7 @@ * * @author Baptiste Leduc */ -final class PhpStanExtractor implements PropertyTypeExtractorInterface, ConstructorArgumentTypeExtractorInterface +final class PhpStanExtractor implements PropertyDescriptionExtractorInterface, PropertyTypeExtractorInterface, ConstructorArgumentTypeExtractorInterface { private const PROPERTY = 0; private const ACCESSOR = 1; @@ -242,6 +245,126 @@ public function getTypeFromConstructor(string $class, string $property): ?Type return $this->stringTypeResolver->resolve((string) $tagDocNode->type, $typeContext); } + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + /** @var PhpDocNode|null $docNode */ + [$docNode] = $this->getDocBlockFromProperty($class, $property); + if (null === $docNode) { + return null; + } + + if ($shortDescription = $this->getDescriptionsFromDocNode($docNode)[0]) { + return $shortDescription; + } + + foreach ($docNode->getVarTagValues() as $var) { + if ($var->description) { + return $var->description; + } + } + + return null; + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + /** @var PhpDocNode|null $docNode */ + [$docNode] = $this->getDocBlockFromProperty($class, $property); + if (null === $docNode) { + return null; + } + + return $this->getDescriptionsFromDocNode($docNode)[1]; + } + + /** + * A docblock is splitted into a template marker, a short description, an optional long description and a tags section. + * + * - The template marker is either empty, or #@+ or #@-. + * - The short description is started from a non-tag character, and until one or multiple newlines. + * - The long description (optional), is started from a non-tag character, and until a new line is encountered followed by a tag. + * - Tags, and the remaining characters + * + * This method returns the short and the long descriptions. + * + * @return array{0: ?string, 1: ?string} + */ + private function getDescriptionsFromDocNode(PhpDocNode $docNode): array + { + $isTemplateMarker = static fn (PhpDocChildNode $node): bool => $node instanceof PhpDocTextNode && ('#@+' === $node->text || '#@-' === $node->text); + + $shortDescription = ''; + $longDescription = ''; + $shortDescriptionCompleted = false; + + // BC layer for phpstan/phpdoc-parser < 2.0 + if (!class_exists(ParserConfig::class)) { + $isNewLine = static fn (PhpDocChildNode $node): bool => $node instanceof PhpDocTextNode && '' === $node->text; + + foreach ($docNode->children as $child) { + if (!$child instanceof PhpDocTextNode) { + break; + } + + if ($isTemplateMarker($child)) { + continue; + } + + if ($isNewLine($child) && !$shortDescriptionCompleted) { + if ($shortDescription) { + $shortDescriptionCompleted = true; + } + + continue; + } + + if (!$shortDescriptionCompleted) { + $shortDescription = \sprintf("%s\n%s", $shortDescription, $child->text); + + continue; + } + + $longDescription = \sprintf("%s\n%s", $longDescription, $child->text); + } + } else { + foreach ($docNode->children as $child) { + if (!$child instanceof PhpDocTextNode) { + break; + } + + if ($isTemplateMarker($child)) { + continue; + } + + foreach (explode("\n", $child->text) as $line) { + if ('' === $line && !$shortDescriptionCompleted) { + if ($shortDescription) { + $shortDescriptionCompleted = true; + } + + continue; + } + + if (!$shortDescriptionCompleted) { + $shortDescription = \sprintf("%s\n%s", $shortDescription, $line); + + continue; + } + + $longDescription = \sprintf("%s\n%s", $longDescription, $line); + } + } + } + + $shortDescription = trim(preg_replace('/^#@[+-]{1}/m', '', $shortDescription), "\n"); + $longDescription = trim($longDescription, "\n"); + + return [ + $shortDescription ?: null, + $longDescription ?: null, + ]; + } + private function getDocBlockFromConstructor(string $class, string $property): ?ParamTagValueNode { try { @@ -287,7 +410,11 @@ private function getDocBlock(string $class, string $property): array $ucFirstProperty = ucfirst($property); - if ([$docBlock, $source, $declaringClass] = $this->getDocBlockFromProperty($class, $property)) { + if ([$docBlock, $constructorDocBlock, $source, $declaringClass] = $this->getDocBlockFromProperty($class, $property)) { + if (!$docBlock?->getTagsByName('@var') && $constructorDocBlock) { + $docBlock = $constructorDocBlock; + } + $data = [$docBlock, $source, null, $declaringClass]; } elseif ([$docBlock, $_, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR)) { $data = [$docBlock, self::ACCESSOR, null, $declaringClass]; @@ -301,7 +428,7 @@ private function getDocBlock(string $class, string $property): array } /** - * @return array{PhpDocNode, int, string}|null + * @return array{?PhpDocNode, ?PhpDocNode, int, string}|null */ private function getDocBlockFromProperty(string $class, string $property): ?array { @@ -324,28 +451,25 @@ private function getDocBlockFromProperty(string $class, string $property): ?arra } } - // Type can be inside property docblock as `@var` $rawDocNode = $reflectionProperty->getDocComment(); $phpDocNode = $rawDocNode ? $this->getPhpDocNode($rawDocNode) : null; - $source = self::PROPERTY; - if (!$phpDocNode?->getTagsByName('@var')) { - $phpDocNode = null; + $constructorPhpDocNode = null; + if ($reflectionProperty->isPromoted()) { + $constructorRawDocNode = (new \ReflectionMethod($class, '__construct'))->getDocComment(); + $constructorPhpDocNode = $constructorRawDocNode ? $this->getPhpDocNode($constructorRawDocNode) : null; } - // or in the constructor as `@param` for promoted properties - if (!$phpDocNode && $reflectionProperty->isPromoted()) { - $constructor = new \ReflectionMethod($class, '__construct'); - $rawDocNode = $constructor->getDocComment(); - $phpDocNode = $rawDocNode ? $this->getPhpDocNode($rawDocNode) : null; + $source = self::PROPERTY; + if (!$phpDocNode?->getTagsByName('@var') && $constructorPhpDocNode) { $source = self::MUTATOR; } - if (!$phpDocNode) { + if (!$phpDocNode && !$constructorPhpDocNode) { return null; } - return [$phpDocNode, $source, $reflectionProperty->class]; + return [$phpDocNode, $constructorPhpDocNode, $source, $reflectionProperty->class]; } /** diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php index 9d6f9f4ee73a8..003011f87bf13 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php @@ -136,7 +136,7 @@ public static function provideLegacyTypes() null, null, ], - ['bal', [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'DateTimeImmutable')], null, null], + ['bal', [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'DateTimeImmutable')], 'A short description ignoring template.', "A long description...\n\n...over several lines."], ['parent', [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy')], null, null], ['collection', [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'DateTimeImmutable'))], null, null], ['nestedCollection', [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_STRING, false)))], null, null], @@ -545,7 +545,7 @@ public static function typeProvider(): iterable yield ['foo4', Type::null(), null, null]; yield ['foo5', Type::mixed(), null, null]; yield ['files', Type::union(Type::list(Type::object(\SplFileInfo::class)), Type::resource()), null, null]; - yield ['bal', Type::object(\DateTimeImmutable::class), null, null]; + yield ['bal', Type::object(\DateTimeImmutable::class), 'A short description ignoring template.', "A long description...\n\n...over several lines."]; yield ['parent', Type::object(ParentDummy::class), null, null]; yield ['collection', Type::list(Type::object(\DateTimeImmutable::class)), null, null]; yield ['nestedCollection', Type::list(Type::list(Type::string())), null, null]; diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php index d2d847b12fe89..5563af2a1bf07 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php @@ -1081,6 +1081,24 @@ public static function genericsProvider(): iterable Type::nullable(Type::generic(Type::object(IFace::class), Type::object(Dummy::class))), ]; } + + /** + * @dataProvider descriptionsProvider + */ + public function testGetDescriptions(string $property, ?string $shortDescription, ?string $longDescription) + { + $this->assertEquals($shortDescription, $this->extractor->getShortDescription(Dummy::class, $property)); + $this->assertEquals($longDescription, $this->extractor->getLongDescription(Dummy::class, $property)); + } + + public static function descriptionsProvider(): iterable + { + yield ['foo', 'Short description.', 'Long description.']; + yield ['bar', 'This is bar', null]; + yield ['baz', 'Should be used.', null]; + yield ['bal', 'A short description ignoring template.', "A long description...\n\n...over several lines."]; + yield ['foo2', null, null]; + } } class PhpStanOmittedParamTagTypeDocBlock diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Dummy.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Dummy.php index 17a0b02a46ed1..f41ec7f61c65f 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Dummy.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Dummy.php @@ -31,6 +31,14 @@ class Dummy extends ParentDummy protected $baz; /** + * #@+ + * A short description ignoring template. + * + * + * A long description... + * + * ...over several lines. + * * @var \DateTimeImmutable */ public $bal; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy