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
32 changes: 24 additions & 8 deletions bin/src/MixinGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@

final class MixinGenerator
{
private const MEMBERSHIP_ASSERTIONS = [
'oneof', 'inarray', 'nulloroneof', 'nullorinarray',
'alloneof', 'allinarray', 'allnulloroneof', 'allnullorinarray',
];

/**
* @psalm-var list<string>
*
Expand Down Expand Up @@ -123,23 +128,23 @@ private function trait(ReflectionClass $assert): string
$declaredMethods = [];

foreach ($staticMethods as $method) {
$this->haveAssert[$method->getName()] = str_contains($method->getDocComment(), '@psalm-assert');
$this->haveAssert[$method->getName()] = $this->hasAssertion($method->getName(), $method->getDocComment());

$nullOr = $this->nullOr($method, 4);
if (null !== $nullOr) {
$this->haveAssert["nullOr".$method->getName()] = str_contains($nullOr, '@psalm-assert');
$this->haveAssert["nullOr".$method->getName()] = $this->hasAssertion("nullOr".$method->getName(), $nullOr);
$declaredMethods[] = $nullOr;
}

$all = $this->all($method, 4);
if (null !== $all) {
$this->haveAssert["all".$method->getName()] = str_contains($all, '@psalm-assert');
$this->haveAssert["all".$method->getName()] = $this->hasAssertion("all".$method->getName(), $all);
$declaredMethods[] = $all;
}

$allNullOr = $this->allNullOr($method, 4);
if (null !== $allNullOr) {
$this->haveAssert["allNullOr".$method->getName()] = str_contains($allNullOr, '@psalm-assert');
$this->haveAssert["allNullOr".$method->getName()] = $this->hasAssertion("allNullOr".$method->getName(), $allNullOr);
$declaredMethods[] = $allNullOr;
}
}
Expand All @@ -161,6 +166,12 @@ trait Mixin
);
}

private function hasAssertion(string $methodName, string $docComment): bool
{
return str_contains($docComment, '@psalm-assert')
|| in_array(strtolower($methodName), self::MEMBERSHIP_ASSERTIONS, true);
}

/**
* @param ReflectionMethod $method
* @param int $indent
Expand Down Expand Up @@ -338,7 +349,7 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
foreach ($values as $value) {
$parts = $this->splitDocLine($value);
if ('mixed' !== $parts[0]) {
$phpdocReturnType = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames);
$phpdocReturnType = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames, $this->preservesNullEntries($newMethodName));
}
}
continue;
Expand All @@ -357,7 +368,7 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
}

if ('psalm-assert' === $key) {
$type = $this->applyTypeTemplate($type, $typeTemplate, $templateTypeNames);
$type = $this->applyTypeTemplate($type, $typeTemplate, $templateTypeNames, $this->preservesNullEntries($newMethodName));

$phpdocReturnType = $type;
}
Expand Down Expand Up @@ -452,9 +463,9 @@ private function nullableReturnType(?ReflectionType $type): string
return '?'.$typeStr;
}

private function applyTypeTemplate(string $type, string $typeTemplate, array $templateTypeNames = []): string
private function applyTypeTemplate(string $type, string $typeTemplate, array $templateTypeNames = [], bool $preserveNullEntries = false): string
{
if (in_array($type, $templateTypeNames, true) && str_contains($typeTemplate, 'iterable') && str_contains($typeTemplate, '|null')) {
if (!$preserveNullEntries && in_array($type, $templateTypeNames, true) && str_contains($typeTemplate, 'iterable') && str_contains($typeTemplate, '|null')) {
$typeTemplate = str_replace('|null', '', $typeTemplate);
}

Expand All @@ -467,6 +478,11 @@ private function applyTypeTemplate(string $type, string $typeTemplate, array $te
return $combinedType;
}

private function preservesNullEntries(string $methodName): bool
{
return in_array(strtolower($methodName), ['allnulloroneof', 'allnullorinarray'], true);
}

private function shouldSkipAnnotation(string $newMethodName, string $key): bool
{
if (!in_array($newMethodName, $this->unsupportedMethods, true)) {
Expand Down
6 changes: 6 additions & 0 deletions src/Assert.php
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,10 @@ public static function range(mixed $value, mixed $min, mixed $max, string|callab
*
* @psalm-pure
*
* @template TAllowed
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
* @return TAllowed
*
* @throws InvalidArgumentException
*/
Expand All @@ -1234,7 +1237,10 @@ public static function oneOf(mixed $value, mixed $values, string|callable $messa
*
* @psalm-pure
*
* @template TAllowed
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
* @return TAllowed
*
* @throws InvalidArgumentException
*/
Expand Down
8 changes: 8 additions & 0 deletions src/HasAssert.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ final class HasAssert
'nulloruniquevalues' => true,
'alluniquevalues' => true,
'allnulloruniquevalues' => true,
'oneof' => true,
'nulloroneof' => true,
'alloneof' => true,
'allnulloroneof' => true,
'inarray' => true,
'nullorinarray' => true,
'allinarray' => true,
'allnullorinarray' => true,
'lower' => true,
'nullorlower' => true,
'alllower' => true,
Expand Down
42 changes: 30 additions & 12 deletions src/Mixin.php
Original file line number Diff line number Diff line change
Expand Up @@ -2861,9 +2861,12 @@ public static function allNullOrRange(mixed $value, mixed $min, mixed $max, call
/**
* @psalm-pure
*
* @param string|callable():string $message
* @template TAllowed
*
* @return mixed
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
*
* @return TAllowed|null
*
* @throws InvalidArgumentException
*/
Expand All @@ -2877,9 +2880,12 @@ public static function nullOrOneOf(mixed $value, mixed $values, callable|string
/**
* @psalm-pure
*
* @param string|callable():string $message
* @template TAllowed
*
* @return iterable
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
*
* @return iterable<TAllowed>
*
* @throws InvalidArgumentException
*/
Expand All @@ -2897,9 +2903,12 @@ public static function allOneOf(mixed $value, mixed $values, callable|string $me
/**
* @psalm-pure
*
* @param string|callable():string $message
* @template TAllowed
*
* @return iterable
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
*
* @return iterable<TAllowed|null>
*
* @throws InvalidArgumentException
*/
Expand All @@ -2917,9 +2926,12 @@ public static function allNullOrOneOf(mixed $value, mixed $values, callable|stri
/**
* @psalm-pure
*
* @param string|callable():string $message
* @template TAllowed
*
* @return mixed
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
*
* @return TAllowed|null
*
* @throws InvalidArgumentException
*/
Expand All @@ -2933,9 +2945,12 @@ public static function nullOrInArray(mixed $value, mixed $values, callable|strin
/**
* @psalm-pure
*
* @param string|callable():string $message
* @template TAllowed
*
* @return iterable
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
*
* @return iterable<TAllowed>
*
* @throws InvalidArgumentException
*/
Expand All @@ -2953,9 +2968,12 @@ public static function allInArray(mixed $value, mixed $values, callable|string $
/**
* @psalm-pure
*
* @param string|callable():string $message
* @template TAllowed
*
* @return iterable
* @param array<array-key, TAllowed> $values
* @param string|callable():string $message
*
* @return iterable<TAllowed|null>
*
* @throws InvalidArgumentException
*/
Expand Down
131 changes: 130 additions & 1 deletion src/PsalmPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
use Psalm\Plugin\EventHandler\Event\AfterMethodCallAnalysisEvent;
use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\PluginRegistrationSocket;
use Psalm\Type;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TIterable;
use Psalm\Type\Atomic\TKeyedArray;
use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Atomic\TNonEmptyArray;
use Psalm\Type\Union;
use SimpleXMLElement;

final class PsalmPlugin implements PluginEntryPointInterface, AfterMethodCallAnalysisInterface
Expand All @@ -28,7 +35,11 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve
return;
}

$firstArg = $event->getExpr()->getArgs()[0] ?? null;
$args = $event->getExpr()->getArgs();
$isMembership = \in_array(\strtolower($method), ['oneof', 'inarray', 'nulloroneof', 'nullorinarray', 'alloneof', 'allinarray', 'allnulloroneof', 'allnullorinarray'], true);
$firstArg = $isMembership
? self::getArgument($args, 'value', 0)
: ($args[0] ?? null);
if ($firstArg === null) {
return;
}
Expand All @@ -45,6 +56,124 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve

$candidateType = $event->getContext()->vars_in_scope[$varId];

if ($isMembership) {
$valuesArg = self::getArgument($args, 'values', 1);
if ($valuesArg === null) {
return;
}

$valuesType = $event->getStatementsSource()->getNodeTypeProvider()->getType($valuesArg->value);
if ($valuesType === null || !$valuesType->hasArray()) {
return;
}

$allowedType = match (true) {
$valuesType->getArray() instanceof TKeyedArray => $valuesType->getArray()->getGenericValueType(),
$valuesType->getArray() instanceof TArray => $valuesType->getArray()->type_params[1],
default => null,
};
if ($allowedType === null) {
return;
}

if (\str_starts_with(\strtolower($method), 'nullor') || \str_starts_with(\strtolower($method), 'allnullor')) {
$allowedType = Type::combineUnionTypes($allowedType, Type::getNull(), $event->getCodebase());
}

$isAll = \str_starts_with(\strtolower($method), 'all');
$candidateType = $isAll
? self::narrowIterable($candidateType, $allowedType, $event)
: self::intersect($candidateType, $allowedType, $event);

$event->getContext()->vars_in_scope[$varId] = $candidateType;
$event->setReturnTypeCandidate($candidateType);

return;
}

$event->setReturnTypeCandidate($candidateType);
}

/**
* @param list<\PhpParser\Node\Arg> $args
*/
private static function getArgument(array $args, string $name, int $position): ?\PhpParser\Node\Arg
{
foreach ($args as $arg) {
if (!$arg->unpack && $arg->name?->name === $name) {
return $arg;
}
}

$arg = $args[$position] ?? null;

return $arg !== null && $arg->name === null && !$arg->unpack ? $arg : null;
}

private static function intersect(Union $type, Union $allowedType, AfterMethodCallAnalysisEvent $event): Union
{
return Type::intersectUnionTypes($type, $allowedType, $event->getCodebase(), false, false) ?? Type::getNever();
}

private static function narrowIterable(Union $type, Union $allowedType, AfterMethodCallAnalysisEvent $event): Union
{
$narrowed = [];

foreach ($type->getAtomicTypes() as $atomicType) {
if ($atomicType instanceof TKeyedArray) {
$properties = $atomicType->properties;
foreach ($atomicType->properties as $key => $propertyType) {
$properties[$key] = self::intersect($propertyType, $allowedType, $event)
->setPossiblyUndefined($propertyType->possibly_undefined, $propertyType->possibly_undefined_from_try);
}
$fallbackParams = $atomicType->fallback_params;
if ($fallbackParams !== null) {
$fallbackParams[1] = self::intersect($fallbackParams[1], $allowedType, $event);
}
$narrowed[] = new TKeyedArray(
$properties,
$atomicType->class_strings,
$fallbackParams,
$atomicType->is_list,
$atomicType->from_docblock,
);
} elseif ($atomicType instanceof TNonEmptyArray) {
$narrowed[] = new TNonEmptyArray(
[
$atomicType->type_params[0],
self::intersect($atomicType->type_params[1], $allowedType, $event),
],
$atomicType->count,
$atomicType->min_count,
$atomicType->value,
$atomicType->from_docblock,
);
} elseif ($atomicType instanceof TArray) {
$narrowed[] = new TArray(
[
$atomicType->type_params[0],
self::intersect($atomicType->type_params[1], $allowedType, $event),
],
$atomicType->from_docblock,
);
} elseif ($atomicType instanceof TIterable) {
$narrowed[] = new TIterable([
$atomicType->type_params[0],
self::intersect($atomicType->type_params[1], $allowedType, $event),
], $atomicType->extra_types);
} elseif ($atomicType instanceof TMixed) {
$narrowed[] = new TIterable([Type::getMixed(), $allowedType]);
} elseif ($atomicType->isIterable($event->getCodebase())) {
$iterableType = $atomicType->getIterable($event->getCodebase());
$narrowed[] = new TIterable([
$iterableType->type_params[0],
self::intersect($iterableType->type_params[1], $allowedType, $event),
], [$atomicType]);
} else {
continue;
}
}

return $narrowed === [] ? Type::getNever() : new Union($narrowed);
}
}
Loading