Skip to content

Commit

Permalink
Merge pull request #249 from m0003r/class-string-support
Browse files Browse the repository at this point in the history
Add support for class-string (and templates)
  • Loading branch information
Lctrs authored Apr 28, 2021
2 parents 149d97d + b8ce39d commit f50b17f
Show file tree
Hide file tree
Showing 3 changed files with 326 additions and 1 deletion.
48 changes: 47 additions & 1 deletion src/Checker/PsrContainerChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use Psalm\Codebase;
use Psalm\Context;
use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface;
use Psalm\StatementsSource;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TTemplateParamClass;
use Psalm\Type\Union;
use Psr\Container\ContainerInterface;

use function explode;
use function is_string;

/**
* @internal
Expand Down Expand Up @@ -54,7 +59,26 @@ public static function afterMethodCallAnalysis(
}

$arg = $expr->args[0] ?? null;
if ($arg === null || ! $arg->value instanceof ClassConstFetch) {
if ($arg === null) {
return;
}

if (! $arg->value instanceof ClassConstFetch) {
if (! $arg->value instanceof Variable || ! is_string($arg->value->name)) {
return;
}

// phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$variableType = $context->vars_in_scope['$' . $arg->value->name] ?? null;
if (! $variableType instanceof Union) {
return;
}

$candidate = self::handleVariable($variableType);
if (! $candidate->isMixed()) {
$return_type_candidate = $candidate;
}

return;
}

Expand All @@ -69,4 +93,26 @@ public static function afterMethodCallAnalysis(
),
]);
}

// phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
private static function handleVariable(Union $variableType): Union
{
/** @var list<Atomic> $types */
$types = [];
foreach ($variableType->getAtomicTypes() as $type) {
if ($type instanceof TTemplateParamClass) {
$types[] = new Atomic\TTemplateParam(
$type->param_name,
new Union([$type->as_type ?? new TNamedObject($type->as)]),
$type->defining_class
);
} elseif ($type instanceof TClassString && $type->as_type !== null) {
$types[] = $type->as_type;
} else {
$types[] = new Atomic\TMixed();
}
}

