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;