Skip to content

Commit

Permalink
Merge pull request #36 from CakeDC/feature/ExpressionTypeResolverExte…
Browse files Browse the repository at this point in the history
…nsion

Feature/expression type resolver extension
  • Loading branch information
rochamarcelo authored Oct 15, 2024
2 parents 2fa5cff + 62dfb64 commit aa2e842
Show file tree
Hide file tree
Showing 14 changed files with 437 additions and 18 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"require": {
"php": ">=8.1.0",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan": "^1.12",
"cakephp/cakephp": "^5.0"
},
"require-dev": {
Expand Down
8 changes: 8 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,11 @@ services:
class: CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension
tags:
- phpstan.phpDoc.typeNodeResolverExtension
-
factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\Mailer\MailerAwareTrait, getMailer, %s\Mailer\%sMailer)
tags:
- phpstan.broker.expressionTypeResolverExtension
-
factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable)
tags:
- phpstan.broker.expressionTypeResolverExtension
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ rules:
parameters:
level: max
checkGenericClassInNonGenericObjectType: false
treatPhpDocTypesAsCertain: false
86 changes: 86 additions & 0 deletions src/Constraint/ArrayOfStringStartsWith.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Constraint;

use PHPUnit\Framework\Constraint\Constraint;

class ArrayOfStringStartsWith extends Constraint
{
/**
* @var array<string>
*/
private readonly array $actual;
/**
* @var array<array{expected: string, type: string, actual: string|null}>
*/
private array $result = [];
/**
* @var array<string>
*/
private array $notExpected = [];

/**
* @param array<string> $actual
*/
public function __construct(array $actual)
{
$this->actual = $actual;
}

/**
* @return string
*/
public function toString(): string
{
return 'a list of errors';
}

/**
* @param mixed $other
* @return bool
*/
protected function matches(mixed $other): bool
{
$result = true;
$this->notExpected = $this->actual;
assert(is_array($other));
foreach ($other as $key => $error) {
if (!isset($this->actual[$key])) {
$this->result[$key] = ['expected' => $error, 'type' => 'missing', 'actual' => null];
$result = false;
continue;
}
unset($this->notExpected[$key]);
if (!str_starts_with($this->actual[$key], $error)) {
$this->result[$key] = ['expected' => $error, 'type' => 'not-equal', 'actual' => $this->actual[$key]];
$result = false;
}
}

return $result && empty($this->notExpected);
}

/**
* @param mixed $other
* @return string
*/
protected function failureDescription(mixed $other): string
{
$text = "\n";
foreach ($this->result as $item) {
if ($item['type'] === 'not-equal') {
$text .= sprintf(" -%s \n +%s \n", $item['expected'], $item['actual']);
}
if ($item['type'] === 'missing') {
$text .= sprintf(" -%s \n", $item['expected']);
}
}

foreach ($this->notExpected as $item) {
$text .= sprintf(" \n +%s", $item);
}

return $text;
}
}
39 changes: 39 additions & 0 deletions src/Rule/Traits/AnalyseCheckLineStartsWithTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Rule\Traits;

use CakeDC\PHPStan\Constraint\ArrayOfStringStartsWith;
use PHPStan\Analyser\Error;

/**
* @mixin \PHPStan\Testing\RuleTestCase;
*/
trait AnalyseCheckLineStartsWithTrait
{
/**
* @param array $files
* @param array $expected
* @return void
*/
public function analyseCheckLineStartsWith(array $files, array $expected): void
{
$actualErrors = $this->gatherAnalyserErrors($files);
$messageText = static function (int $line, string $message): string {
return sprintf('%02d: %s', $line, $message);
};
$actualErrors = array_map(static function (Error $error) use ($messageText): string {
$line = $error->getLine();
if ($line === null) {
return $messageText(-1, $error->getMessage());
}

return $messageText($line, $error->getMessage());
}, $actualErrors);

$expected = array_map(static function (array $item) use ($messageText): string {
return $messageText($item[1], $item[0]);
}, $expected);
$this->assertThat($expected, new ArrayOfStringStartsWith($actualErrors));
}
}
128 changes: 128 additions & 0 deletions src/Type/BaseTraitExpressionTypeResolverExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);

/**
* Copyright 2024, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2024, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Type;

use CakeDC\PHPStan\Utility\CakeNameRegistry;
use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Type\ExpressionTypeResolverExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\ThisType;
use PHPStan\Type\Type;
use ReflectionException;

class BaseTraitExpressionTypeResolverExtension implements ExpressionTypeResolverExtension
{
/**
* TableLocatorDynamicReturnTypeExtension constructor.
*
* @param string $targetTrait The target trait.
* @param string $methodName The dynamic method to handle.
* @param string $namespaceFormat The resolve namespace format.
* @param string|null $propertyDefaultValue A property name for default classname, used when no args in method call.
*/
public function __construct(
protected string $targetTrait,
protected string $methodName,
protected string $namespaceFormat,
protected ?string $propertyDefaultValue = null
) {
}