return new Union($types);
}
}
28 changes: 28 additions & 0 deletions test/Integration/acceptance/PsrContainer.feature
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ Feature: PsrContainer
{
$this->container->get(SomeService::class)->do();
}
/**
* @param class-string<SomeService> $class
*/
public function classString(string $class): SomeService {
return $this->container->get($class);
}
/**
* @template T
* @param class-string<T> $class
* @return T
*/
public function templated(string $class) {
return $this->container->get($class);
}
/**
* @template T of SomeService
* @param T|class-string<T> $class
* @return SomeService
*/
public function templatedOrStraightforward($class) {
if ($class instanceof SomeService) {
return $class;
}
return $this->container->get($class);
}
}
"""
When I run psalm
Expand Down
251 changes: 251 additions & 0 deletions test/Unit/Checker/PsrContainerCheckerClassStringTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<?php

declare(strict_types=1);

namespace Lctrs\PsalmPsrContainerPlugin\Test\Unit\Checker;

use Lctrs\PsalmPsrContainerPlugin\Checker\PsrContainerChecker;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psalm\Codebase;
use Psalm\Context;
use Psalm\StatementsSource;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TObject;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Atomic\TTemplateParamClass;
use Psalm\Type\Union;
use Psr\Container\ContainerInterface;

class PsrContainerCheckerClassStringTest extends TestCase
{
use ProphecyTrait;

private const METHOD_ID = 'Psr\Container\ContainerInterface::get';
private const VARIABLE_NAME = 'param';

public function testItDoesNothingWithEmptyContext(): void
{
$fileReplacements = [];
$returnTypeCandidate = $baseReturnType = new Union([new TMixed()]);

PsrContainerChecker::afterMethodCallAnalysis(
$this->getMethodCall(),
self::METHOD_ID,
self::METHOD_ID,
self::METHOD_ID,
$this->createStub(Context::class),
$this->createStub(StatementsSource::class),
$this->createStub(Codebase::class),
$fileReplacements,
$returnTypeCandidate
);

self::assertSame($baseReturnType, $returnTypeCandidate);
}

/**
* @dataProvider pairsProvider
*/
public function testItSetsTheReturnTypeAsAUnionWithFetchedClass(Union $variableType, Union $expectedType): void
{
$fileReplacements = [];
$returnTypeCandidate = new Union([new TMixed()]);

PsrContainerChecker::afterMethodCallAnalysis(
$this->getMethodCall(),
self::METHOD_ID,
self::METHOD_ID,
self::METHOD_ID,
$this->createContext($variableType),
$this->createStub(StatementsSource::class),
$this->createStub(Codebase::class),
$fileReplacements,
$returnTypeCandidate
);

self::assertNotNull($returnTypeCandidate);
self::assertTrue($expectedType->equals($returnTypeCandidate));

if (! $expectedType->hasTemplate()) {
return;
}

// we should also check for template match
self::assertEquals($expectedType->getId(), $returnTypeCandidate->getId());
}

/**
* @dataProvider pairsProvider
*/
public function testItSetsTheReturnTypeAsAUnionWithFetchedClassWithContainerImplementingContainerInterface(
Union $variableType,
Union $expectedType
): void {
$fileReplacements = [];
$returnTypeCandidate = new Union([new TMixed()]);

$codebase = $this->prophesize(Codebase::class);
$codebase->classImplements(MyOtherContainer::class, ContainerInterface::class)
->willReturn(true)
->shouldBeCalledOnce();

PsrContainerChecker::afterMethodCallAnalysis(
$this->getMethodCall(),
MyOtherContainer::class . '::get',
MyOtherContainer::class . '::get',
MyOtherContainer::class . '::get',
$this->createContext($variableType),
$this->createStub(StatementsSource::class),
$codebase->reveal(),
$fileReplacements,
$returnTypeCandidate
);

self::assertNotNull($returnTypeCandidate);
self::assertTrue($expectedType->equals($returnTypeCandidate));

if (! $expectedType->hasTemplate()) {
return;
}

// we should also check for template match
self::assertEquals($expectedType->getId(), $returnTypeCandidate->getId());
}

/**
* @return array<string, array{0: Union, 1: Union}>
*/
public function pairsProvider(): array
{
return [
'mixed variable' => [
new Union([new TMixed()]),
new Union([new TMixed()]),
],
'class string' => [
new Union([
new TClassString('object', new TNamedObject('Abracadabra')),
]),
new Union([new TNamedObject('Abracadabra')]),
],
'class string and mixed' => [
new Union([
new TClassString('object', new TNamedObject('Abracadabra')),
new TMixed(),
]),
new Union([
new TNamedObject('Abracadabra'),
new TMixed(),
]),
],
'templated class string without as_type' => [
new Union([
new TTemplateParamClass(
'T',
'object',
null,
'definingclass'
),
]),
new Union([
new TTemplateParam(
'T',
new Union([new TObject()]),
'definingclass'
),
]),
],
'templated class string with as_type' => [
new Union([
new TTemplateParamClass(
'T',
'Abracadabra',
new TNamedObject('Abracadabra'),
'definingclass'
),
]),
new Union([
new TTemplateParam(
'T',
new Union([new TNamedObject('Abracadabra')]),
'definingclass'
),
]),
],
'union of class string or templated class string' => [
new Union([
new TTemplateParamClass(
'T',
'object',
null,
'definingclass'
),
new TClassString('object', new TNamedObject('Abracadabra')),
]),
new Union([
new TTemplateParam(
'T',
new Union([new TObject()]),
'definingclass'
),
new TNamedObject('Abracadabra'),
]),
],
];
}

/**
* @return Stub&Context
*/
protected function createContext(Union $variableType)
{
$stub = $this->createStub(Context::class);

// phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$stub->vars_in_scope['$' . self::VARIABLE_NAME] = $variableType;

return $stub;
}

protected function getMethodCall(): MethodCall
{
return new MethodCall(
new Variable('dummy'),
'get',
[
new Arg(
new Variable(self::VARIABLE_NAME)
),
]
);
}
}


// phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses,Squiz.Classes.ClassFileName.NoMatch

final class MyOtherContainer implements ContainerInterface
{
/**
* @inheritDoc
*/
public function get($id)
{
return 'dummy';
}

/**
* @inheritDoc
*/
public function has($id)
{
return true;
}
}

0 comments on commit f50b17f

Please sign in to comment.