Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions src/Analyzed/ClassLikeResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Laravel\Surveyor\Analysis\EntityType;
use Laravel\Surveyor\Types\TemplateTagType;
use Laravel\Surveyor\Types\Type;

class ClassLikeResult
Expand All @@ -21,6 +22,9 @@ class ClassLikeResult
/** @var array<string, MethodResult> */
protected array $methods = [];

/** @var array<string, TemplateTagType> */
protected array $templateTags = [];

protected bool $arrayable = false;

/**
Expand Down Expand Up @@ -205,4 +209,25 @@ public function getUse(string $name): ?string
{
return $this->uses[$name] ?? null;
}

public function addTemplateTag(TemplateTagType $tag): void
{
$this->templateTags[$tag->name] = $tag;
}

/** @return array<string, TemplateTagType> */
public function templateTags(): array
{
return $this->templateTags;
}

public function hasTemplateTag(string $name): bool
{
return isset($this->templateTags[$name]);
}

public function getTemplateTag(string $name): ?TemplateTagType
{
return $this->templateTags[$name] ?? null;
}
}
31 changes: 29 additions & 2 deletions src/Analyzer/ArrayableResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Laravel\Surveyor\Analyzed\ClassLikeResult;
use Laravel\Surveyor\Concerns\SubstitutesTemplateBindings;
use Laravel\Surveyor\Types\ArrayType;
use Laravel\Surveyor\Types\ClassType;
use Laravel\Surveyor\Types\Contracts\Type as TypeContract;
use Laravel\Surveyor\Types\StringType;
use ReflectionClass;
use Throwable;

class ArrayableResolver
{
use SubstitutesTemplateBindings;

public function __construct(
protected Analyzer $analyzer,
) {
Expand Down Expand Up @@ -67,18 +71,41 @@ public function resolve(TypeContract $type): ?TypeContract
$returnType = $analyzed->getMethod('toArray')->returnType();

if ($returnType instanceof ArrayType) {
return $returnType;
return $this->substituteTemplateBindings($type, $analyzed, $returnType);
}
}

if ($isJsonSerializable && $analyzed->hasMethod('jsonSerialize')) {
$returnType = $analyzed->getMethod('jsonSerialize')->returnType();

if ($returnType instanceof ArrayType) {
return $returnType;
return $this->substituteTemplateBindings($type, $analyzed, $returnType);
}
}

return null;
}

protected function substituteTemplateBindings(ClassType $callerType, ClassLikeResult $analyzed, ArrayType $resolved): ArrayType
{
$templateTags = array_values($analyzed->templateTags());
$genericTypes = array_values($callerType->genericTypes());

if (empty($templateTags) || empty($genericTypes)) {
return $resolved;
}

$bindings = [];
foreach ($templateTags as $i => $tag) {
if (isset($genericTypes[$i]) && ! $genericTypes[$i] instanceof StringType) {
$bindings[$tag->name] = $genericTypes[$i];
}
}

if (empty($bindings)) {
return $resolved;
}

return $this->substituteInArrayType($resolved, $bindings);
}
}
41 changes: 41 additions & 0 deletions src/Concerns/SubstitutesTemplateBindings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Laravel\Surveyor\Concerns;

use Laravel\Surveyor\Types\ArrayShapeType;
use Laravel\Surveyor\Types\ArrayType;
use Laravel\Surveyor\Types\ClassType;
use Laravel\Surveyor\Types\Contracts\Type as TypeContract;
use Laravel\Surveyor\Types\StringType;
use Laravel\Surveyor\Types\Type;
use Laravel\Surveyor\Types\UnionType;

