Skip to content
Merged
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.cache
/.idea/
/vendor/
composer.lock
composer.lock
.phpstan
11 changes: 11 additions & 0 deletions .php-cs-fixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use Cego\CegoFixer;

$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/test');

return CegoFixer::applyRules($finder, [
'ternary_to_null_coalescing' => true,
]);
20 changes: 18 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,24 @@
"email": "niza@cego.dk"
}
],
"autoload": {
"psr-4": {
"Cego\\phpstan\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Test\\": "test/"
}
},
"require": {
"nunomaduro/larastan": "^2.1",
"phpstan/phpstan": "^1.4"
"php": "^8.1",
"phpstan/phpstan": "^1.4",
"nunomaduro/larastan": "^2.4",
"spatie/laravel-data": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"cego/php-cs-fixer": "^1.0"
}
}
19 changes: 19 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
includes:
- ./../../nunomaduro/larastan/extension.neon

services:
-
class: Cego\phpstan\SpatieLaravelData\Collectors\ConstructorCollector
tags:
- phpstan.collector

-
class: Cego\phpstan\SpatieLaravelData\Collectors\FromCollector
tags:
- phpstan.collector

-
class: Cego\phpstan\SpatieLaravelData\Collectors\CastCollector
tags:
- phpstan.collector

rules:
- Cego\phpstan\SpatieLaravelData\Rules\ValidTypeRule

parameters:
level: 8
reportUnmatchedIgnoredErrors: false
Expand Down
97 changes: 97 additions & 0 deletions src/SpatieLaravelData/Collectors/CastCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace Cego\phpstan\SpatieLaravelData\Collectors;

use PhpParser\Node;
use Illuminate\Support\Str;
use PHPStan\Analyser\Scope;
use PHPStan\Type\VerbosityLevel;
use PHPStan\Collectors\Collector;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Casts\Cast;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Node\InClassMethodNode;
use PHPStan\ShouldNotHappenException;
use Cego\phpstan\TypeSystem\UnionType;
use Spatie\LaravelData\Casts\Uncastable;
use PHPStan\Reflection\ParametersAcceptorSelector;

/**
* @implements Collector<InClassMethodNode, array<string, array<int, string>>
*/
class CastCollector implements Collector
{
/**
* Returns the node type, this collector operates on
*
* @phpstan-return class-string<InClassMethodNode>
*/
public function getNodeType(): string
{
return InClassMethodNode::class;
}

/**
* Process the nodes and stores value in the collector instance
*
* @phpstan-param StaticCall $node
*
* @throws ShouldNotHappenException
*
* @return string|null Collected data
*/
public function processNode(Node $node, Scope $scope): ?string
{
// Skip wrong nodes
if ( ! $node instanceof InClassMethodNode) {
return null;
}

// Skip wrong methods
if ($this->isNotCastMethod($node)) {
return null;
}

$variant = ParametersAcceptorSelector::selectSingle($node->getMethodReflection()->getVariants());
$returnType = $variant->getReturnType();

return Str::of($returnType->describe(VerbosityLevel::typeOnly()))
// Get individual union types
->explode('|')
// Get individual intersection types
->map(fn (string $type) => Str::of($type)->explode('&'))
// For each intersection type (which might be an intersection of 1 item)
// Only keep cast information for classes / interfaces
->map(function (Collection $intersectionTypes) {
$classTypes = $intersectionTypes
// We only care about classes / interfaces
->filter(fn (string $type) => class_exists($type) || interface_exists($type))
// We do not care for the uncastable class
->reject(fn (string $type) => is_a($type, Uncastable::class, true));

// We only support intersection types of explicit classes / interfaces.
if ($intersectionTypes->count() !== $classTypes->count()) {
return [];
}

return $classTypes->all();
})
// Remove any intersection types we have deemed unfit
->reject(fn (array $collection) => empty($collection))
->pipe(UnionType::fromRaw(...))
->toString();
}

/**
* Returns true if the given node is not the cast method of a Cast class
*
* @param InClassMethodNode $node
*
* @return bool
*/
private function isNotCastMethod(InClassMethodNode $node): bool
{
return $node->getMethodReflection()->getName() !== 'cast'
|| ! $node->getMethodReflection()->getDeclaringClass()->implementsInterface(Cast::class);
}
}
183 changes: 183 additions & 0 deletions src/SpatieLaravelData/Collectors/ConstructorCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

