diff --git a/.gitignore b/.gitignore index 98f9d09..cd44a62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.cache /.idea/ /vendor/ -composer.lock \ No newline at end of file +composer.lock +.phpstan diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..3258f53 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,11 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/test'); + +return CegoFixer::applyRules($finder, [ + 'ternary_to_null_coalescing' => true, +]); diff --git a/composer.json b/composer.json index ec4474d..9936c87 100644 --- a/composer.json +++ b/composer.json @@ -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" } } diff --git a/extension.neon b/extension.neon index 11536cc..33b46ee 100644 --- a/extension.neon +++ b/extension.neon @@ -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 diff --git a/src/SpatieLaravelData/Collectors/CastCollector.php b/src/SpatieLaravelData/Collectors/CastCollector.php new file mode 100644 index 0000000..e9cc4ab --- /dev/null +++ b/src/SpatieLaravelData/Collectors/CastCollector.php @@ -0,0 +1,97 @@ +> + */ +class CastCollector implements Collector +{ + /** + * Returns the node type, this collector operates on + * + * @phpstan-return class-string + */ + 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); + } +} diff --git a/src/SpatieLaravelData/Collectors/ConstructorCollector.php b/src/SpatieLaravelData/Collectors/ConstructorCollector.php new file mode 100644 index 0000000..37120b5 --- /dev/null +++ b/src/SpatieLaravelData/Collectors/ConstructorCollector.php @@ -0,0 +1,183 @@ +>> + */ +class ConstructorCollector implements Collector +{ + /** + * Returns the node type, this collector operates on + * + * @phpstan-return class-string + */ + 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> + */ + 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); + } +} diff --git a/src/SpatieLaravelData/Collectors/FromCollector.php b/src/SpatieLaravelData/Collectors/FromCollector.php new file mode 100644 index 0000000..6163c53 --- /dev/null +++ b/src/SpatieLaravelData/Collectors/FromCollector.php @@ -0,0 +1,123 @@ +> + */ +class FromCollector implements Collector +{ + /** + * Returns the node type, this collector operates on + * + * @phpstan-return class-string + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Process the nodes and stores value in the collector instance + * + * @phpstan-param StaticCall $node + * + * @return string|null Collected data + */ + public function processNode(Node $node, Scope $scope): ?string + { + if ( ! $node instanceof StaticCall) { + return null; + } + + if ($this->isNotSpatieLaravelDataFromCall($node, $scope)) { + return null; + } + + $types = []; + + foreach ($node->args as $arg) { + if ($arg instanceof Node\VariadicPlaceholder) { + continue; + } + + if ( ! $arg->value instanceof Array_) { + continue; + } + + $argData = []; + + foreach ($arg->value->items as $item) { + if ( ! $item->key instanceof Node\Scalar\String_) { + continue; + } + + $type = $scope->getType($item->value); + + if ($type instanceof ConstantScalarType) { + $argData[] = new KeyTypePair($item->key->value, UnionType::fromString(get_debug_type($type->getValue()))); + } else { + $argData[] = new KeyTypePair($item->key->value, UnionType::fromString($scope->getType($item->value)->describe(VerbosityLevel::typeOnly()))); + } + } + + $types[] = $argData; + } + + return serialize(new Call( + $this->getTargetClass($node, $scope), + $types, + new Method( + $scope->getFile(), + $node->getLine(), + ) + )); + } + + /** + * Returns true if the given node is not a laravel data class static ::From call + * + * @param StaticCall $node + * @param Scope $scope + * + * @return bool + */ + private function isNotSpatieLaravelDataFromCall(StaticCall $node, Scope $scope): bool + { + if (strtolower($node->name->name) !== 'from') { + return true; + } + + return ! is_a($this->getTargetClass($node, $scope), Data::class, true); + } + + /** + * Returns the target / result class of the given static call + * + * @param StaticCall $node + * @param Scope $scope + * + * @return string + */ + private function getTargetClass(StaticCall $node, Scope $scope) + { + if ($node->class instanceof Node\Expr) { + return $scope->getType($node->class)->getReferencedClasses()[0]; + } + + return $scope->resolveName($node->class); + } +} diff --git a/src/SpatieLaravelData/Data/Call.php b/src/SpatieLaravelData/Data/Call.php new file mode 100644 index 0000000..a7a6f26 --- /dev/null +++ b/src/SpatieLaravelData/Data/Call.php @@ -0,0 +1,48 @@ +> $arrayArguments + * @param Method $method + */ + public function __construct( + public readonly string $target, + public readonly array $arrayArguments, + public readonly Method $method, + ) { + } + + /** + * Returns array containing all the necessary state of the object. + */ + public function __serialize(): array + { + return [ + 'target' => $this->target, + 'method' => serialize($this->method), + 'arrayArguments' => serialize($this->arrayArguments), + ]; + } + + /** + * Restores the object state from the given data array. + * + * @param array $data + */ + public function __unserialize(array $data): void + { + $this->target = $data['target']; + $this->method = Method::unserialize($data['method']); + $this->arrayArguments = unserialize($data['arrayArguments'], ['allowed_classes' => [KeyTypePair::class]]); + } +} diff --git a/src/SpatieLaravelData/Data/Constructor.php b/src/SpatieLaravelData/Data/Constructor.php new file mode 100644 index 0000000..6f6d79f --- /dev/null +++ b/src/SpatieLaravelData/Data/Constructor.php @@ -0,0 +1,53 @@ + $properties + */ + public function __construct( + public readonly string $class, + array $properties + ) { + $this->properties = collect($properties)->keyBy('key')->all(); + } + + /** + * Returns array containing all the necessary state of the object. + * + * @since 7.4 + * @link https://wiki.php.net/rfc/custom_object_serialization + */ + public function __serialize(): array + { + return [ + 'class' => $this->class, + 'properties' => serialize($this->properties), + ]; + } + + /** + * Restores the object state from the given data array. + * + * @param array $data + * + * @since 7.4 + * @link https://wiki.php.net/rfc/custom_object_serialization + */ + public function __unserialize(array $data): void + { + $this->class = $data['class']; + $this->properties = unserialize($data['properties'], ['allowed_classes' => [KeyTypePair::class]]); + } +} diff --git a/src/SpatieLaravelData/Data/KeyTypePair.php b/src/SpatieLaravelData/Data/KeyTypePair.php new file mode 100644 index 0000000..ceab129 --- /dev/null +++ b/src/SpatieLaravelData/Data/KeyTypePair.php @@ -0,0 +1,45 @@ + $this->key, + 'type' => serialize($this->type), + ]; + } + + /** + * Restores the object state from the given data array. + * + * @param array $data + */ + public function __unserialize(array $data): void + { + $this->key = $data['key']; + $this->type = UnionType::unserialize($data['type']); + } +} diff --git a/src/SpatieLaravelData/Data/Method.php b/src/SpatieLaravelData/Data/Method.php new file mode 100644 index 0000000..3320471 --- /dev/null +++ b/src/SpatieLaravelData/Data/Method.php @@ -0,0 +1,44 @@ + $this->file, + 'line' => $this->line, + ]; + } + + /** + * Restores the object state from the given data array. + * + * @param array $data + */ + public function __unserialize(array $data): void + { + $this->file = $data['file']; + $this->line = $data['line']; + } +} diff --git a/src/SpatieLaravelData/Rules/ValidTypeRule.php b/src/SpatieLaravelData/Rules/ValidTypeRule.php new file mode 100644 index 0000000..b8fd0ba --- /dev/null +++ b/src/SpatieLaravelData/Rules/ValidTypeRule.php @@ -0,0 +1,167 @@ + + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Processes the given node + * + * @phpstan-param TNodeType $node + * + * @return (string|RuleError)[] errors + */ + public function processNode(Node $node, Scope $scope): array + { + if ( ! $node instanceof CollectedDataNode) { + return []; + } + + $castCollector = collect($node->get(CastCollector::class)) + ->flatten() + ->map(UnionType::fromString(...)) + ->reject(fn (UnionType $unionType) => $unionType->isMixed()) + ->values() + ->all(); + + $classCollector = collect($node->get(ConstructorCollector::class)) + ->flatten(1) + ->map(Constructor::unserialize(...)) + ->keyBy('class') + ->all(); + + return collect($node->get(FromCollector::class)) + // Flatten from a list of calls pr. file, to just a list of calls. + ->flatten(1) + ->map(Call::unserialize(...)) + // Check each call for errors + ->map(fn (Call $call) => $this->compareTypes($call, $castCollector, $classCollector[$call->target])) + // Flatten from a list of errors pr. call, to just a list of errors. + ->flatten() + // To array, so PhpStan can serialize the data. + ->all(); + } + + /** + * Compares the types of the specific call, with the constructor which would be expected + * + * @param Call $call + * @param list $casts + * @param Constructor $constructor + * + * @throws ShouldNotHappenException + * + * @return array + */ + private function compareTypes(Call $call, array $casts, Constructor $constructor): array + { + $errors = []; + + foreach ($call->arrayArguments as $arrayList) { + foreach ($arrayList as $type) { + // Ignore any additional data, since it does not matter + if ( ! isset($constructor->properties[$type->key])) { + continue; + } + + $error = $this->checkType($call, $type->key, $type->type, $casts, $constructor->properties[$type->key]->type); + + if ($error !== null) { + $errors[] = $error; + } + } + } + + return $errors; + } + + /** + * Checks the specific type for a single key, with the expected types of that key. + * + * @param Call $call + * @param string $key + * @param UnionType $actualType + * @param list $casts + * @param UnionType $expectedType + * + * @throws ShouldNotHappenException + * + * @return RuleError|null + */ + private function checkType(Call $call, string $key, UnionType $actualType, array $casts, UnionType $expectedType): ?RuleError + { + // Ignore cases, where there exists a cast - since we cannot analyse them in dept + if ($this->expectedTypesMatchesExactlyCast($casts, $expectedType)) { + return null; + } + + if ( ! TypeSystem::isSubtypeOf($actualType, $expectedType)) { + return RuleErrorBuilder::message(self::getErrorMessage($key, $call->target, $expectedType, $actualType)) + ->line($call->method->line) + ->file($call->method->file) + ->tip('This is a custom CEGO rule, if you found a bug fix it in the cego/phpstan project') + ->build(); + } + + return null; + } + + /** + * Returns the error message to give the developer on errors + * + * @param string $property + * @param string $class + * @param string $expectedType + * @param string $actualType + * + * @return string + */ + public static function getErrorMessage(string $property, string $class, string $expectedType, string $actualType): string + { + return sprintf('Argument $%s for %s::__construct() expects type [%s] but [%s] was given', $property, $class, $expectedType, $actualType); + } + + /** + * Returns true if a cast exists exactly for the expected types. + * + * @param list $casts + * @param UnionType $expectedTypes + * + * @return bool + */ + private function expectedTypesMatchesExactlyCast(array $casts, UnionType $expectedTypes): bool + { + foreach ($casts as $castType) { + if (TypeSystem::isSubtypeOf($expectedTypes, $castType)) { + return true; + } + } + + return false; + } +} diff --git a/src/SpatieLaravelData/Traits/UnserializesSelf.php b/src/SpatieLaravelData/Traits/UnserializesSelf.php new file mode 100644 index 0000000..efb9df7 --- /dev/null +++ b/src/SpatieLaravelData/Traits/UnserializesSelf.php @@ -0,0 +1,11 @@ + [__CLASS__]]); + } +} diff --git a/src/TypeSystem/IntersectionType.php b/src/TypeSystem/IntersectionType.php new file mode 100644 index 0000000..6f988c3 --- /dev/null +++ b/src/TypeSystem/IntersectionType.php @@ -0,0 +1,96 @@ + + */ + public array $types = []; + + /** + * Constructor + * + * @param Type[] $types + */ + public function __construct(array $types) + { + $this->types = $types; + } + + /** + * Constructor from array representation + * + * @param array $intersectionType + * + * @return static + */ + public static function fromRaw(array $intersectionType): self + { + return new self(collect($intersectionType)->mapInto(Type::class)->all()); + } + + /** + * Returns the types composing the intersection type + * + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * Returns true if this is an intersection of a single type. + * Meaning it is not actually an intersection type. + * + * @return bool + */ + public function isIntersectionOfOne(): bool + { + return count($this->types) === 1; + } + + /** + * Returns the type in its string representation + * + * @return string + */ + public function toString(): string + { + return $this->__toString(); + } + + /** + * Allows a class to decide how it will react when it is treated like a string. + * + * @return string + */ + public function __toString(): string + { + return collect($this->types) + ->map(fn (Type $type) => $type->toString()) + ->sort() + ->implode('&'); + } + + /** + * Returns true if the type is considered to accept "mixed" + * + * @return bool + */ + public function isMixed(): bool + { + // An intersection type is mixed, if all the types are considered mixed. + foreach ($this->types as $type) { + if ( ! $type->isMixed()) { + return false; + } + } + + return true; + } +} diff --git a/src/TypeSystem/Type.php b/src/TypeSystem/Type.php new file mode 100644 index 0000000..7bef4a1 --- /dev/null +++ b/src/TypeSystem/Type.php @@ -0,0 +1,149 @@ +type = 'mixed'; + } else { + $this->type = Str::of($type)->replaceMatches('/<.*>/', '')->toString(); + } + } + + /** + * Returns true if the type is not real, meaning a variable cannot have this type. + * + * @return bool + */ + public function isNotReal(): bool + { + return in_array(strtolower($this->type), ['void', 'never'], true); + } + + /** + * Returns true if the type is of mixed + * + * @return bool + */ + public function isMixed(): bool + { + return strtolower($this->type) === 'mixed'; + } + + /** + * Returns true if the type is a number + * + * @return bool + */ + public function isNumber(): bool + { + return in_array(strtolower($this->type), ['int', 'float'], true); + } + + /** + * Returns true if the type is a float + * + * @return bool + */ + public function isFloat(): bool + { + return strtolower($this->type) === 'float'; + } + + /** + * Returns true if it is a class type + * + * @return bool + */ + public function isClass(): bool + { + return class_exists($this->type); + } + + /** + * Returns true if the a interface type + * + * @return bool + */ + public function isInterface(): bool + { + return interface_exists($this->type); + } + + /** + * Returns true if the given type exactly matches this type + * + * @param Type $type + * + * @return bool + */ + public function equals(Type $type): bool + { + return strtolower($this->type) === strtolower($type->type); + } + + /** + * Returns true if this type is exactly the given type, or direct subset (for classes and interfaces) + * + * @param Type $type + * + * @return bool + */ + public function isA(Type $type): bool + { + return strtolower($this->type) === strtolower($type->type) + || is_a($this->type, $type->type, true); + } + + /** + * Returns true if the type is class or interface type + * + * @return bool + */ + public function isClassOrInterface(): bool + { + return $this->isClass() || $this->isInterface(); + } + + /** + * Returns the string in its string representation + * + * @return string + */ + public function toString(): string + { + return $this->__toString(); + } + + /** + * Allows a class to decide how it will react when it is treated like a string. + * + * @return string + */ + public function __toString(): string + { + if ($this->isClassOrInterface()) { + return $this->type; + } + + // Handles when empty string == mixed + if ($this->isMixed()) { + return 'mixed'; + } + + return strtolower($this->type); + } +} diff --git a/src/TypeSystem/TypeSystem.php b/src/TypeSystem/TypeSystem.php new file mode 100644 index 0000000..1dcbc92 --- /dev/null +++ b/src/TypeSystem/TypeSystem.php @@ -0,0 +1,106 @@ +getIntersectionTypes() as $intersectionType) { + if ( ! self::isIntersectionTypeSubsetOfUnionType($intersectionType, $parentType)) { + return false; + } + } + + return true; + } + + /** + * Returns true if the given intersection type is a subset of the given parent type + * + * @param IntersectionType $intersectionType + * @param UnionType $parentType + * + * @return bool + */ + private static function isIntersectionTypeSubsetOfUnionType(IntersectionType $intersectionType, UnionType $parentType): bool + { + // For an intersection type to be a subset of a union type, then at least one of the underlying types + // for the intersection type has to be a subset of the type options for the union type + foreach ($intersectionType->getTypes() as $type) { + if (self::isTypeSubsetOfUnionType($type, $parentType)) { + return true; + } + } + + return false; + } + + /** + * Returns true if the given type, is a subset of the given union type + * + * @param Type $type + * @param UnionType $parentUnionType + * + * @return bool + */ + private static function isTypeSubsetOfUnionType(Type $type, UnionType $parentUnionType): bool + { + // For a specific type to be a subset of a union type, then the type + // has to be a subset of only one of the types of the underlying intersection types. + foreach ($parentUnionType->getIntersectionTypes() as $parentIntersectionType) { + foreach ($parentIntersectionType->getTypes() as $parentType) { + if (self::isTypeSubsetOfType($type, $parentType)) { + return true; + } + } + } + + return false; + } + + /** + * Returns true if the given type is a subset of the given parent type + * + * @param Type $type + * @param Type $parentType + * + * @return bool + */ + private static function isTypeSubsetOfType(Type $type, Type $parentType): bool + { + // A non-real type cannot exist as a variable, and therefor is never a subtype. + if ($type->isNotReal()) { + return false; + } + + // Everything is a subset of mixed + if ($parentType->isMixed()) { + return true; + } + + // All numbers are a subset of float + if ($type->isNumber() && $parentType->isFloat()) { + return true; + } + + // If a type equals or is a instance of the parent type, then its a subset. + if ($type->isA($parentType)) { + return true; + } + + // Otherwise it is not. + return false; + } +} diff --git a/src/TypeSystem/UnionType.php b/src/TypeSystem/UnionType.php new file mode 100644 index 0000000..2ed42e3 --- /dev/null +++ b/src/TypeSystem/UnionType.php @@ -0,0 +1,128 @@ + + */ + private array $intersectionTypes; + + /** + * @param IntersectionType[] $intersectionTypes + */ + public function __construct(array $intersectionTypes) + { + $this->intersectionTypes = $intersectionTypes; + } + + public static function fromRaw(array|Arrayable $unionType): self + { + if ($unionType instanceof Arrayable) { + $unionType = $unionType->toArray(); + } + + return new self(collect($unionType)->map(IntersectionType::fromRaw(...))->all()); + } + + public static function fromString(string $type): self + { + return self::fromRaw( + Str::of($type) + ->explode('|') + ->map(fn (string $type) => explode('&', $type)) + ->all() + ); + } + + public function isMixed(): bool + { + // A union type is mixed, if just one of the types are considered mixed + foreach ($this->intersectionTypes as $type) { + if ($type->isMixed()) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + public function getIntersectionTypes(): array + { + return $this->intersectionTypes; + } + + public function isUnionOfOne(): bool + { + return count($this->intersectionTypes) === 1; + } + + public function toString(): string + { + return $this->__toString(); + } + + /** + * Magic method {@see https://www.php.net/manual/en/language.oop5.magic.php#object.tostring} + * allows a class to decide how it will react when it is treated like a string. + * + * @return string Returns string representation of the object that + * implements this interface (and/or "__toString" magic method). + */ + public function __toString(): string + { + $type = collect($this->intersectionTypes) + ->map(function (IntersectionType $intersectionType) { + if ($intersectionType->isIntersectionOfOne()) { + return $intersectionType->toString(); + } + + return sprintf('(%s)', $intersectionType->toString()); + }) + ->sort() + ->implode('|'); + + // No need to add parentheses from intersection, unless there are more types. + if ($this->isUnionOfOne()) { + return trim($type, '()'); + } + + return $type; + } + + /** + * Returns array containing all the necessary state of the object. + * + * @since 7.4 + * @link https://wiki.php.net/rfc/custom_object_serialization + */ + public function __serialize(): array + { + return [ + 'type' => $this->toString(), + ]; + } + + /** + * Restores the object state from the given data array. + * + * @param array $data + * + * @since 7.4 + * @link https://wiki.php.net/rfc/custom_object_serialization + */ + public function __unserialize(array $data): void + { + $this->intersectionTypes = self::fromString($data['type'])->intersectionTypes; + } +} diff --git a/test/Samples/CastedSpatieLaravelData.php b/test/Samples/CastedSpatieLaravelData.php new file mode 100644 index 0000000..02e6745 --- /dev/null +++ b/test/Samples/CastedSpatieLaravelData.php @@ -0,0 +1,53 @@ + 'hello world', // A cast exists, so we don't actually know if this is legal or not + ], + [ + 'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug: + ], + ); + + self::from( + [ + 'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not + ], + [ + 'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug: + ], + ); + + self::from( + [ + 'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not + ], + [ + 'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug: + ], + ); + + return self::from( + [ + 'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not + ], + [ + 'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug: + ], + ); + } +} diff --git a/test/Samples/Casts/BackedEnumCast.php b/test/Samples/Casts/BackedEnumCast.php new file mode 100644 index 0000000..069fdf3 --- /dev/null +++ b/test/Samples/Casts/BackedEnumCast.php @@ -0,0 +1,16 @@ + $var */ + $var = ''; + + return self::from([ + 'genericProperty' => $var, + ]); + } +} diff --git a/test/Samples/InvalidComplexSpatieLaravelData.php b/test/Samples/InvalidComplexSpatieLaravelData.php new file mode 100644 index 0000000..f5a47ca --- /dev/null +++ b/test/Samples/InvalidComplexSpatieLaravelData.php @@ -0,0 +1,32 @@ + 123, + 'nullableTypeStringProperty' => 123.45, + ], + [ + 'nullableMarkStringProperty' => [], + 'nullableTypeStringProperty' => [], + 'intersectionProperty' => (object) [], + ], + ); + } +} diff --git a/test/Samples/InvalidScalarSpatieLaravelData.php b/test/Samples/InvalidScalarSpatieLaravelData.php new file mode 100644 index 0000000..bc4b3cc --- /dev/null +++ b/test/Samples/InvalidScalarSpatieLaravelData.php @@ -0,0 +1,26 @@ + null, + 'intProperty' => 123.45, + 'booleanProperty' => [], + 'floatProperty' => '12.5', + ]); + } +} diff --git a/test/Samples/ValidComplexSpatieLaravelData.php b/test/Samples/ValidComplexSpatieLaravelData.php new file mode 100644 index 0000000..e4591bd --- /dev/null +++ b/test/Samples/ValidComplexSpatieLaravelData.php @@ -0,0 +1,33 @@ + null, + 'nullableTypeStringProperty' => null, + ], + [ + 'nullableMarkStringProperty' => null, + 'nullableTypeStringProperty' => null, + 'intersectionProperty' => new UncastableSpatieDataCast(), + ], + ); + } +} diff --git a/test/Samples/ValidSpatieLaravelData.php b/test/Samples/ValidSpatieLaravelData.php new file mode 100644 index 0000000..3d0f2d2 --- /dev/null +++ b/test/Samples/ValidSpatieLaravelData.php @@ -0,0 +1,41 @@ + 'my string', + 'intProperty' => 123, + 'floatProperty' => 123.45, + 'float2Property' => 123, // int is smaller than float, so is always compatible. + 'booleanProperty' => true, + 'objectProperty' => new EmptySpatieLaravelData(), + ], [ + 'nullableObjectProperty' => null, + 'mixedPropertyType' => 'dsjahiuoas', + 'unionType' => new UncastableSpatieDataCast(), + 'intersectionType' => new EmptySpatieLaravelData(), + ]); + } +} diff --git a/test/SpatieLaravelData/ValidTypeRuleTest.php b/test/SpatieLaravelData/ValidTypeRuleTest.php new file mode 100644 index 0000000..c5efaa5 --- /dev/null +++ b/test/SpatieLaravelData/ValidTypeRuleTest.php @@ -0,0 +1,97 @@ +analyse([__DIR__ . '/../Samples/ValidSpatieLaravelData.php'], []); + } + + /** @test */ + public function it_returns_no_errors_for_valid_complex_data(): void + { + $this->analyse([__DIR__ . '/../Samples/ValidComplexSpatieLaravelData.php'], []); + } + + /** @test */ + public function it_returns_errors_for_invalid_scalar_data(): void + { + $this->analyse([__DIR__ . '/../Samples/InvalidScalarSpatieLaravelData.php'], [ + $this->expectError(19, 'stringProperty', InvalidScalarSpatieLaravelData::class, 'string', 'null'), + $this->expectError(19, 'intProperty', InvalidScalarSpatieLaravelData::class, 'int', 'float'), + $this->expectError(19, 'booleanProperty', InvalidScalarSpatieLaravelData::class, 'bool', 'array'), + $this->expectError(19, 'floatProperty', InvalidScalarSpatieLaravelData::class, 'float', 'string'), + ]); + } + + /** @test */ + public function it_returns_errors_for_invalid_complex_data(): void + { + $this->analyse([__DIR__ . '/../Samples/InvalidComplexSpatieLaravelData.php'], [ + $this->expectError(20, 'nullableMarkStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'int'), + $this->expectError(20, 'nullableTypeStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'float'), + $this->expectError(20, 'nullableMarkStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'array'), + $this->expectError(20, 'nullableTypeStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'array'), + $this->expectError(20, 'intersectionProperty', InvalidComplexSpatieLaravelData::class, '\Spatie\LaravelData\Casts\Cast&\Stringable', 'stdClass'), + ]); + } + + /** @test */ + public function it_ignores_potential_problems_for_objects_with_casts(): void + { + $this->analyse([ + __DIR__ . '/../Samples/CastedSpatieLaravelData.php', + __DIR__ . '/../Samples/Casts/BackedEnumCast.php', + __DIR__ . '/../Samples/Casts/UncastableSpatieDataCast.php', + ], []); + } + + /** @test */ + public function it_does_not_care_about_generics(): void + { + $this->analyse([ + __DIR__ . '/../Samples/GenericsSpatieLaravelData.php', + ], []); + } + + private function expectError(int $line, string $property, string $class, string $expectedType, string $actualType): array + { + return [ + ValidTypeRule::getErrorMessage($property, $class, $expectedType, $actualType), + $line, + 'This is a custom CEGO rule, if you found a bug fix it in the cego/phpstan project', + ]; + } +}