PhpStanExtractor.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\PropertyInfo\Extractor;
  11. use phpDocumentor\Reflection\Types\ContextFactory;
  12. use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode;
  13. use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
  14. use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
  15. use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
  16. use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
  17. use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
  18. use PHPStan\PhpDocParser\Lexer\Lexer;
  19. use PHPStan\PhpDocParser\Parser\ConstExprParser;
  20. use PHPStan\PhpDocParser\Parser\PhpDocParser;
  21. use PHPStan\PhpDocParser\Parser\TokenIterator;
  22. use PHPStan\PhpDocParser\Parser\TypeParser;
  23. use PHPStan\PhpDocParser\ParserConfig;
  24. use Symfony\Component\PropertyInfo\PhpStan\NameScope;
  25. use Symfony\Component\PropertyInfo\PhpStan\NameScopeFactory;
  26. use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
  27. use Symfony\Component\PropertyInfo\Type as LegacyType;
  28. use Symfony\Component\PropertyInfo\Util\PhpStanTypeHelper;
  29. use Symfony\Component\TypeInfo\Exception\UnsupportedException;
  30. use Symfony\Component\TypeInfo\Type;
  31. use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory;
  32. use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver;
  33. /**
  34. * Extracts data using PHPStan parser.
  35. *
  36. * @author Baptiste Leduc <baptiste.leduc@gmail.com>
  37. */
  38. final class PhpStanExtractor implements PropertyTypeExtractorInterface, ConstructorArgumentTypeExtractorInterface
  39. {
  40. private const PROPERTY = 0;
  41. private const ACCESSOR = 1;
  42. private const MUTATOR = 2;
  43. private PhpDocParser $phpDocParser;
  44. private Lexer $lexer;
  45. private NameScopeFactory $nameScopeFactory;
  46. private StringTypeResolver $stringTypeResolver;
  47. private TypeContextFactory $typeContextFactory;
  48. /** @var array<string, array{PhpDocNode|null, int|null, string|null, string|null}> */
  49. private array $docBlocks = [];
  50. private PhpStanTypeHelper $phpStanTypeHelper;
  51. private array $mutatorPrefixes;
  52. private array $accessorPrefixes;
  53. private array $arrayMutatorPrefixes;
  54. /** @var array<string, NameScope> */
  55. private array $contexts = [];
  56. /**
  57. * @param list<string>|null $mutatorPrefixes
  58. * @param list<string>|null $accessorPrefixes
  59. * @param list<string>|null $arrayMutatorPrefixes
  60. */
  61. public function __construct(?array $mutatorPrefixes = null, ?array $accessorPrefixes = null, ?array $arrayMutatorPrefixes = null, private bool $allowPrivateAccess = true)
  62. {
  63. if (!class_exists(ContextFactory::class)) {
  64. throw new \LogicException(\sprintf('Unable to use the "%s" class as the "phpdocumentor/type-resolver" package is not installed. Try running composer require "phpdocumentor/type-resolver".', __CLASS__));
  65. }
  66. if (!class_exists(PhpDocParser::class)) {
  67. throw new \LogicException(\sprintf('Unable to use the "%s" class as the "phpstan/phpdoc-parser" package is not installed. Try running composer require "phpstan/phpdoc-parser".', __CLASS__));
  68. }
  69. $this->phpStanTypeHelper = new PhpStanTypeHelper();
  70. $this->mutatorPrefixes = $mutatorPrefixes ?? ReflectionExtractor::$defaultMutatorPrefixes;
  71. $this->accessorPrefixes = $accessorPrefixes ?? ReflectionExtractor::$defaultAccessorPrefixes;
  72. $this->arrayMutatorPrefixes = $arrayMutatorPrefixes ?? ReflectionExtractor::$defaultArrayMutatorPrefixes;
  73. if (class_exists(ParserConfig::class)) {
  74. $parserConfig = new ParserConfig([]);
  75. $this->phpDocParser = new PhpDocParser($parserConfig, new TypeParser($parserConfig, new ConstExprParser($parserConfig)), new ConstExprParser($parserConfig));
  76. $this->lexer = new Lexer($parserConfig);
  77. } else {
  78. $this->phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser());
  79. $this->lexer = new Lexer();
  80. }
  81. $this->nameScopeFactory = new NameScopeFactory();
  82. $this->stringTypeResolver = new StringTypeResolver();
  83. $this->typeContextFactory = new TypeContextFactory($this->stringTypeResolver);
  84. }
  85. public function getTypes(string $class, string $property, array $context = []): ?array
  86. {
  87. /** @var PhpDocNode|null $docNode */
  88. [$docNode, $source, $prefix, $declaringClass] = $this->getDocBlock($class, $property);
  89. if (null === $docNode) {
  90. return null;
  91. }
  92. switch ($source) {
  93. case self::PROPERTY:
  94. $tag = '@var';
  95. break;
  96. case self::ACCESSOR:
  97. $tag = '@return';
  98. break;
  99. case self::MUTATOR:
  100. $tag = '@param';
  101. break;
  102. }
  103. $parentClass = null;
  104. $types = [];
  105. foreach ($docNode->getTagsByName($tag) as $tagDocNode) {
  106. if ($tagDocNode->value instanceof InvalidTagValueNode) {
  107. continue;
  108. }
  109. if (
  110. $tagDocNode->value instanceof ParamTagValueNode
  111. && null === $prefix
  112. && $tagDocNode->value->parameterName !== '$'.$property
  113. ) {
  114. continue;
  115. }
  116. $nameScope ??= $this->contexts[$class.'/'.$declaringClass] ??= $this->nameScopeFactory->create($class, $declaringClass);
  117. foreach ($this->phpStanTypeHelper->getTypes($tagDocNode->value, $nameScope) as $type) {
  118. switch ($type->getClassName()) {
  119. case 'self':
  120. case 'static':
  121. $resolvedClass = $class;
  122. break;
  123. case 'parent':
  124. if (false !== $resolvedClass = $parentClass ??= get_parent_class($class)) {
  125. break;
  126. }
  127. // no break
  128. default:
  129. $types[] = $type;
  130. continue 2;
  131. }
  132. $types[] = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $type->isNullable(), $resolvedClass, $type->isCollection(), $type->getCollectionKeyTypes(), $type->getCollectionValueTypes());
  133. }
  134. }
  135. if (!isset($types[0])) {
  136. return null;
  137. }
  138. if (!\in_array($prefix, $this->arrayMutatorPrefixes, true)) {
  139. return $types;
  140. }
  141. return [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $types[0])];
  142. }
  143. /**
  144. * @return LegacyType[]|null
  145. */
  146. public function getTypesFromConstructor(string $class, string $property): ?array
  147. {
  148. if (null === $tagDocNode = $this->getDocBlockFromConstructor($class, $property)) {
  149. return null;
  150. }
  151. $types = [];
  152. foreach ($this->phpStanTypeHelper->getTypes($tagDocNode, $this->nameScopeFactory->create($class)) as $type) {
  153. $types[] = $type;
  154. }
  155. if (!isset($types[0])) {
  156. return null;
  157. }
  158. return $types;
  159. }
  160. public function getType(string $class, string $property, array $context = []): ?Type
  161. {
  162. /** @var PhpDocNode|null $docNode */
  163. [$docNode, $source, $prefix, $declaringClass] = $this->getDocBlock($class, $property);
  164. if (null === $docNode) {
  165. return null;
  166. }
  167. $typeContext = $this->typeContextFactory->createFromClassName($class, $declaringClass);
  168. $tag = match ($source) {
  169. self::PROPERTY => '@var',
  170. self::ACCESSOR => '@return',
  171. self::MUTATOR => '@param',
  172. default => 'invalid',
  173. };
  174. $types = [];
  175. foreach ($docNode->getTagsByName($tag) as $tagDocNode) {
  176. if (!$tagDocNode->value instanceof ParamTagValueNode && !$tagDocNode->value instanceof ReturnTagValueNode && !$tagDocNode->value instanceof VarTagValueNode) {
  177. continue;
  178. }
  179. if ($tagDocNode->value instanceof ParamTagValueNode && null === $prefix && $tagDocNode->value->parameterName !== '$'.$property) {
  180. continue;
  181. }
  182. try {
  183. $types[] = $this->stringTypeResolver->resolve((string) $tagDocNode->value->type, $typeContext);
  184. } catch (UnsupportedException) {
  185. }
  186. }
  187. if (!$type = $types[0] ?? null) {
  188. return null;
  189. }
  190. if (!\in_array($prefix, $this->arrayMutatorPrefixes, true)) {
  191. return $type;
  192. }
  193. return Type::list($type);
  194. }
  195. public function getTypeFromConstructor(string $class, string $property): ?Type
  196. {
  197. if (!$tagDocNode = $this->getDocBlockFromConstructor($class, $property)) {
  198. return null;
  199. }
  200. $typeContext = $this->typeContextFactory->createFromClassName($class);
  201. return $this->stringTypeResolver->resolve((string) $tagDocNode->type, $typeContext);
  202. }
  203. private function getDocBlockFromConstructor(string $class, string $property): ?ParamTagValueNode
  204. {
  205. try {
  206. $reflectionClass = new \ReflectionClass($class);
  207. } catch (\ReflectionException) {
  208. return null;
  209. }
  210. if (null === $reflectionConstructor = $reflectionClass->getConstructor()) {
  211. return null;
  212. }
  213. if (!$rawDocNode = $reflectionConstructor->getDocComment()) {
  214. return null;
  215. }
  216. $phpDocNode = $this->getPhpDocNode($rawDocNode);
  217. return $this->filterDocBlockParams($phpDocNode, $property);
  218. }
  219. private function filterDocBlockParams(PhpDocNode $docNode, string $allowedParam): ?ParamTagValueNode
  220. {
  221. $tags = array_values(array_filter($docNode->getTagsByName('@param'), fn ($tagNode) => $tagNode instanceof PhpDocTagNode && ('$'.$allowedParam) === $tagNode->value->parameterName));
  222. if (!$tags) {
  223. return null;
  224. }
  225. return $tags[0]->value;
  226. }
  227. /**
  228. * @return array{PhpDocNode|null, int|null, string|null, string|null}
  229. */
  230. private function getDocBlock(string $class, string $property): array
  231. {
  232. $propertyHash = $class.'::'.$property;
  233. if (isset($this->docBlocks[$propertyHash])) {
  234. return $this->docBlocks[$propertyHash];
  235. }
  236. $ucFirstProperty = ucfirst($property);
  237. if ([$docBlock, $source, $declaringClass] = $this->getDocBlockFromProperty($class, $property)) {
  238. $data = [$docBlock, $source, null, $declaringClass];
  239. } elseif ([$docBlock, $_, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR)) {
  240. $data = [$docBlock, self::ACCESSOR, null, $declaringClass];
  241. } elseif ([$docBlock, $prefix, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::MUTATOR)) {
  242. $data = [$docBlock, self::MUTATOR, $prefix, $declaringClass];
  243. } else {
  244. $data = [null, null, null, null];
  245. }
  246. return $this->docBlocks[$propertyHash] = $data;
  247. }
  248. /**
  249. * @return array{PhpDocNode, int, string}|null
  250. */
  251. private function getDocBlockFromProperty(string $class, string $property): ?array
  252. {
  253. // Use a ReflectionProperty instead of $class to get the parent class if applicable
  254. try {
  255. $reflectionProperty = new \ReflectionProperty($class, $property);
  256. } catch (\ReflectionException) {
  257. return null;
  258. }
  259. if (!$this->canAccessMemberBasedOnItsVisibility($reflectionProperty)) {
  260. return null;
  261. }
  262. $reflector = $reflectionProperty->getDeclaringClass();
  263. foreach ($reflector->getTraits() as $trait) {
  264. if ($trait->hasProperty($property)) {
  265. return $this->getDocBlockFromProperty($trait->getName(), $property);
  266. }
  267. }
  268. // Type can be inside property docblock as `@var`
  269. $rawDocNode = $reflectionProperty->getDocComment();
  270. $phpDocNode = $rawDocNode ? $this->getPhpDocNode($rawDocNode) : null;
  271. $source = self::PROPERTY;
  272. if (!$phpDocNode?->getTagsByName('@var')) {
  273. $phpDocNode = null;
  274. }
  275. // or in the constructor as `@param` for promoted properties
  276. if (!$phpDocNode && $reflectionProperty->isPromoted()) {
  277. $constructor = new \ReflectionMethod($class, '__construct');
  278. $rawDocNode = $constructor->getDocComment();
  279. $phpDocNode = $rawDocNode ? $this->getPhpDocNode($rawDocNode) : null;
  280. $source = self::MUTATOR;
  281. }
  282. if (!$phpDocNode) {
  283. return null;
  284. }
  285. return [$phpDocNode, $source, $reflectionProperty->class];
  286. }
  287. /**
  288. * @return array{PhpDocNode, string, string}|null
  289. */
  290. private function getDocBlockFromMethod(string $class, string $ucFirstProperty, int $type): ?array
  291. {
  292. $prefixes = self::ACCESSOR === $type ? $this->accessorPrefixes : $this->mutatorPrefixes;
  293. $prefix = null;
  294. foreach ($prefixes as $prefix) {
  295. $methodName = $prefix.$ucFirstProperty;
  296. try {
  297. $reflectionMethod = new \ReflectionMethod($class, $methodName);
  298. if ($reflectionMethod->isStatic()) {
  299. continue;
  300. }
  301. if (
  302. (
  303. (self::ACCESSOR === $type && 0 === $reflectionMethod->getNumberOfRequiredParameters())
  304. || (self::MUTATOR === $type && $reflectionMethod->getNumberOfParameters() >= 1)
  305. )
  306. && $this->canAccessMemberBasedOnItsVisibility($reflectionMethod)
  307. ) {
  308. break;
  309. }
  310. } catch (\ReflectionException) {
  311. // Try the next prefix if the method doesn't exist
  312. }
  313. }
  314. if (!isset($reflectionMethod)) {
  315. return null;
  316. }
  317. if (null === $rawDocNode = $reflectionMethod->getDocComment() ?: null) {
  318. return null;
  319. }
  320. $phpDocNode = $this->getPhpDocNode($rawDocNode);
  321. return [$phpDocNode, $prefix, $reflectionMethod->class];
  322. }
  323. private function getPhpDocNode(string $rawDocNode): PhpDocNode
  324. {
  325. $tokens = new TokenIterator($this->lexer->tokenize($rawDocNode));
  326. $phpDocNode = $this->phpDocParser->parse($tokens);
  327. $tokens->consumeTokenType(Lexer::TOKEN_END);
  328. return $phpDocNode;
  329. }
  330. private function canAccessMemberBasedOnItsVisibility(\ReflectionProperty|\ReflectionMethod $member): bool
  331. {
  332. return $this->allowPrivateAccess || $member->isPublic();
  333. }
  334. }