diff --git a/conf/config.neon b/conf/config.neon index 512320ccd46..f874c53fcaf 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -219,6 +219,9 @@ parameters: editorUrlTitle: null errorFormat: null __validate: true + phpStormMeta: + metaPaths: + - '.phpstorm.meta.php' extensions: rules: PHPStan\DependencyInjection\RulesExtension @@ -408,6 +411,9 @@ parametersSchema: editorUrl: schema(string(), nullable()) editorUrlTitle: schema(string(), nullable()) errorFormat: schema(string(), nullable()) + phpStormMeta: structure([ + metaPaths: listOf(string()) + ]) # irrelevant Nette parameters debugMode: bool() @@ -2061,6 +2067,23 @@ services: singleReflectionFile: %singleReflectionFile% autowired: false + # PhpStorm meta support + cachedParserForMeta: + class: PHPStan\Parser\CachedParser + arguments: + originalParser: @currentPhpVersionRichParser + cachedNodesByStringCountMax: %cache.nodesByStringCountMax% + autowired: false + - + class: PHPStan\PhpStormMeta\MetaFileParser + arguments: + parser: @cachedParserForMeta + metaPaths: %phpStormMeta.metaPaths% + - + class: PHPStan\PhpStormMeta\OverrideParser + - + class: PHPStan\PhpStormMeta\ReturnTypeExtensionGenerator + # Error formatters - diff --git a/src/DependencyInjection/Type/LazyDynamicReturnTypeExtensionRegistryProvider.php b/src/DependencyInjection/Type/LazyDynamicReturnTypeExtensionRegistryProvider.php index f992ad9ddf5..b288115345f 100644 --- a/src/DependencyInjection/Type/LazyDynamicReturnTypeExtensionRegistryProvider.php +++ b/src/DependencyInjection/Type/LazyDynamicReturnTypeExtensionRegistryProvider.php @@ -5,27 +5,40 @@ use PHPStan\Broker\Broker; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\Container; +use PHPStan\PhpStormMeta\MetaFileParser; +use PHPStan\PhpStormMeta\ReturnTypeExtensionGenerator; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\DynamicReturnTypeExtensionRegistry; +use function array_push; class LazyDynamicReturnTypeExtensionRegistryProvider implements DynamicReturnTypeExtensionRegistryProvider { private ?DynamicReturnTypeExtensionRegistry $registry = null; - public function __construct(private Container $container) + public function __construct(private Container $container, private MetaFileParser $phpStormMetaParser, private ReturnTypeExtensionGenerator $phpStormMetaExtensionGenerator) { } public function getRegistry(): DynamicReturnTypeExtensionRegistry { if ($this->registry === null) { + $dynamicMethodReturnTypeExtensions = $this->container->getServicesByTag(BrokerFactory::DYNAMIC_METHOD_RETURN_TYPE_EXTENSION_TAG); + $dynamicStaticMethodReturnTypeExtensions = $this->container->getServicesByTag(BrokerFactory::DYNAMIC_STATIC_METHOD_RETURN_TYPE_EXTENSION_TAG); + $dynamicFunctionReturnTypeExtensions = $this->container->getServicesByTag(BrokerFactory::DYNAMIC_FUNCTION_RETURN_TYPE_EXTENSION_TAG); + + $phpStormMetaExtensions = $this->phpStormMetaExtensionGenerator->generateExtensionsBasedOnMeta($this->phpStormMetaParser->getMeta()); + + array_push($dynamicMethodReturnTypeExtensions, ...$phpStormMetaExtensions->nonStaticMethodExtensions); + array_push($dynamicStaticMethodReturnTypeExtensions, ...$phpStormMetaExtensions->staticMethodExtensions); + array_push($dynamicFunctionReturnTypeExtensions, ...$phpStormMetaExtensions->functionExtensions); + $this->registry = new DynamicReturnTypeExtensionRegistry( $this->container->getByType(Broker::class), $this->container->getByType(ReflectionProvider::class), - $this->container->getServicesByTag(BrokerFactory::DYNAMIC_METHOD_RETURN_TYPE_EXTENSION_TAG), - $this->container->getServicesByTag(BrokerFactory::DYNAMIC_STATIC_METHOD_RETURN_TYPE_EXTENSION_TAG), - $this->container->getServicesByTag(BrokerFactory::DYNAMIC_FUNCTION_RETURN_TYPE_EXTENSION_TAG), + $dynamicMethodReturnTypeExtensions, + $dynamicStaticMethodReturnTypeExtensions, + $dynamicFunctionReturnTypeExtensions, ); } diff --git a/src/PhpStormMeta/FunctionReturnTypeResolver.php b/src/PhpStormMeta/FunctionReturnTypeResolver.php new file mode 100644 index 00000000000..cd0407d0a12 --- /dev/null +++ b/src/PhpStormMeta/FunctionReturnTypeResolver.php @@ -0,0 +1,55 @@ + */ + private readonly array $functionNames; + + /** + * @param list $functionNames + */ + public function __construct( + private readonly TypeFromMetaResolver $metaResolver, + array $functionNames, + ) + { + $this->functionNames = array_map(strtolower(...), $functionNames); + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + $functionName = strtolower($functionReflection->getName()); + + return in_array($functionName, $this->functionNames, true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $functionName = strtolower($functionReflection->getName()); + $args = $functionCall->getArgs(); + + if (count($args) > 0) { + return $this->metaResolver->resolveReferencedType($functionName, ...$args); + } + + return null; + } + +} diff --git a/src/PhpStormMeta/MetaFileParser.php b/src/PhpStormMeta/MetaFileParser.php new file mode 100644 index 00000000000..34769c6fdec --- /dev/null +++ b/src/PhpStormMeta/MetaFileParser.php @@ -0,0 +1,127 @@ + */ + private array $parsedMetaPaths = []; + + private bool $metaParsed; + + /** + * @param list $metaPaths + */ + public function __construct( + private readonly CachedParser $parser, + private readonly OverrideParser $overrideParser, + private readonly FileHelper $fileHelper, + private readonly array $metaPaths, + ) + { + $this->parsedMeta = new CallReturnOverrideCollection(); + $this->metaParsed = $this->metaPaths === []; + } + + public function getMeta(): CallReturnOverrideCollection + { + if (!$this->metaParsed) { + $this->parseMeta($this->parsedMeta, ...$this->metaPaths); + $this->metaParsed = true; + } + + return $this->parsedMeta; + } + + private function parseMeta( + CallReturnOverrideCollection $resultCollector, + string ...$metaPaths, + ): void + { + $metaFiles = []; + + /** @var array $newlyParsedMetaPaths */ + $newlyParsedMetaPaths = []; + foreach ($metaPaths as $relativePath) { + if (array_key_exists($relativePath, $this->parsedMetaPaths)) { + continue; + } + + $path = $this->fileHelper->absolutizePath($relativePath); + + if (is_dir($path)) { + $finder = (new Finder())->in($path)->files()->ignoreDotFiles(false); + foreach ($finder as $fileInfo) { + $metaFiles [] = $fileInfo->getPathname(); + } + } elseif (file_exists($path)) { + $singleFile = new SplFileInfo($path); + $metaFiles[] = $singleFile->getPathname(); + } + + $newlyParsedMetaPaths[$relativePath] = $path; + } + + foreach ($metaFiles as $metaFile) { + $stmts = $this->parser->parseFile($metaFile); + + foreach ($stmts as $topStmt) { + if (!($topStmt instanceof Stmt\Namespace_)) { + continue; + } + + if ($topStmt->name === null || $topStmt->name->toString() !== 'PHPSTORM_META') { + continue; + } + + foreach ($topStmt->stmts as $metaStmt) { + if (!($metaStmt instanceof Expression) + || !($metaStmt->expr instanceof FuncCall) + || !($metaStmt->expr->name instanceof Name) + || $metaStmt->expr->name->toString() !== 'override' + ) { + continue; + } + + $args = $metaStmt->expr->getArgs(); + if (count($args) < 2) { + continue; + } + + [$callableArg, $overrideArg] = $args; + $parsedOverride = $this->overrideParser->parseOverride($callableArg, $overrideArg); + + if ($parsedOverride instanceof MethodCallTypeOverride) { + $resultCollector->addMethodCallOverride($parsedOverride); + } elseif ($parsedOverride instanceof FunctionCallTypeOverride) { + $resultCollector->addFunctionCallOverride($parsedOverride); + } + } + } + + foreach ($newlyParsedMetaPaths as $relativePath => $path) { + $this->parsedMetaPaths[$relativePath] = $path; + } + } + } + +} diff --git a/src/PhpStormMeta/NonStaticMethodReturnTypeResolver.php b/src/PhpStormMeta/NonStaticMethodReturnTypeResolver.php new file mode 100644 index 00000000000..050d7bce024 --- /dev/null +++ b/src/PhpStormMeta/NonStaticMethodReturnTypeResolver.php @@ -0,0 +1,63 @@ + */ + private readonly array $methodNames; + + /** + * @param list $methodNames + */ + public function __construct( + private readonly TypeFromMetaResolver $metaResolver, + private readonly string $className, + array $methodNames, + ) + { + $this->methodNames = array_map(strtolower(...), $methodNames); + } + + public function getClass(): string + { + return $this->className; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + $methodName = strtolower($methodReflection->getName()); + + return in_array($methodName, $this->methodNames, true); + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type + { + $methodName = strtolower($methodReflection->getName()); + $args = $methodCall->getArgs(); + + if (count($args) > 0) { + $fqn = sprintf('%s::%s', $this->className, $methodName); + return $this->metaResolver->resolveReferencedType($fqn, ...$args); + } + + return null; + } + +} diff --git a/src/PhpStormMeta/OverrideParser.php b/src/PhpStormMeta/OverrideParser.php new file mode 100644 index 00000000000..d6134914ee8 --- /dev/null +++ b/src/PhpStormMeta/OverrideParser.php @@ -0,0 +1,114 @@ +value; + + $override = $overrideArg->value; + if (!$override instanceof ParserNode\Expr\FuncCall + || !$override->name instanceof ParserNode\Name + ) { + return null; + } + + $map = null; + $typeOffset = null; + $elementTypeOffset = null; + + if (count($override->getArgs()) > 0) { + $overrideName = $override->name->toString(); + $overrideArg0 = $override->getArgs()[0]->value; + if ($overrideName === 'map') { + if ($overrideArg0 instanceof ParserNode\Expr\Array_) { + $map = new ReturnTypeMap(); + foreach ($overrideArg0->items as $arrayItem) { + if (!($arrayItem instanceof ParserNode\Expr\ArrayItem) + || !($arrayItem->key instanceof ParserNode\Scalar\String_) + ) { + continue; + } + + $arrayKey = $arrayItem->key->value; + $arrayValue = $arrayItem->value; + if ($arrayValue instanceof ParserNode\Expr\ClassConstFetch + && $arrayValue->class instanceof ParserNode\Name\FullyQualified + && $arrayValue->name instanceof ParserNode\Identifier + && strtolower(trim($arrayValue->name->name)) !== '' + ) { + $map->addMapping($arrayKey, clone $arrayValue->class); + } elseif ($arrayValue instanceof ParserNode\Scalar\String_) { + $map->addMapping($arrayKey, $arrayValue->value); + } + } + } + } elseif ($overrideName === 'type') { + if ($overrideArg0 instanceof ParserNode\Scalar\LNumber) { + $typeOffset = new PassedArgumentType($overrideArg0->value); + } + } elseif ($overrideName === 'elementType') { + if ($overrideArg0 instanceof ParserNode\Scalar\LNumber) { + $elementTypeOffset = new PassedArrayElementType($overrideArg0->value); + } + } + } + + $returnType = $map ?? $typeOffset ?? $elementTypeOffset; + + if ($returnType === null) { + return null; + } + + if ($identifier instanceof ParserNode\Expr\StaticCall) { + if ($identifier->class instanceof ParserNode\Name\FullyQualified && + $identifier->name instanceof ParserNode\Identifier && + count($identifier->getArgs()) > 0 + ) { + $identifierArg0 = $identifier->getArgs()[0]->value; + if ($identifierArg0 instanceof ParserNode\Scalar\LNumber) { + return new MethodCallTypeOverride( + $identifier->class->toString(), + $identifier->name->toString(), + $identifierArg0->value, + $returnType, + ); + } + } + } + + if ($identifier instanceof ParserNode\Expr\FuncCall) { + if ($identifier->name instanceof ParserNode\Name\FullyQualified && + count($identifier->getArgs()) > 0 + ) { + $identifierArg0 = $identifier->getArgs()[0]->value; + if ($identifierArg0 instanceof ParserNode\Scalar\LNumber) { + return new FunctionCallTypeOverride( + $identifier->name->toString(), + $identifierArg0->value, + $returnType, + ); + } + } + } + + return null; + } + +} diff --git a/src/PhpStormMeta/ReturnTypeExtensionCollection.php b/src/PhpStormMeta/ReturnTypeExtensionCollection.php new file mode 100644 index 00000000000..1229d656674 --- /dev/null +++ b/src/PhpStormMeta/ReturnTypeExtensionCollection.php @@ -0,0 +1,21 @@ + */ + public array $nonStaticMethodExtensions = []; + + /** @var list */ + public array $staticMethodExtensions = []; + + /** @var list */ + public array $functionExtensions = []; + +} diff --git a/src/PhpStormMeta/ReturnTypeExtensionGenerator.php b/src/PhpStormMeta/ReturnTypeExtensionGenerator.php new file mode 100644 index 00000000000..da1d59a9045 --- /dev/null +++ b/src/PhpStormMeta/ReturnTypeExtensionGenerator.php @@ -0,0 +1,49 @@ +> $classMethodMap */ + $classMethodMap = []; + /** @var list $functionList */ + $functionList = []; + + foreach ($overrides->getAllOverrides() as $override) { + if ($override instanceof MethodCallTypeOverride) { + if (!array_key_exists($override->classlikeName, $classMethodMap)) { + $classMethodMap[$override->classlikeName] = []; + } + $classMethodMap[$override->classlikeName][] = $override->methodName; + } elseif ($override instanceof FunctionCallTypeOverride) { + $functionList [] = $override->functionName; + } + } + + foreach ($classMethodMap as $className => $methodList) { + $result->nonStaticMethodExtensions [] = new NonStaticMethodReturnTypeResolver($resolver, $className, $methodList); + $result->staticMethodExtensions [] = new StaticMethodReturnTypeResolver($resolver, $className, $methodList); + } + + $result->functionExtensions [] = new FunctionReturnTypeResolver($resolver, $functionList); + + return $result; + } + +} diff --git a/src/PhpStormMeta/StaticMethodReturnTypeResolver.php b/src/PhpStormMeta/StaticMethodReturnTypeResolver.php new file mode 100644 index 00000000000..8191c26ac75 --- /dev/null +++ b/src/PhpStormMeta/StaticMethodReturnTypeResolver.php @@ -0,0 +1,63 @@ + */ + private readonly array $methodNames; + + /** + * @param list $methodNames + */ + public function __construct( + private readonly TypeFromMetaResolver $metaResolver, + private readonly string $className, + array $methodNames, + ) + { + $this->methodNames = array_map(strtolower(...), $methodNames); + } + + public function getClass(): string + { + return $this->className; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + $methodName = strtolower($methodReflection->getName()); + + return in_array($methodName, $this->methodNames, true); + } + + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type + { + $methodName = strtolower($methodReflection->getName()); + $args = $methodCall->getArgs(); + + if (count($args) > 0) { + $fqn = sprintf('%s::%s', $this->className, $methodName); + return $this->metaResolver->resolveReferencedType($fqn, ...$args); + } + + return null; + } + +} diff --git a/src/PhpStormMeta/TypeFromMetaResolver.php b/src/PhpStormMeta/TypeFromMetaResolver.php new file mode 100644 index 00000000000..3c6f927320d --- /dev/null +++ b/src/PhpStormMeta/TypeFromMetaResolver.php @@ -0,0 +1,99 @@ +overrides->getOverrideForCall($fqn); + + if ($override !== null) { + $arg = $args[$override->argumentOffset] ?? null; + if ($arg !== null) { + return $this->resolveTypeFromArgument($override->returnType, $arg); + } + } + + return null; + } + + private function resolveTypeFromArgument(CallReturnTypeOverride $overrideType, Arg $arg): ?Type + { + $argValue = $arg->value; + if ($overrideType instanceof ReturnTypeMap) { + if ($argValue instanceof String_) { + $resolvedType = $overrideType->getMappingForArgument($argValue->value); + return $this->parseResolvedType($resolvedType); + } + return null; + } + + if ($overrideType instanceof PassedArgumentType) { + // TODO + return null; + } + + if ($overrideType instanceof PassedArrayElementType) { + // TODO + return null; + } + + throw new InvalidArgumentException(); + } + + private function parseResolvedType(FullyQualified|string|null $resolvedType): ?Type + { + if ($resolvedType === null) { + return null; + } + + if ($resolvedType instanceof FullyQualified) { + return new ObjectType($resolvedType->toString()); + } + + $resolvedType = trim($resolvedType); + if ($resolvedType === '') { + return null; + } + + $unionTypes = explode('|', $resolvedType); + if (count($unionTypes) === 1) { + return new ObjectType($resolvedType); + } + + $resolvedSubtypes = []; + foreach ($unionTypes as $subtype) { + $resolvedSubtypes [] = new ObjectType($subtype); + } + + return TypeCombinator::union(...$resolvedSubtypes); + } + +} diff --git a/src/PhpStormMeta/TypeMapping/CallReturnOverrideCollection.php b/src/PhpStormMeta/TypeMapping/CallReturnOverrideCollection.php new file mode 100644 index 00000000000..3027855bd3a --- /dev/null +++ b/src/PhpStormMeta/TypeMapping/CallReturnOverrideCollection.php @@ -0,0 +1,57 @@ + */ + private array $overridesByFqn = []; + + public function addMethodCallOverride(MethodCallTypeOverride $override): void + { + $fqn = sprintf('%s::%s', $override->classlikeName, $override->methodName); + + $key = strtolower($fqn); + + if (array_key_exists($key, $this->overridesByFqn)) { + throw new LogicException(sprintf("An override for method '%s' has already been defined", $fqn)); + } + + $this->overridesByFqn[$key] = $override; + } + + public function addFunctionCallOverride(FunctionCallTypeOverride $override): void + { + $fqn = $override->functionName; + + $key = strtolower($fqn); + + if (array_key_exists($key, $this->overridesByFqn)) { + throw new LogicException(sprintf("An override for function '%s' has already been defined", $fqn)); + } + + $this->overridesByFqn[$key] = $override; + } + + /** + * @return array + */ + public function getAllOverrides(): array + { + return $this->overridesByFqn; + } + + public function getOverrideForCall(string $fqn): MethodCallTypeOverride|FunctionCallTypeOverride|null + { + $key = strtolower($fqn); + + return $this->overridesByFqn[$key] ?? null; + } + +} diff --git a/src/PhpStormMeta/TypeMapping/CallReturnTypeOverride.php b/src/PhpStormMeta/TypeMapping/CallReturnTypeOverride.php new file mode 100644 index 00000000000..b44e3d2631b --- /dev/null +++ b/src/PhpStormMeta/TypeMapping/CallReturnTypeOverride.php @@ -0,0 +1,8 @@ + */ + private array $map = []; + + public function addMapping(string $argumentName, string|ParserNode\Name\FullyQualified $returnTypeMapping): void + { + if (array_key_exists($argumentName, $this->map)) { + throw new LogicException(sprintf("Return type for argument '%s' already specified", $argumentName)); + } + $this->map[$argumentName] = $returnTypeMapping; + } + + public function getMappingForArgument(string $argumentName): string|ParserNode\Name\FullyQualified|null + { + return $this->map[$argumentName] ?? null; + } + + public static function merge(self ...$maps): self + { + $result = new self(); + + foreach ($maps as $map) { + foreach ($map->map as $argumentName => $returnTypeMapping) { + $result->map[$argumentName] = $returnTypeMapping; + } + } + + return $result; + } + +}