trait SubstitutesTemplateBindings
{
protected function substituteInType(TypeContract $type, array $bindings): TypeContract
{
return match (true) {
$type instanceof StringType && isset($bindings[$type->value]) => $bindings[$type->value],
$type instanceof ArrayShapeType => new ArrayShapeType(
$this->substituteInType($type->keyType, $bindings),
$this->substituteInType($type->valueType, $bindings),
),
$type instanceof ArrayType => $this->substituteInArrayType($type, $bindings),
$type instanceof UnionType => Type::union(...array_map(fn ($t) => $this->substituteInType($t, $bindings), $type->types)),
$type instanceof ClassType && ! empty($type->genericTypes()) => (clone $type)->setGenericTypes(
array_map(fn ($g) => $this->substituteInType($g, $bindings), $type->genericTypes())
),
default => $type,
};
}

protected function substituteInArrayType(ArrayType $type, array $bindings): ArrayType
{
$newValues = [];
foreach ($type->value as $key => $value) {
$newValues[$key] = $this->substituteInType($value, $bindings);
}

return new ArrayType($newValues);
}
}
4 changes: 4 additions & 0 deletions src/NodeResolvers/Shared/ParsesClassLikeDocBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,9 @@ protected function parseClassLikeDocBlock(Node\Stmt\ClassLike $node, ClassLikeRe

$result->addMethod($methodResult);
}

foreach ($this->docBlockParser->resolveTemplateTags($node->getDocComment()) as $tag) {
$result->addTemplateTag($tag);
}
}
}
34 changes: 22 additions & 12 deletions src/NodeResolvers/Shared/ResolvesMethodCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Surveyor\NodeResolvers\Shared;

