diff --git a/src/Infer/Analyzer/MethodAnalysisResult.php b/src/Infer/Analyzer/MethodAnalysisResult.php new file mode 100644 index 00000000..8f662274 --- /dev/null +++ b/src/Infer/Analyzer/MethodAnalysisResult.php @@ -0,0 +1,15 @@ +traverseClassMethod( + $scope = $this->traverseClassMethod( [$this->getClassReflector()->getMethod($methodDefinition->type->name)->getAstNode()], $methodDefinition, $indexBuilders, @@ -40,7 +40,10 @@ public function analyze(FunctionLikeDefinition $methodDefinition, array $indexBu $methodDefinition->isFullyAnalyzed = true; - return $methodDefinition; + return new MethodAnalysisResult( + scope: $scope, + definition: $methodDefinition, + ); } private function getClassReflector(): ClassReflector @@ -57,7 +60,7 @@ private function traverseClassMethod(array $nodes, FunctionLikeDefinition $metho $traverser->addVisitor(new TypeInferer( $this->index, $nameResolver, - new Scope($this->index, new NodeTypesResolver(), new ScopeContext($this->classDefinition), $nameResolver), + $scope = new Scope($this->index, new NodeTypesResolver(), new ScopeContext($this->classDefinition), $nameResolver), Context::getInstance()->extensionsBroker->extensions, [new IndexBuildingHandler($indexBuilders)], )); @@ -70,6 +73,6 @@ private function traverseClassMethod(array $nodes, FunctionLikeDefinition $metho $traverser->traverse(Arr::wrap($node)); - return $node; + return $scope; } } diff --git a/src/Infer/Definition/ClassDefinition.php b/src/Infer/Definition/ClassDefinition.php index a86d2e4f..2405285b 100644 --- a/src/Infer/Definition/ClassDefinition.php +++ b/src/Infer/Definition/ClassDefinition.php @@ -20,6 +20,8 @@ class ClassDefinition { + private array $methodsScopes = []; + public function __construct( // FQ name public string $name, @@ -57,10 +59,13 @@ public function getMethodDefinition(string $name, Scope $scope = new GlobalScope $methodDefinition = $this->methods[$name]; if (! $methodDefinition->isFullyAnalyzed()) { - $this->methods[$name] = (new MethodAnalyzer( + $result = (new MethodAnalyzer( $scope->index, $this ))->analyze($methodDefinition, $indexBuilders); + + $this->methodsScopes[$name] = $result->scope; + $this->methods[$name] = $result->definition; } $methodScope = new Scope( @@ -116,4 +121,11 @@ private function replaceTemplateInType(Type $type, array $templateTypesMap) return $type; } + + public function getMethodScope(string $methodName) + { + $this->getMethodDefinition($methodName); + + return $this->methodsScopes[$methodName] ?? null; + } } diff --git a/src/Infer/Extensions/Event/FunctionCallEvent.php b/src/Infer/Extensions/Event/FunctionCallEvent.php new file mode 100644 index 00000000..bd5e0ecb --- /dev/null +++ b/src/Infer/Extensions/Event/FunctionCallEvent.php @@ -0,0 +1,28 @@ +scope->index->getFunctionDefinition($this->name); + } + + public function getName() + { + return $this->name; + } +} diff --git a/src/Infer/Extensions/ExtensionsBroker.php b/src/Infer/Extensions/ExtensionsBroker.php index f5d6e8be..93a9bb66 100644 --- a/src/Infer/Extensions/ExtensionsBroker.php +++ b/src/Infer/Extensions/ExtensionsBroker.php @@ -41,6 +41,22 @@ public function getMethodReturnType($event) return null; } + public function getFunctionReturnType($event) + { + $extensions = array_filter($this->extensions, function ($e) use ($event) { + return $e instanceof FunctionReturnTypeExtension + && $e->shouldHandle($event->getName()); + }); + + foreach ($extensions as $extension) { + if ($propertyType = $extension->getFunctionReturnType($event)) { + return $propertyType; + } + } + + return null; + } + public function getStaticMethodReturnType($event) { $extensions = array_filter($this->extensions, function ($e) use ($event) { diff --git a/src/Infer/Extensions/FunctionReturnTypeExtension.php b/src/Infer/Extensions/FunctionReturnTypeExtension.php new file mode 100644 index 00000000..c93a0628 --- /dev/null +++ b/src/Infer/Extensions/FunctionReturnTypeExtension.php @@ -0,0 +1,13 @@ +setType( + $node, + new ConcatenatedStringType(TypeHelper::flattenStringConcatTypes([ + $scope->getType($node->left), + $scope->getType($node->right), + ])), + ); + } +} diff --git a/src/Infer/Scope/Scope.php b/src/Infer/Scope/Scope.php index b57f4746..fdc9093a 100644 --- a/src/Infer/Scope/Scope.php +++ b/src/Infer/Scope/Scope.php @@ -15,11 +15,13 @@ use Dedoc\Scramble\Support\Type\CallableStringType; use Dedoc\Scramble\Support\Type\KeyedArrayType; use Dedoc\Scramble\Support\Type\ObjectType; +use Dedoc\Scramble\Support\Type\Reference\AbstractReferenceType; use Dedoc\Scramble\Support\Type\Reference\CallableCallReferenceType; use Dedoc\Scramble\Support\Type\Reference\MethodCallReferenceType; use Dedoc\Scramble\Support\Type\Reference\NewCallReferenceType; use Dedoc\Scramble\Support\Type\Reference\PropertyFetchReferenceType; use Dedoc\Scramble\Support\Type\Reference\StaticMethodCallReferenceType; +use Dedoc\Scramble\Support\Type\Reference\StaticPropertyFetchReferenceType; use Dedoc\Scramble\Support\Type\SelfType; use Dedoc\Scramble\Support\Type\TemplateType; use Dedoc\Scramble\Support\Type\Type; @@ -54,7 +56,13 @@ public function getType(Node $node): Type } if ($node instanceof Node\Expr\ConstFetch) { - return (new ConstFetchTypeGetter)($node); + $type = (new ConstFetchTypeGetter)($node); + + if (! $type instanceof AbstractReferenceType) { + return $type; + } + + return $this->setType($node, $type); } if ($node instanceof Node\Expr\Ternary) { @@ -73,7 +81,13 @@ public function getType(Node $node): Type } if ($node instanceof Node\Expr\ClassConstFetch) { - return (new ClassConstFetchTypeGetter)($node, $this); + $type = (new ClassConstFetchTypeGetter)($node, $this); + + if (! $type instanceof AbstractReferenceType) { + return $type; + } + + return $this->setType($node, $type); } if ($node instanceof Node\Expr\BooleanNot) { @@ -117,11 +131,11 @@ public function getType(Node $node): Type $calleeType = $this->getType($node->var); if ($calleeType instanceof TemplateType) { - // @todo - // if ($calleeType->is instanceof ObjectType) { - // $calleeType = $calleeType->is; - // } - return $this->setType($node, new UnknownType("Cannot infer type of method [{$node->name->name}] call on template type: not supported yet.")); + if ($calleeType->is instanceof ObjectType) { + $calleeType = $calleeType->is; + } else { + return $this->setType($node, new UnknownType("Cannot infer type of method [{$node->name->name}] call on template type: not supported yet.")); + } } return $this->setType( @@ -147,6 +161,23 @@ public function getType(Node $node): Type ); } + if ($node instanceof Node\Expr\StaticPropertyFetch) { + // Only string method names support. + if (! $node->name instanceof Node\Identifier) { + return $type; + } + + // Only string class names support. + if (! $node->class instanceof Node\Name) { + return $type; + } + + return $this->setType( + $node, + new StaticPropertyFetchReferenceType($node->class->toString(), $node->name->name), + ); + } + if ($node instanceof Node\Expr\PropertyFetch) { // Only string prop names support. if (! $name = ($node->name->name ?? null)) { @@ -303,11 +334,6 @@ private function getVariableType(Node\Expr\Variable $node) return $type; } - public function getMethodCallType(Type $calledOn, string $methodName, array $arguments = []): Type - { - - } - public function getPropertyFetchType(Type $calledOn, string $propertyName): Type { return (new ReferenceTypeResolver($this->index))->resolve($this, new PropertyFetchReferenceType($calledOn, $propertyName)); diff --git a/src/Infer/Scope/ScopeTypeResolver.php b/src/Infer/Scope/ScopeTypeResolver.php new file mode 100644 index 00000000..85eb4638 --- /dev/null +++ b/src/Infer/Scope/ScopeTypeResolver.php @@ -0,0 +1,27 @@ +scope->getType($node); + + if (! $type) { + return null; + } + + return (new ReferenceTypeResolver($this->scope->index)) + ->resolve($this->scope, $type) + ->mergeAttributes($type->attributes()); + } +} diff --git a/src/Infer/Services/ConstFetchTypeGetter.php b/src/Infer/Services/ConstFetchTypeGetter.php new file mode 100644 index 00000000..e14d6169 --- /dev/null +++ b/src/Infer/Services/ConstFetchTypeGetter.php @@ -0,0 +1,33 @@ +getValue(); + + $type = TypeHelper::createTypeFromValue($constantValue); + + if ($type) { + return $type; + } + } catch (\ReflectionException $e) { + return new UnknownType('Cannot get const value'); + } + + return new UnknownType('ConstFetchTypeGetter is not yet implemented fully for non-class const fetches.'); + } +} diff --git a/src/Infer/Services/ReferenceTypeResolver.php b/src/Infer/Services/ReferenceTypeResolver.php index fdf6af49..a5c3dbbd 100644 --- a/src/Infer/Services/ReferenceTypeResolver.php +++ b/src/Infer/Services/ReferenceTypeResolver.php @@ -7,6 +7,7 @@ use Dedoc\Scramble\Infer\Definition\ClassDefinition; use Dedoc\Scramble\Infer\Definition\ClassPropertyDefinition; use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition; +use Dedoc\Scramble\Infer\Extensions\Event\FunctionCallEvent; use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent; use Dedoc\Scramble\Infer\Extensions\Event\StaticMethodCallEvent; use Dedoc\Scramble\Infer\Scope\Index; @@ -17,6 +18,7 @@ use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\Reference\AbstractReferenceType; use Dedoc\Scramble\Support\Type\Reference\CallableCallReferenceType; +use Dedoc\Scramble\Support\Type\Reference\ConstFetchReferenceType; use Dedoc\Scramble\Support\Type\Reference\Dependency\ClassDependency; use Dedoc\Scramble\Support\Type\Reference\Dependency\FunctionDependency; use Dedoc\Scramble\Support\Type\Reference\Dependency\MethodDependency; @@ -25,6 +27,7 @@ use Dedoc\Scramble\Support\Type\Reference\NewCallReferenceType; use Dedoc\Scramble\Support\Type\Reference\PropertyFetchReferenceType; use Dedoc\Scramble\Support\Type\Reference\StaticMethodCallReferenceType; +use Dedoc\Scramble\Support\Type\Reference\StaticPropertyFetchReferenceType; use Dedoc\Scramble\Support\Type\Reference\StaticReference; use Dedoc\Scramble\Support\Type\SelfType; use Dedoc\Scramble\Support\Type\SideEffects\SelfTemplateDefinition; @@ -123,6 +126,10 @@ private function checkDependencies(AbstractReferenceType $type) private function doResolve(Type $t, Type $type, Scope $scope) { $resolver = function () use ($t, $scope) { + if ($t instanceof ConstFetchReferenceType) { + return $this->resolveConstFetchReferenceType($scope, $t); + } + if ($t instanceof MethodCallReferenceType) { return $this->resolveMethodCallReferenceType($scope, $t); } @@ -131,6 +138,10 @@ private function doResolve(Type $t, Type $type, Scope $scope) return $this->resolveStaticMethodCallReferenceType($scope, $t); } + if ($t instanceof StaticPropertyFetchReferenceType) { + return $this->resolveStaticPropertyFetchReferenceType($scope, $t); + } + if ($t instanceof CallableCallReferenceType) { return $this->resolveCallableCallReferenceType($scope, $t); } @@ -157,6 +168,60 @@ private function doResolve(Type $t, Type $type, Scope $scope) return $this->resolve($scope, $resolved); } + private function resolveConstFetchReferenceType(Scope $scope, ConstFetchReferenceType $type) + { + $analyzedType = clone $type; + + if ($type->callee instanceof StaticReference) { + $contextualCalleeName = match ($type->callee->keyword) { + StaticReference::SELF => $scope->context->functionDefinition?->definingClassName, + StaticReference::STATIC => $scope->context->classDefinition?->name, + StaticReference::PARENT => $scope->context->classDefinition?->parentFqn, + }; + + // This can only happen if any of static reserved keyword used in non-class context – hence considering not possible for now. + if (! $contextualCalleeName) { + return new UnknownType("Cannot properly analyze [{$type->toString()}] reference type as static keyword used in non-class context, or current class scope has no parent."); + } + + $analyzedType->callee = $contextualCalleeName; + } + + return (new ConstFetchTypeGetter)($scope, $analyzedType->callee, $analyzedType->constName); + } + + private function resolveStaticPropertyFetchReferenceType(Scope $scope, StaticPropertyFetchReferenceType $type) + { + $analyzedType = clone $type; + + $contextualClassName = $this->resolveClassName($scope, $type->callee); + if (! $contextualClassName) { + return new UnknownType("Cannot properly analyze [{$type->toString()}] reference type as static keyword used in non-class context, or current class scope has no parent."); + } + $type->callee = $contextualClassName; + + if ( + ! array_key_exists($type->callee, $this->index->classesDefinitions) + && ! $this->resolveUnknownClassResolver($type->callee) + ) { + return new UnknownType(); + } + + /** @var ClassDefinition $calleeDefinition */ + $calleeDefinition = $this->index->getClassDefinition($type->callee); + + if (! $propertyDefinition = $calleeDefinition->getPropertyDefinition($type->propertyName)) { + return new UnknownType("Cannot get a static property type [$type->propertyName] on type [$type->callee]"); + } + + $propertyType = $propertyDefinition->type; + if (! $propertyType || $propertyType instanceof TemplateType) { + return new UnknownType("Cannot get a static property type [$type->propertyName] on type [$type->callee]"); + } + + return $propertyType; + } + private function resolveMethodCallReferenceType(Scope $scope, MethodCallReferenceType $type) { // (#self).listTableDetails() @@ -286,6 +351,26 @@ private function resolveUnknownClassResolver(string $className): ?ClassDefinitio private function resolveCallableCallReferenceType(Scope $scope, CallableCallReferenceType $type) { + if ($type->callee instanceof CallableStringType) { + $analyzedType = clone $type; + + $analyzedType->arguments = array_map( + // @todo: fix resolving arguments when deep arg is reference + fn ($t) => $t instanceof AbstractReferenceType ? $this->resolve($scope, $t) : $t, + $type->arguments, + ); + + $returnType = Context::getInstance()->extensionsBroker->getFunctionReturnType(new FunctionCallEvent( + name: $analyzedType->callee->name, + scope: $scope, + arguments: $analyzedType->arguments, + )); + + if ($returnType) { + return $returnType; + } + } + $calleeType = $type->callee instanceof CallableStringType ? $this->index->getFunctionDefinition($type->callee->name) : $this->resolve($scope, $type->callee); @@ -400,7 +485,7 @@ private function resolvePropertyFetchReferenceType(Scope $scope, PropertyFetchRe private function getFunctionCallResult( FunctionLikeDefinition $callee, array $arguments, - /* When this is a handling for method call */ + /* When this is a handling for non-static method call */ ObjectType|SelfType|null $calledOnType = null, ) { $returnType = $callee->type->getReturnType(); diff --git a/src/Infer/SimpleTypeGetters/ClassConstFetchTypeGetter.php b/src/Infer/SimpleTypeGetters/ClassConstFetchTypeGetter.php index 97938ba7..88359ea6 100644 --- a/src/Infer/SimpleTypeGetters/ClassConstFetchTypeGetter.php +++ b/src/Infer/SimpleTypeGetters/ClassConstFetchTypeGetter.php @@ -5,7 +5,9 @@ use Dedoc\Scramble\Infer\Scope\Scope; use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; use Dedoc\Scramble\Support\Type\ObjectType; +use Dedoc\Scramble\Support\Type\Reference\ConstFetchReferenceType; use Dedoc\Scramble\Support\Type\Reference\NewCallReferenceType; +use Dedoc\Scramble\Support\Type\Reference\StaticReference; use Dedoc\Scramble\Support\Type\StringType; use Dedoc\Scramble\Support\Type\Type; use Dedoc\Scramble\Support\Type\UnknownType; @@ -15,17 +17,30 @@ class ClassConstFetchTypeGetter { public function __invoke(Node\Expr\ClassConstFetch $node, Scope $scope): Type { - if ($node->name->toString() === 'class') { - if ($node->class instanceof Node\Name) { - return new LiteralStringType($node->class->toString()); - } + if ( + $node->class instanceof Node\Name + && $node->name instanceof Node\Identifier + ) { + $className = in_array($node->class->toString(), StaticReference::KEYWORDS) + ? new StaticReference($node->class->toString()) + : $node->class->toString(); + + return new ConstFetchReferenceType( + $className, + $node->name->toString(), + ); + } + if ($node->name->toString() === 'class') { $type = $scope->getType($node->class); if ($type instanceof ObjectType || $type instanceof NewCallReferenceType) { return new LiteralStringType($type->name); } + // @todo Should be totally possible to return ConstFetchReferenceType here so any reference types can be + // resolved and the most accurate type retrieved. + return new StringType(); } diff --git a/src/Infer/TypeInferer.php b/src/Infer/TypeInferer.php index e691e00a..045ac161 100644 --- a/src/Infer/TypeInferer.php +++ b/src/Infer/TypeInferer.php @@ -9,6 +9,7 @@ use Dedoc\Scramble\Infer\Handler\ArrayItemHandler; use Dedoc\Scramble\Infer\Handler\AssignHandler; use Dedoc\Scramble\Infer\Handler\ClassHandler; +use Dedoc\Scramble\Infer\Handler\ConcatHandler; use Dedoc\Scramble\Infer\Handler\CreatesScope; use Dedoc\Scramble\Infer\Handler\ExceptionInferringExtensions; use Dedoc\Scramble\Infer\Handler\ExpressionTypeInferringExtensions; @@ -42,6 +43,7 @@ public function __construct( $this->handlers = [ new FunctionLikeHandler(), new AssignHandler(), + new ConcatHandler(), new ClassHandler(), new PropertyHandler(), new ArrayHandler(), diff --git a/src/ScrambleServiceProvider.php b/src/ScrambleServiceProvider.php index 10fd2332..55ecd2a9 100644 --- a/src/ScrambleServiceProvider.php +++ b/src/ScrambleServiceProvider.php @@ -18,6 +18,7 @@ use Dedoc\Scramble\Support\Generator\Components; use Dedoc\Scramble\Support\Generator\TypeTransformer; use Dedoc\Scramble\Support\InferExtensions\AbortHelpersExceptionInfer; +use Dedoc\Scramble\Support\InferExtensions\ArrayKeysReturnTypeExtension; use Dedoc\Scramble\Support\InferExtensions\JsonResourceCallsTypeInfer; use Dedoc\Scramble\Support\InferExtensions\JsonResourceCreationInfer; use Dedoc\Scramble\Support\InferExtensions\JsonResourceTypeInfer; @@ -25,6 +26,7 @@ use Dedoc\Scramble\Support\InferExtensions\PossibleExceptionInfer; use Dedoc\Scramble\Support\InferExtensions\ResourceCollectionTypeInfer; use Dedoc\Scramble\Support\InferExtensions\ResponseFactoryTypeInfer; +use Dedoc\Scramble\Support\InferExtensions\RuleExtension; use Dedoc\Scramble\Support\InferExtensions\ValidatorTypeInfer; use Dedoc\Scramble\Support\OperationBuilder; use Dedoc\Scramble\Support\OperationExtensions\DeprecationExtension; @@ -76,6 +78,8 @@ public function configurePackage(Package $package): void $inferExtensionsClasses = array_merge([ ModelExtension::class, + RuleExtension::class, + ArrayKeysReturnTypeExtension::class, ], $inferExtensionsClasses); return array_merge( diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index ca514cbf..404ae108 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -3,6 +3,7 @@ namespace Dedoc\Scramble\Support\IndexBuilders; use Dedoc\Scramble\Infer\Scope\Scope; +use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver; use Dedoc\Scramble\Support\Generator\MissingExample; use Dedoc\Scramble\Support\Generator\Parameter; use Dedoc\Scramble\Support\Generator\Schema; @@ -151,7 +152,13 @@ private function makeStringParameter(Scope $scope, Node $node) private function makeEnumParameter(Scope $scope, Node $node) { - if (! $className = TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null) { + if (! $argType = TypeHelper::getArgType($scope, $node->args, ['default', 1])) { + return [null, null]; + } + + $argType = ReferenceTypeResolver::getInstance()->resolve($scope, $argType); + + if (! $className = $argType->value ?? null) { return [null, null]; } diff --git a/src/Support/InferExtensions/ArrayKeysReturnTypeExtension.php b/src/Support/InferExtensions/ArrayKeysReturnTypeExtension.php new file mode 100644 index 00000000..c99876db --- /dev/null +++ b/src/Support/InferExtensions/ArrayKeysReturnTypeExtension.php @@ -0,0 +1,47 @@ +getArg('array', 0); + + if ( + ! $argType instanceof ArrayType + && ! $argType instanceof KeyedArrayType + ) { + return null; + } + + if ($argType instanceof ArrayType) { + return new ArrayType(value: $argType->key); + } + + $index = 0; + + return new KeyedArrayType(array_map( + function (ArrayItemType_ $item) use (&$index) { + return new ArrayItemType_( + key: null, + value: TypeHelper::createTypeFromValue($item->key === null ? $index++ : $item->key), + ); + }, + $argType->items, + )); + } +} diff --git a/src/Support/InferExtensions/RuleExtension.php b/src/Support/InferExtensions/RuleExtension.php new file mode 100644 index 00000000..a4fa1505 --- /dev/null +++ b/src/Support/InferExtensions/RuleExtension.php @@ -0,0 +1,43 @@ + match ($event->name) { + 'in' => new Generic(In::class, [ + $event->getArg('values', 0), + ]), + 'enum' => new Generic(Enum::class, [ + $event->getArg('type', 0), + ]), + 'unique' => new Generic(Unique::class, [ + $event->getArg('table', 0), + $event->getArg('column', 1, new LiteralStringType('NULL')), + ]), + 'exists' => new Generic(Exists::class, [ + $event->getArg('table', 0), + $event->getArg('column', 1, new LiteralStringType('NULL')), + ]), + default => null, + }); + } +} diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 1ec5852a..09fadfb3 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -33,7 +33,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) $routeInfo->getMethodType(); try { - $bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()); + $bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode(), $routeInfo); $bodyParamsNames = array_map(fn ($p) => $p->name, $bodyParams); @@ -112,14 +112,14 @@ protected function hasBinary($bodyParams): bool }); } - protected function extractParamsFromRequestValidationRules(Route $route, ?ClassMethod $methodNode) + protected function extractParamsFromRequestValidationRules(Route $route, ?ClassMethod $methodNode, $routeInfo) { - [$rules, $nodesResults] = $this->extractRouteRequestValidationRules($route, $methodNode); + [$rules, $nodesResults] = $this->extractRouteRequestValidationRules($route, $methodNode, $routeInfo); return (new RulesToParameters($rules, $nodesResults, $this->openApiTransformer))->handle(); } - protected function extractRouteRequestValidationRules(Route $route, $methodNode) + protected function extractRouteRequestValidationRules(Route $route, $methodNode, $routeInfo) { $rules = []; $nodesResults = []; @@ -133,7 +133,7 @@ protected function extractRouteRequestValidationRules(Route $route, $methodNode) } if (($validateCallExtractor = new ValidateCallExtractor($methodNode))->shouldHandle()) { - if ($validateCallRules = $validateCallExtractor->extract()) { + if ($validateCallRules = $validateCallExtractor->extract($routeInfo)) { $rules = array_merge($rules, $validateCallRules); $nodesResults[] = $validateCallExtractor->node(); } diff --git a/src/Support/OperationExtensions/RulesExtractor/Rules/Contracts/StringBasedRule.php b/src/Support/OperationExtensions/RulesExtractor/Rules/Contracts/StringBasedRule.php new file mode 100644 index 00000000..903e2aff --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/Rules/Contracts/StringBasedRule.php @@ -0,0 +1,23 @@ +validate(['field' => 'in:foo,bar']) + * ``` + * + * `shouldHandle` will receive `'in'` as `$rule` argument. + * `handle` will receive `'in'` as `$rule` and `['foo', 'bar']` as `$parameters`. + */ +interface StringBasedRule +{ + public function shouldHandle(string $rule): bool; + + public function handle(OpenApiType $previousType, string $rule, array $parameters): OpenApiType; +} diff --git a/src/Support/OperationExtensions/RulesExtractor/Rules/Contracts/TypeBasedRule.php b/src/Support/OperationExtensions/RulesExtractor/Rules/Contracts/TypeBasedRule.php new file mode 100644 index 00000000..a884e845 --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/Rules/Contracts/TypeBasedRule.php @@ -0,0 +1,13 @@ +isInstanceOf(Enum::class); + } + + public function handle(OpenApiType $previousType, Type $rule): OpenApiType + { + $enumName = $this->getEnumName($rule); + + return $this->openApiTransformer->transform( + new ObjectType($enumName) + ); + } + + protected function getEnumName(Type $rule) + { + $enumTypePropertyType = $rule->getPropertyType('type'); + + if (! $enumTypePropertyType instanceof LiteralStringType) { + throw new \RuntimeException('Unexpected enum value type'); + } + + return $enumTypePropertyType->value; + } +} diff --git a/src/Support/OperationExtensions/RulesExtractor/Rules/InRule.php b/src/Support/OperationExtensions/RulesExtractor/Rules/InRule.php new file mode 100644 index 00000000..262332d4 --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/Rules/InRule.php @@ -0,0 +1,57 @@ +isInstanceOf(In::class); + } + + public function handle(OpenApiType $previousType, string|Type $rule, array $parameters = []): OpenApiType + { + $allowedValues = $this->getNormalizedValues($rule, $parameters); + + return $previousType->enum( + collect($allowedValues) + ->mapInto(Stringable::class) + ->map(fn (Stringable $v) => (string) $v->trim('"')->replace('""', '"')) + ->values() + ->all() + ); + } + + private function getNormalizedValues(Type|string $rule, array $parameters) + { + if (is_string($rule)) { + return $parameters; + } + + $valueType = $rule->getPropertyType('value'); + if (! $valueType instanceof ArrayType) { + throw new \RuntimeException('Invalid value type'); + } + + return collect($valueType->items) + ->map(fn (ArrayItemType_ $itemType) => $itemType->value) + ->filter(fn (Type $t) => $t instanceof LiteralStringType) + ->map(fn (LiteralStringType $t) => $t->value) + ->values() + ->all(); + } +} diff --git a/src/Support/OperationExtensions/RulesExtractor/Rules/StringRule.php b/src/Support/OperationExtensions/RulesExtractor/Rules/StringRule.php new file mode 100644 index 00000000..7e813faa --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/Rules/StringRule.php @@ -0,0 +1,20 @@ +handle; $validationRules = $this->node()->node ?? null; + if ($validationRules) { + $type = $routeInfo->getMethodScopeTypeResolver()->getType($validationRules); + // dump([ + // $routeInfo->className().'@'.$routeInfo->methodName() => $type->toString(), + // ]); + } + if ($validationRules) { $printer = new Standard(); $validationRulesCode = $printer->prettyPrint([$validationRules]); diff --git a/src/Support/ResponseExtractor/ModelInfo.php b/src/Support/ResponseExtractor/ModelInfo.php index 152ccc7d..6c1fb602 100644 --- a/src/Support/ResponseExtractor/ModelInfo.php +++ b/src/Support/ResponseExtractor/ModelInfo.php @@ -54,6 +54,16 @@ public function handle() { $class = $this->qualifyModel($this->class); + $reflectionClass = new ReflectionClass($class); + if (! $reflectionClass->isInstantiable()) { + return collect([ + 'instance' => null, + 'class' => $class, + 'attributes' => collect(), + 'relations' => collect(), + ]); + } + /** @var Model $model */ $model = app()->make($class); diff --git a/src/Support/RouteInfo.php b/src/Support/RouteInfo.php index 1a09f029..1f055a77 100644 --- a/src/Support/RouteInfo.php +++ b/src/Support/RouteInfo.php @@ -4,6 +4,7 @@ use Dedoc\Scramble\Infer; use Dedoc\Scramble\Infer\Reflector\MethodReflector; +use Dedoc\Scramble\Infer\Scope\ScopeTypeResolver; use Dedoc\Scramble\Infer\Services\FileParser; use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper; use Dedoc\Scramble\Support\IndexBuilders\Bag; @@ -236,4 +237,23 @@ public function getMethodType(): ?FunctionType return $this->methodType; } + + /** + * Returns a scope type resolver which allows to get the type of any + * node from the method body. + * + * @internal - not sure if this is here to stick + */ + public function getMethodScopeTypeResolver(): ?ScopeTypeResolver + { + if (! $this->isClassBased() || ! $this->getMethodType()) { + return null; + } + + $methodScope = $this->infer + ->analyzeClass($this->className()) + ->getMethodScope($this->methodName()); + + return new ScopeTypeResolver($methodScope); + } } diff --git a/src/Support/Type/ConcatenatedStringType.php b/src/Support/Type/ConcatenatedStringType.php new file mode 100644 index 00000000..d4890c87 --- /dev/null +++ b/src/Support/Type/ConcatenatedStringType.php @@ -0,0 +1,28 @@ + $p->toString(), $this->parts)).')'; + } +} diff --git a/src/Support/Type/ObjectType.php b/src/Support/Type/ObjectType.php index 08ad8cd4..d2580baa 100644 --- a/src/Support/Type/ObjectType.php +++ b/src/Support/Type/ObjectType.php @@ -2,10 +2,10 @@ namespace Dedoc\Scramble\Support\Type; +use Dedoc\Scramble\Infer\Context; use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition; use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent; use Dedoc\Scramble\Infer\Extensions\Event\PropertyFetchEvent; -use Dedoc\Scramble\Infer\Extensions\ExtensionsBroker; use Dedoc\Scramble\Infer\Scope\GlobalScope; use Dedoc\Scramble\Infer\Scope\Scope; @@ -28,7 +28,7 @@ public function isSame(Type $type) public function getPropertyType(string $propertyName, Scope $scope = new GlobalScope): Type { - if ($propertyType = app(ExtensionsBroker::class)->getPropertyType(new PropertyFetchEvent( + if ($propertyType = Context::getInstance()->extensionsBroker->getPropertyType(new PropertyFetchEvent( instance: $this, name: $propertyName, scope: $scope, @@ -54,7 +54,7 @@ public function getMethodDefinition(string $methodName, Scope $scope = new Globa public function getMethodReturnType(string $methodName, array $arguments = [], Scope $scope = new GlobalScope): ?Type { - if ($returnType = app(ExtensionsBroker::class)->getMethodReturnType(new MethodCallEvent( + if ($returnType = Context::getInstance()->extensionsBroker->getMethodReturnType(new MethodCallEvent( instance: $this, name: $methodName, scope: $scope, diff --git a/src/Support/Type/Reference/ConstFetchReferenceType.php b/src/Support/Type/Reference/ConstFetchReferenceType.php new file mode 100644 index 00000000..28a3fa2d --- /dev/null +++ b/src/Support/Type/Reference/ConstFetchReferenceType.php @@ -0,0 +1,24 @@ +callee) ? $this->callee : $this->callee->toString(); + + return "(#{$callee})::{$this->constName}"; + } + + public function dependencies(): array + { + return []; + } +} diff --git a/src/Support/Type/Reference/StaticPropertyFetchReferenceType.php b/src/Support/Type/Reference/StaticPropertyFetchReferenceType.php new file mode 100644 index 00000000..363afeb3 --- /dev/null +++ b/src/Support/Type/Reference/StaticPropertyFetchReferenceType.php @@ -0,0 +1,26 @@ +callee})::\${$this->propertyName}"; + } + + public function dependencies(): array + { + return [ + new PropertyDependency($this->callee, $this->propertyName), + ]; + } +} diff --git a/src/Support/Type/TypeHelper.php b/src/Support/Type/TypeHelper.php index ad49183b..53e4282b 100644 --- a/src/Support/Type/TypeHelper.php +++ b/src/Support/Type/TypeHelper.php @@ -147,6 +147,24 @@ public static function createTypeFromValue(mixed $value) return new LiteralBooleanType($value); } + if (is_array($value)) { + return new KeyedArrayType( + collect($value) + ->map(function ($value, $key) { + return new ArrayItemType_( + is_string($key) ? $key : null, + static::createTypeFromValue($value), + ); + }) + ->values() + ->all() + ); + } + + if (is_null($value)) { + return new NullType; + } + return null; // @todo: object } @@ -188,4 +206,15 @@ public static function createTypeFromReflectionType(ReflectionType $reflectionTy return new UnknownType('Cannot create type from reflection type '.((string) $reflectionType)); } + + /** + * @param Type[] $parts + */ + public static function flattenStringConcatTypes(array $parts): array + { + return collect($parts) + ->flatMap(fn ($t) => $t instanceof ConcatenatedStringType ? $t->parts : [$t]) + ->values() + ->all(); + } } diff --git a/tests/Infer/Scope/ScopeTest.php b/tests/Infer/Scope/ScopeTest.php index 6c6e7706..bc1b9410 100644 --- a/tests/Infer/Scope/ScopeTest.php +++ b/tests/Infer/Scope/ScopeTest.php @@ -1,5 +1,7 @@ getExpressionType($statement); @@ -27,6 +29,45 @@ function getStatementTypeForScopeTest(string $statement, array $extensions = []) ['unknown() ?: unknown() ?: unknown()', 'unknown'], ]); +it('infers static property fetch nodes types', function ($code, $expectedTypeString) { + expect(getStatementType($code)->toString())->toBe($expectedTypeString); +})->with([ + ['parent::$bar', 'unknown'], +]); + +it('infers concat string type', function ($code, $expectedTypeString) { + expect(getStatementTypeForScopeTest($code)->toString())->toBe($expectedTypeString); +})->with([ + ['"a"."b"."c"', 'string(string(a), string(b), string(c))'], +]); +it('infers concat string type with unknowns', function ($code, $expectedTypeString) { + expect(getStatementTypeForScopeTest($code)->toString())->toBe($expectedTypeString); +})->with([ + ['"a"."b".auth()->user()->id', 'string(string(a), string(b), unknown)'], +]); + +it('analyzes call type of param properly', function () { + $foo = app(ClassAnalyzer::class) + ->analyze(ScopeTest_Foo::class) + ->getMethodDefinition('foo'); + + expect($foo->type->getReturnType()->toString())->toBe('int(42)'); +}); +class ScopeTest_Foo +{ + public function foo(ScopeTest_Bar $bar) + { + return $bar->getAnswer(); + } +} +class ScopeTest_Bar +{ + public function getAnswer() + { + return 42; + } +} + it('infers match node type', function ($code, $expectedTypeString) { expect(getStatementTypeForScopeTest($code)->toString())->toBe($expectedTypeString); })->with([ diff --git a/tests/Infer/Services/ConstFetchTypeGetterTest.php b/tests/Infer/Services/ConstFetchTypeGetterTest.php new file mode 100644 index 00000000..5a85cc4a --- /dev/null +++ b/tests/Infer/Services/ConstFetchTypeGetterTest.php @@ -0,0 +1,14 @@ +toString())->toBe('list{string(foo), string(bar)}'); +}); +class ConstFetchTypeGetterTest_Foo +{ + const ARRAY = ['foo', 'bar']; +} diff --git a/tests/Infer/Services/ReferenceTypeResolverTest.php b/tests/Infer/Services/ReferenceTypeResolverTest.php index 9c4f6357..c3a06068 100644 --- a/tests/Infer/Services/ReferenceTypeResolverTest.php +++ b/tests/Infer/Services/ReferenceTypeResolverTest.php @@ -16,6 +16,54 @@ * Late static binding */ +/* + * Class' `class` const fetch + */ +it('infers static keywords const fetches on parent class', function (string $method, string $expectedType) { + $methodDef = $this->classAnalyzer + ->analyze(\Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo::class) + ->getMethodDefinition($method); + expect($methodDef->type->getReturnType()->toString())->toBe($expectedType); +})->with([ + ['selfClassFetch', 'string(Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo)'], + ['staticClassFetch', 'string(Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo)'], +]); + +it('infers static keywords const fetches on child class', function (string $method, string $expectedType) { + $methodDef = $this->classAnalyzer + ->analyze(\Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Bar::class) + ->getMethodDefinition($method); + expect($methodDef->type->getReturnType()->toString())->toBe($expectedType); +})->with([ + ['selfClassFetch', 'string(Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo)'], + ['staticClassFetch', 'string(Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Bar)'], + ['parentClassFetch', 'string(Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo)'], +]); + +/* + * Class const fetch + */ +it('infers static keywords some consts fetches on parent class', function (string $method, string $expectedType) { + $methodDef = $this->classAnalyzer + ->analyze(\Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo::class) + ->getMethodDefinition($method); + expect($methodDef->type->getReturnType()->toString())->toBe($expectedType); +})->with([ + ['selfConstFetch', 'int(42)'], + ['staticConstFetch', 'int(42)'], +]); + +it('infers static keywords some consts fetches on child class', function (string $method, string $expectedType) { + $methodDef = $this->classAnalyzer + ->analyze(\Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Bar::class) + ->getMethodDefinition($method); + expect($methodDef->type->getReturnType()->toString())->toBe($expectedType); +})->with([ + ['selfConstFetch', 'int(42)'], + ['staticConstFetch', 'int(21)'], + ['parentConstFetch', 'int(42)'], +]); + /* * New calls */ @@ -64,6 +112,67 @@ ['parentMethodCall', 'string(foo)'], ]); +/* + * Property fetches + */ +it('infers property fetches on parent class', function (string $method, string $expectedType) { + $methodDef = $this->classAnalyzer + ->analyze(\Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Foo::class) + ->getMethodDefinition($method); + expect($methodDef->type->getReturnType()->toString())->toBe($expectedType); +})->with([ + ['selfPropertyFetch', 'string(foo)'], + ['staticPropertyFetch', 'string(foo)'], +]); + +it('infers property fetches on child class', function (string $method, string $expectedType) { + $methodDef = $this->classAnalyzer + ->analyze(\Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Bar::class) + ->getMethodDefinition($method); + expect($methodDef->type->getReturnType()->toString())->toBe($expectedType); +})->with([ + ['selfPropertyFetch', 'string(foo)'], + ['staticPropertyFetch', 'string(bar)'], + ['parentPropertyFetch', 'string(foo)'], +]); + +/* + * Complex static calls + */ + +it('infers type of static method call and instance property fetch', function () { + $this->classAnalyzer->analyze(ReferenceTypeResolverTest_Foo::class); + $this->classAnalyzer->analyze(ReferenceTypeResolverTest_Bar::class); + + $type = getStatementType('(new ReferenceTypeResolverTest_Bar)->test()'); + + expect($type->toString())->toBe('list{list{string(bar), int(21)}, list{string(foo), int(21)}}'); +}); +class ReferenceTypeResolverTest_Foo +{ + public $prop = 42; + + public function oreo() + { + return ['foo', $this->prop]; + } + + public function test() + { + return [static::oreo(), self::oreo()]; + } +} +class ReferenceTypeResolverTest_Bar extends ReferenceTypeResolverTest_Foo +{ + public $prop = 21; + + public function oreo() + { + return ['bar', $this->prop]; + } +} +$a = (new ReferenceTypeResolverTest_Bar)->test(); + it('complex static call and property fetch', function () { $type = getStatementType('Dedoc\Scramble\Tests\Infer\Services\StaticCallsClasses\Bar::wow()'); diff --git a/tests/InferExtensions/RuleExtensionTest.php b/tests/InferExtensions/RuleExtensionTest.php new file mode 100644 index 00000000..b897fbcc --- /dev/null +++ b/tests/InferExtensions/RuleExtensionTest.php @@ -0,0 +1,17 @@ +toString())->toBe($expectedType); +})->with([ + ['Illuminate\Validation\Rule::in(...["values" => ["foo", "bar"]])', 'Illuminate\Validation\Rules\In'], + ['Illuminate\Validation\Rule::in(values: ["foo", "bar"])', 'Illuminate\Validation\Rules\In'], + ['Illuminate\Validation\Rule::in(["foo", "bar"])', 'Illuminate\Validation\Rules\In'], +]); diff --git a/tests/Support/InferExtensions/ArrayKeysReturnTypeExtensionTest.php b/tests/Support/InferExtensions/ArrayKeysReturnTypeExtensionTest.php new file mode 100644 index 00000000..0f1adfec --- /dev/null +++ b/tests/Support/InferExtensions/ArrayKeysReturnTypeExtensionTest.php @@ -0,0 +1,15 @@ + 1, "bar" => 2])', [new ArrayKeysReturnTypeExtension]); + + expect($type->toString())->toBe('list{string(foo), string(bar)}'); +}); + +it('infers all array keys return type from non-string keys', function () { + $type = getStatementType('array_keys([1, "foo", "bar" => 42])', [new ArrayKeysReturnTypeExtension]); + + expect($type->toString())->toBe('list{int(0), int(1), string(bar)}'); +}); diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 23a76009..050d108d 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -299,6 +299,10 @@ public function index(Illuminate\Http\Request $request) } } +it('falls back to static analysis when cannot evaluate rules', function () { + +}); + it('ignores param in rules with annotation', function () { $openApiDocument = generateForRoute(function () { return RouteFacade::get('api/test/{id}', [RequestBodyExtensionTest__ignores_rules_param_with_annotation::class, 'index']);