Skip to content
Merged
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
114 changes: 114 additions & 0 deletions src/Psalm/Compose/AdjacentTemplateValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\Psalm\Compose;

use Psalm\CodeLocation;
use Psalm\Internal\Type\Comparator\UnionTypeComparator;
use Psalm\Issue\InvalidArgument;
use Psalm\IssueBuffer;
use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent;
use Psalm\Type\Atomic\TGenericObject;
use Psalm\Type\Union;

use function count;
use function strtolower;

/**
* @internal
*
* Since Iso/Lens templates are covariant, Psalm's template-inference based
* argument check will silently widen the shared boundary type between
* adjacent compose() args (e.g. accepting Iso<A,B>, Iso<C,C>, Iso<C,D>).
*
* This validator walks the adjacent arg pairs and emits InvalidArgument
* when the right template of arg[i] is not equivalent to the left template
* of arg[i+1].
*/
final class AdjacentTemplateValidator
{
/**
* @param class-string $genericClass Iso::class or Lens::class
* @param non-empty-string $functionId
*/
public static function validate(
FunctionReturnTypeProviderEvent $event,
string $genericClass,
string $functionId,
): void {
$args = $event->getCallArgs();
if (count($args) < 2) {
return;
}

$source = $event->getStatementsSource();
$nodeTypes = $source->getNodeTypeProvider();
$codebase = $source->getCodebase();
$suppressed = $source->getSuppressedIssues();

$genericClassLc = strtolower($genericClass);

$previousRight = null;
$previousIndex = 0;
foreach ($args as $index => $arg) {
$argType = $nodeTypes->getType($arg->value);
if ($argType === null) {
$previousRight = null;
continue;
}

$generic = self::extractGeneric($argType, $genericClassLc);
if ($generic === null) {
$previousRight = null;
continue;
}

[$left, $right] = $generic;

if ($previousRight !== null) {
$forward = UnionTypeComparator::isContainedBy($codebase, $previousRight, $left);
$backward = UnionTypeComparator::isContainedBy($codebase, $left, $previousRight);

if (!$forward || !$backward) {
IssueBuffer::maybeAdd(
new InvalidArgument(
'Argument ' . ($index + 1) . ' of ' . $functionId
. ' expects ' . $genericClass . '<' . $previousRight->getId() . ', ...>,'
. ' ' . $genericClass . '<' . $left->getId() . ', ...> provided'
. ' (compose boundary mismatch with argument ' . ($previousIndex + 1) . ')',
new CodeLocation($source, $arg->value),
$functionId,
),
$suppressed,
);
}
}

$previousRight = $right;
$previousIndex = $index;
}
}

/**
* @param lowercase-string $genericClassLc
* @return array{0: Union, 1: Union}|null
*/
private static function extractGeneric(Union $argType, string $genericClassLc): ?array
{
foreach ($argType->getAtomicTypes() as $atomic) {
if (!$atomic instanceof TGenericObject) {
continue;
}
if (strtolower($atomic->value) !== $genericClassLc) {
continue;
}
if (count($atomic->type_params) < 2) {
continue;
}

return [$atomic->type_params[0], $atomic->type_params[1]];
}

return null;
}
}
17 changes: 15 additions & 2 deletions src/Psalm/Iso/Provider/ComposeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,37 @@
use Psalm\Plugin\DynamicTemplateProvider;
use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface;
use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent;
use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface;
use Psalm\Storage\FunctionLikeParameter;
use Psalm\Type\Atomic\TGenericObject;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Union;

use VeeWee\Reflecta\Iso\Iso;
use VeeWee\Reflecta\Psalm\Compose\AdjacentTemplateValidator;
use function array_map;
use function count;
use function range;

final class ComposeProvider implements DynamicFunctionStorageProviderInterface
final class ComposeProvider implements DynamicFunctionStorageProviderInterface, FunctionReturnTypeProviderInterface
{
private const FUNCTION_ID = 'veewee\reflecta\iso\compose';

/**
* @return array<lowercase-string>
*/
public static function getFunctionIds(): array
{
return ['veewee\reflecta\iso\compose'];
return [self::FUNCTION_ID];
}

public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union
{
AdjacentTemplateValidator::validate($event, Iso::class, self::FUNCTION_ID);

// Defer return type to the DynamicFunctionStorage provider.
return null;
}

public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage
Expand Down
17 changes: 15 additions & 2 deletions src/Psalm/Lens/Provider/ComposeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,37 @@
use Psalm\Plugin\DynamicTemplateProvider;
use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface;
use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent;
use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface;
use Psalm\Storage\FunctionLikeParameter;
use Psalm\Type\Atomic\TGenericObject;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Union;

use VeeWee\Reflecta\Lens\Lens;
use VeeWee\Reflecta\Psalm\Compose\AdjacentTemplateValidator;
use function array_map;
use function count;
use function range;

final class ComposeProvider implements DynamicFunctionStorageProviderInterface
final class ComposeProvider implements DynamicFunctionStorageProviderInterface, FunctionReturnTypeProviderInterface
{
private const FUNCTION_ID = 'veewee\reflecta\lens\compose';

/**
* @return array<lowercase-string>
*/
public static function getFunctionIds(): array
{
return ['veewee\reflecta\lens\compose'];
return [self::FUNCTION_ID];
}

public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union
{
AdjacentTemplateValidator::validate($event, Lens::class, self::FUNCTION_ID);

// Defer return type to the DynamicFunctionStorage provider.
return null;
}

public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage
Expand Down
3 changes: 1 addition & 2 deletions tests/static-analyzer/Iso/compose.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ function it_knows_composed_result(Iso $iso1, Iso $iso2, Iso $iso3): Iso
* @param Iso<C,D> $iso3
* @return Iso<A,D>
*
* @ psalm-suppress InvalidArgument -
* TODO : Invalid compose iso-param detection does not work any since contravariant templates are introduced.
* @psalm-suppress InvalidArgument
*/
function it_knows_broken_composition(Iso $iso1, Iso $iso2, Iso $iso3): Iso
{
Expand Down
3 changes: 1 addition & 2 deletions tests/static-analyzer/Lens/compose.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ function it_knows_composed_result(Lens $lens1, Lens $lens2, Lens $lens3): Lens
* @param Lens<C,D> $lens3
* @return Lens<A,D>
*
* @ psalm-suppress InvalidArgument -
* TODO : Invalid compose lens-param detection does not work any since contravariant templates are introduced.
* @psalm-suppress InvalidArgument
*/
function it_knows_broken_composition(Lens $lens1, Lens $lens2, Lens $lens3): Lens
{
Expand Down