namespace Cego\phpstan\SpatieLaravelData\Collectors;

use PhpParser\Node;
use RuntimeException;
use PhpParser\Node\Name;
use PhpParser\Node\Param;
use PHPStan\Analyser\Scope;
use Spatie\LaravelData\Data;
use PhpParser\Node\Identifier;
use PhpParser\Node\ComplexType;
use PHPStan\Collectors\Collector;
use PHPStan\Node\InClassMethodNode;
use Cego\phpstan\TypeSystem\UnionType;
use PHPStan\Reflection\ClassReflection;
use Cego\phpstan\SpatieLaravelData\Data\Constructor;
use Cego\phpstan\SpatieLaravelData\Data\KeyTypePair;

/**
* @implements Collector<InClassMethodNode, array<string, array<string, array<int, string>>>
*/
class ConstructorCollector implements Collector
{
/**
* Returns the node type, this collector operates on
*
* @phpstan-return class-string<InClassMethodNode>
*/
public function getNodeType(): string
{
return InClassMethodNode::class;
}

/**
* Process the nodes and stores value in the collector instance
*
* @phpstan-param InClassMethodNode $node
*
* @return string|null Collected data
*/
public function processNode(Node $node, Scope $scope): ?string
{
if ( ! $node instanceof InClassMethodNode) {
return null;
}

if ($this->isNotSpatieLaravelDataConstructor($node)) {
return null;
}

return serialize(new Constructor(
$node->getMethodReflection()->getDeclaringClass()->getName(),
collect($node->getOriginalNode()->getParams())->map($this->getParameterTypes(...))->all()
));
}

/**
* Returns a key-value mapping of the parameter name and its allowed types
*
* @param Param $parameter
*
* @return KeyTypePair
*/
private function getParameterTypes(Param $parameter): KeyTypePair
{
return new KeyTypePair(
$this->getParameterName($parameter),
UnionType::fromRaw($this->parseType($parameter->type)),
);
}

/**
* @param null|Identifier|Name|ComplexType $type
*
* @return array<int, array<int, string>>
*/
private function parseType($type): array
{
// If no type is defined, then return mixed.
if ($type === null) {
return [['mixed']];
}

// Simple type (int, string, bool)
if ($type instanceof Identifier) {
return [[$type->name]];
}

// Class types
if ($type instanceof Name) {
// We do not support special type checking (self, parent, static)
// since we are unlikely to use this feature,
// and implementing it is currently not straight forward.
if ($type->isSpecialClassName()) {
return [['mixed']];
}

return [[$type->toCodeString()]];
}

// Complex types
if ($type instanceof Node\ComplexType) {
if ($type instanceof Node\NullableType) {
return [
...$this->parseType($type->type),
['null'],
];
}

if ($type instanceof Node\UnionType) {
return collect($type->types)
->map(fn ($unionType) => $this->parseType($unionType))
->flatten(1)
->all();
}

if ($type instanceof Node\IntersectionType) {
return [
collect($type->types)
->map(fn ($intersectionType) => $this->parseType($intersectionType))
->flatten(2)
->all(),
];
}
}

return [['mixed']];
}

/**
* Returns the name of the given parameter
*
* @param Param $parameter
*
* @return string
*/
private function getParameterName(Param $parameter): string
{
if ( ! is_string($parameter->var->name)) {
throw new RuntimeException('A constructor property name cannot be an expression');
}

return $parameter->var->name;
}

/**
* Returns true if the given node is not a laravel data constructor
*
* @param InClassMethodNode $node
*
* @return bool
*/
private function isNotSpatieLaravelDataConstructor(InClassMethodNode $node): bool
{
return $this->isNotConstructor($node)
|| $this->isNotSpatieLaravelDataClass($node->getMethodReflection()->getDeclaringClass());
}

/**
* Returns true if the given node is not a constructor class
*
* @param InClassMethodNode $node
*
* @return bool
*/
private function isNotConstructor(InClassMethodNode $node): bool
{
return $node->getMethodReflection()->getName() !== '__construct';
}

/**
* Returns true if the given class is not a laravel data class
*
* @param ClassReflection $class
*
* @return bool
*/
private function isNotSpatieLaravelDataClass(ClassReflection $class): bool
{
return ! in_array(Data::class, $class->getParentClassesNames(), true);
}
}
Loading