use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Request as RequestFacade;
use Laravel\Surveyor\Concerns\LazilyLoadsDependencies;
use Laravel\Surveyor\Types\ClassType;
Expand All @@ -26,19 +27,18 @@ protected function resolveMethodCall(Node\Expr\MethodCall|Node\Expr\NullsafeMeth
$methodName = $this->from($node->name);

if (! Type::is($methodName, StringType::class) || $methodName->value === null) {
// Method names that happen to match PHP function names resolve as ClassType
// due to Util::isClassOrInterface(). Handle resource conditionals here before
// returning mixed, since methods like when() collide with Laravel's when() helper.
if (
$methodName instanceof ClassType
&& $methodName->value !== null
&& in_array($methodName->value, static::$conditionalMethods)
&& $this->isJsonResource($var)
) {
return $this->resolveResourceConditional($var, $methodName->value, $node);
}
// Method names that happen to match PHP function/class names resolve as ClassType
// due to Util::isClassOrInterface(). If the value has no namespace separator it's
// a simple identifier mis-identified as a class; treat it as the method name.
if ($methodName instanceof ClassType && $methodName->value !== null && ! str_contains($methodName->value, '\\')) {
if (in_array($methodName->value, static::$conditionalMethods) && $this->isJsonResource($var)) {
return $this->resolveResourceConditional($var, $methodName->value, $node);
}

return Type::mixed();
$methodName = Type::string($methodName->value);
} else {
return Type::mixed();
}
}

switch ($var->value) {
Expand All @@ -58,6 +58,16 @@ protected function resolveMethodCall(Node\Expr\MethodCall|Node\Expr\NullsafeMeth
return $this->resolveResourceConditional($var, $methodName->value, $node);
}

if (
$methodName->value === 'toArray'
&& count($var->genericTypes()) >= 2
&& is_a($this->scope->getUse($var->value), Collection::class, true)
) {
$genericTypes = array_values($var->genericTypes());

return Type::arrayShape($genericTypes[0], $genericTypes[1]);
}

return Type::union(
...$this->reflector->methodReturnType(
$this->scope->getUse($var->value),
Expand Down
4 changes: 4 additions & 0 deletions src/NodeResolvers/Stmt/Class_.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public function resolve(Node\Stmt\Class_ $node)

$this->parseClassLikeDocBlock($node, $result);

if (! empty($result->templateTags())) {
$this->scope->setTemplateTags(array_values($result->templateTags()));
}

if ($this->extendsResource()) {
try {
app(ResourceAnalyzer::class)->injectModelProperties($result->name(), $result, $this->scope);
Expand Down
35 changes: 33 additions & 2 deletions src/Parser/DocBlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Arr;
use Laravel\Surveyor\Analysis\Scope;
use Laravel\Surveyor\Resolvers\DocBlockResolver;
use Laravel\Surveyor\Types\TemplateTagType;
use Laravel\Surveyor\Types\Type;
// use Laravel\Surveyor\Types\Contracts\Type as TypeContract;
// use Laravel\Surveyor\Types\Type as RangerType;
Expand Down Expand Up @@ -108,11 +109,41 @@ public function parseTemplateTags(string $docBlock): array
{
$this->parse($docBlock);

$templateTags = array_map(fn ($tag) => $this->resolve($tag), $this->parsed->getTemplateTagValues());
$allTags = array_merge(
$this->parsed->getTemplateTagValues('@template'),
$this->parsed->getTemplateTagValues('@template-covariant'),
$this->parsed->getTemplateTagValues('@template-contravariant'),
);

$templateTags = array_map(fn ($tag) => $this->resolve($tag), $allTags);

$this->scope->setTemplateTags($templateTags);

return $this->parsed->getTemplateTagValues();
return $allTags;
}

/**
* @return array<string, TemplateTagType>
*/
public function resolveTemplateTags(string $docBlock): array
{
$this->parse($docBlock);

$allTags = array_merge(
$this->parsed->getTemplateTagValues('@template'),
$this->parsed->getTemplateTagValues('@template-covariant'),
$this->parsed->getTemplateTagValues('@template-contravariant'),
);

$result = [];
foreach ($allTags as $tag) {
$resolved = $this->resolve($tag);
if ($resolved instanceof TemplateTagType) {
$result[$resolved->name] = $resolved;
}
}

return $result;
}

public function parseProperties(string $docBlock): array
Expand Down
38 changes: 33 additions & 5 deletions src/Reflector/Reflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Application;
use Laravel\Surveyor\Analysis\Scope;
use Laravel\Surveyor\Analyzed\ClassLikeResult;
use Laravel\Surveyor\Concerns\LazilyLoadsDependencies;
use Laravel\Surveyor\Concerns\SubstitutesTemplateBindings;
use Laravel\Surveyor\Debug\Debug;
use Laravel\Surveyor\Support\Util;
use Laravel\Surveyor\Types\ArrayType;
Expand All @@ -30,7 +32,7 @@

class Reflector
{
use LazilyLoadsDependencies;
use LazilyLoadsDependencies, SubstitutesTemplateBindings;

protected Scope $scope;

Expand Down Expand Up @@ -346,10 +348,20 @@ public function methodReturnType(ClassType|string $class, string $method, ?Node
}

if (count($returnTypes) === 0 && $reflection->isSubclassOf(Model::class)) {
array_push(
$returnTypes,
...$this->methodReturnType(Builder::class, $method, $node),
);
$builderTypes = $this->methodReturnType(Builder::class, $method, $node);

$builderResult = $this->getAnalyzer()
->analyzeClass(Builder::class)
->result();

if ($builderResult instanceof ClassLikeResult && ! empty($builderResult->templateTags())) {
$builderTypes = array_map(
fn ($type) => $this->bindCallerTemplates($type, $className, $builderResult),
$builderTypes,
);
}

array_push($returnTypes, ...$builderTypes);
}

if (count($returnTypes) > 0) {
Expand Down Expand Up @@ -470,4 +482,20 @@ protected function getAppBinding($key)

return $this->appBindings[$key] ?? null;
}

protected function bindCallerTemplates(TypeContract $type, string $callerClass, ClassLikeResult $calleeResult): TypeContract
{
$bindings = [];
foreach ($calleeResult->templateTags() as $tag) {
if ($tag->bound instanceof ClassType && is_a($callerClass, $tag->bound->value, true)) {
$bindings[$tag->name] = new ClassType($callerClass);
}
}

if (empty($bindings)) {
return $type;
}

return $this->substituteInType($type, $bindings);
}
}
Loading