/**
* @param \PhpParser\Node\Expr $expr
* @param \PHPStan\Analyser\Scope $scope
* @return \PHPStan\Type\Type|null
*/
public function getType(Expr $expr, Scope $scope): ?Type
{
if (
!$expr instanceof Expr\MethodCall
|| !$expr->name instanceof Identifier
|| $expr->name->toString() !== $this->methodName
) {
return null;
}

$callerType = $scope->getType($expr->var);
if (!$callerType instanceof ThisType && !$callerType instanceof ObjectType) {
return null;
}
$reflection = $callerType->getClassReflection();
if ($reflection === null || !$this->isFromTargetTrait($reflection)) {
return null;
}

$value = $expr->getArgs()[0]->value ?? null;
$baseName = $this->getBaseName($value, $reflection);
if ($baseName === null) {
return null;
}
$className = CakeNameRegistry::getClassName($baseName, $this->namespaceFormat);
if ($className !== null) {
return new ObjectType($className);
}

return null;
}

/**
* @param \PHPStan\Reflection\ClassReflection $reflection
* @return bool
*/
protected function isFromTargetTrait(ClassReflection $reflection): bool
{
foreach ($reflection->getTraits() as $trait) {
if ($trait->getName() === $this->targetTrait) {
return true;
}
}
foreach ($reflection->getParents() as $parent) {
if ($this->isFromTargetTrait($parent)) {
return true;
}
}

return false;
}

/**
* @param \PhpParser\Node\Expr|null $value
* @param \PHPStan\Reflection\ClassReflection $reflection
* @return string|null
*/
protected function getBaseName(?Expr $value, ClassReflection $reflection): ?string
{
if ($value instanceof String_) {
return $value->value;
}

try {
if ($value === null && $this->propertyDefaultValue) {
$value = $reflection->getNativeReflection()
->getProperty($this->propertyDefaultValue)
->getDefaultValue();

return is_string($value) ? $value : null;
}
} catch (ReflectionException) {
return null;
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@

namespace CakeDC\PHPStan\Test\TestCase\Rule\Model;

use Cake\Core\Configure;
use CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule;
use CakeDC\PHPStan\Rule\Traits\AnalyseCheckLineStartsWithTrait;
use PHPStan\Rules\Properties\PropertyReflectionFinder;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Testing\RuleTestCase;

class AddAssociationMatchOptionsTypesRuleTest extends RuleTestCase
{
use AnalyseCheckLineStartsWithTrait;

/**
* @return \PHPStan\Rules\Rule
*/
Expand All @@ -38,14 +40,7 @@ protected function getRule(): Rule
*/
public function testRule(): void
{
$messageThrough = 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through" (Cake\ORM\Table|string|null) does not accept stdClass.';
if (version_compare(Configure::version(), '5.0.5', '<')) {
$messageThrough = 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through" (Cake\ORM\Table|string) does not accept stdClass.';
}
// first argument: path to the example file that contains some errors that should be reported by MyRule
// second argument: an array of expected errors,
// each error consists of the asserted error message, and the asserted error file line
$this->analyse([__DIR__ . '/Fake/FailingRuleItemsTable.php'], [
$this->analyseCheckLineStartsWith([__DIR__ . '/Fake/FailingRuleItemsTable.php'], [
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "className" (string) does not accept false.',
66,
Expand All @@ -67,12 +62,14 @@ public function testRule(): void
66,
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "bindingKey" (array<string>|string) does not accept int.',
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "bindingKey" ',
66,
'Type #1 from the union: 10 is not a list.',
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "foreignKey" (array<string>|string|false) does not accept 11.',
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "foreignKey" ',
66,
'Type #1 from the union: 11 is not a list.',
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "joinType" (string) does not accept int.',
Expand Down Expand Up @@ -107,11 +104,12 @@ public function testRule(): void
85,
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "targetForeignKey" (array<string>|string|null) does not accept Closure.',
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "targetForeignKey" ',
98,
'Type #1 from the union: Closure(): 10 is not a list.',
],
[
$messageThrough,
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through"',
98,
],
[
Expand Down Expand Up @@ -147,12 +145,14 @@ public function testRule(): void
120,
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "bindingKey" (array<string>|string) does not accept int.',
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "bindingKey" ',
120,
'Type #1 from the union: 10 is not a list.',
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "foreignKey" (array<string>|string|false) does not accept 11.',
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "foreignKey" ',
120,
'Type #1 from the union: 11 is not a list.',
],
[
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "joinType" (string) does not accept int.',
Expand Down Expand Up @@ -215,12 +215,14 @@ public function testRule(): void
148,
],
[
'Call to Cake\ORM\AssociationCollection::load with option "bindingKey" (array<string>|string) does not accept int.',
'Call to Cake\ORM\AssociationCollection::load with option "bindingKey" ',
148,
'Type #1 from the union: 10 is not a list.',
],
[
'Call to Cake\ORM\AssociationCollection::load with option "foreignKey" (array<string>|string|false) does not accept 11.',
'Call to Cake\ORM\AssociationCollection::load with option "foreignKey" ',
148,
'Type #1 from the union: 11 is not a list.',
],
[
'Call to Cake\ORM\AssociationCollection::load with option "joinType" (string) does not accept int.',
Expand Down
Loading

0 comments on commit aa2e842

Please sign in to comment.