Skip to content

Commit aa2e842

Browse files
authored
Merge pull request #36 from CakeDC/feature/ExpressionTypeResolverExtension
Feature/expression type resolver extension
2 parents 2fa5cff + 62dfb64 commit aa2e842

File tree

14 files changed

+437
-18
lines changed

14 files changed

+437
-18
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
],
1313
"require": {
1414
"php": ">=8.1.0",
15-
"phpstan/phpstan": "^1.10",
15+
"phpstan/phpstan": "^1.12",
1616
"cakephp/cakephp": "^5.0"
1717
},
1818
"require-dev": {

extension.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,11 @@ services:
6262
class: CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension
6363
tags:
6464
- phpstan.phpDoc.typeNodeResolverExtension
65+
-
66+
factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\Mailer\MailerAwareTrait, getMailer, %s\Mailer\%sMailer)
67+
tags:
68+
- phpstan.broker.expressionTypeResolverExtension
69+
-
70+
factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable)
71+
tags:
72+
- phpstan.broker.expressionTypeResolverExtension

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ rules:
66
parameters:
77
level: max
88
checkGenericClassInNonGenericObjectType: false
9+
treatPhpDocTypesAsCertain: false
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace CakeDC\PHPStan\Constraint;
5+
6+
use PHPUnit\Framework\Constraint\Constraint;
7+
8+
class ArrayOfStringStartsWith extends Constraint
9+
{
10+
/**
11+
* @var array<string>
12+
*/
13+
private readonly array $actual;
14+
/**
15+
* @var array<array{expected: string, type: string, actual: string|null}>
16+
*/
17+
private array $result = [];
18+
/**
19+
* @var array<string>
20+
*/
21+
private array $notExpected = [];
22+
23+
/**
24+
* @param array<string> $actual
25+
*/
26+
public function __construct(array $actual)
27+
{
28+
$this->actual = $actual;
29+
}
30+
31+
/**
32+
* @return string
33+
*/
34+
public function toString(): string
35+
{
36+
return 'a list of errors';
37+
}
38+
39+
/**
40+
* @param mixed $other
41+
* @return bool
42+
*/
43+
protected function matches(mixed $other): bool
44+
{
45+
$result = true;
46+
$this->notExpected = $this->actual;
47+
assert(is_array($other));
48+
foreach ($other as $key => $error) {
49+
if (!isset($this->actual[$key])) {
50+
$this->result[$key] = ['expected' => $error, 'type' => 'missing', 'actual' => null];
51+
$result = false;
52+
continue;
53+
}
54+
unset($this->notExpected[$key]);
55+
if (!str_starts_with($this->actual[$key], $error)) {
56+
$this->result[$key] = ['expected' => $error, 'type' => 'not-equal', 'actual' => $this->actual[$key]];
57+
$result = false;
58+
}
59+
}
60+
61+
return $result && empty($this->notExpected);
62+
}
63+
64+
/**
65+
* @param mixed $other
66+
* @return string
67+
*/
68+
protected function failureDescription(mixed $other): string
69+
{
70+
$text = "\n";
71+
foreach ($this->result as $item) {
72+
if ($item['type'] === 'not-equal') {
73+
$text .= sprintf(" -%s \n +%s \n", $item['expected'], $item['actual']);
74+
}
75+
if ($item['type'] === 'missing') {
76+
$text .= sprintf(" -%s \n", $item['expected']);
77+
}
78+
}
79+
80+
foreach ($this->notExpected as $item) {
81+
$text .= sprintf(" \n +%s", $item);
82+
}
83+
84+
return $text;
85+
}
86+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace CakeDC\PHPStan\Rule\Traits;
5+
6+
use CakeDC\PHPStan\Constraint\ArrayOfStringStartsWith;
7+
use PHPStan\Analyser\Error;
8+
9+
/**
10+
* @mixin \PHPStan\Testing\RuleTestCase;
11+
*/
12+
trait AnalyseCheckLineStartsWithTrait
13+
{
14+
/**
15+
* @param array $files
16+
* @param array $expected
17+
* @return void
18+
*/
19+
public function analyseCheckLineStartsWith(array $files, array $expected): void
20+
{
21+
$actualErrors = $this->gatherAnalyserErrors($files);
22+
$messageText = static function (int $line, string $message): string {
23+
return sprintf('%02d: %s', $line, $message);
24+
};
25+
$actualErrors = array_map(static function (Error $error) use ($messageText): string {
26+
$line = $error->getLine();
27+
if ($line === null) {
28+
return $messageText(-1, $error->getMessage());
29+
}
30+
31+
return $messageText($line, $error->getMessage());
32+
}, $actualErrors);
33+
34+
$expected = array_map(static function (array $item) use ($messageText): string {
35+
return $messageText($item[1], $item[0]);
36+
}, $expected);
37+
$this->assertThat($expected, new ArrayOfStringStartsWith($actualErrors));
38+
}
39+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2024, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2024, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Type;
15+
16+
use CakeDC\PHPStan\Utility\CakeNameRegistry;
17+
use PhpParser\Node\Expr;
18+
use PhpParser\Node\Identifier;
19+
use PhpParser\Node\Scalar\String_;
20+
use PHPStan\Analyser\Scope;
21+
use PHPStan\Reflection\ClassReflection;
22+
use PHPStan\Type\ExpressionTypeResolverExtension;
23+
use PHPStan\Type\ObjectType;
24+
use PHPStan\Type\ThisType;
25+
use PHPStan\Type\Type;
26+
use ReflectionException;
27+
28+
class BaseTraitExpressionTypeResolverExtension implements ExpressionTypeResolverExtension
29+
{
30+
/**
31+
* TableLocatorDynamicReturnTypeExtension constructor.
32+
*
33+
* @param string $targetTrait The target trait.
34+
* @param string $methodName The dynamic method to handle.
35+
* @param string $namespaceFormat The resolve namespace format.
36+
* @param string|null $propertyDefaultValue A property name for default classname, used when no args in method call.
37+
*/
38+
public function __construct(
39+
protected string $targetTrait,
40+
protected string $methodName,
41+
protected string $namespaceFormat,
42+
protected ?string $propertyDefaultValue = null
43+
) {
44+
}
45+
46+
/**
47+
* @param \PhpParser\Node\Expr $expr
48+
* @param \PHPStan\Analyser\Scope $scope
49+
* @return \PHPStan\Type\Type|null
50+
*/
51+
public function getType(Expr $expr, Scope $scope): ?Type
52+
{
53+
if (
54+
!$expr instanceof Expr\MethodCall
55+
|| !$expr->name instanceof Identifier
56+
|| $expr->name->toString() !== $this->methodName
57+
) {
58+
return null;
59+
}
60+
61+
$callerType = $scope->getType($expr->var);
62+
if (!$callerType instanceof ThisType && !$callerType instanceof ObjectType) {
63+
return null;
64+
}
65+
$reflection = $callerType->getClassReflection();
66+
if ($reflection === null || !$this->isFromTargetTrait($reflection)) {
67+
return null;
68+
}
69+
70+
$value = $expr->getArgs()[0]->value ?? null;
71+
$baseName = $this->getBaseName($value, $reflection);
72+
if ($baseName === null) {
73+
return null;
74+
}
75+
$className = CakeNameRegistry::getClassName($baseName, $this->namespaceFormat);
76+
if ($className !== null) {
77+
return new ObjectType($className);
78+
}
79+
80+
return null;
81+
}
82+
83+
/**
84+
* @param \PHPStan\Reflection\ClassReflection $reflection
85+
* @return bool
86+
*/
87+
protected function isFromTargetTrait(ClassReflection $reflection): bool
88+
{
89+
foreach ($reflection->getTraits() as $trait) {
90+
if ($trait->getName() === $this->targetTrait) {
91+
return true;
92+
}
93+
}
94+
foreach ($reflection->getParents() as $parent) {
95+
if ($this->isFromTargetTrait($parent)) {
96+
return true;
97+
}
98+
}
99+
100+
return false;
101+
}
102+
103+
/**
104+
* @param \PhpParser\Node\Expr|null $value
105+
* @param \PHPStan\Reflection\ClassReflection $reflection
106+
* @return string|null
107+
*/
108+
protected function getBaseName(?Expr $value, ClassReflection $reflection): ?string
109+
{
110+
if ($value instanceof String_) {
111+
return $value->value;
112+
}
113+
114+
try {
115+
if ($value === null && $this->propertyDefaultValue) {
116+
$value = $reflection->getNativeReflection()
117+
->getProperty($this->propertyDefaultValue)
118+
->getDefaultValue();
119+
120+
return is_string($value) ? $value : null;
121+
}
122+
} catch (ReflectionException) {
123+
return null;
124+
}
125+
126+
return null;
127+
}
128+
}

tests/TestCase/Rule/Model/AddAssociationMatchOptionsTypesRuleTest.php

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33

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

6-
use Cake\Core\Configure;
76
use CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule;
7+
use CakeDC\PHPStan\Rule\Traits\AnalyseCheckLineStartsWithTrait;
88
use PHPStan\Rules\Properties\PropertyReflectionFinder;
99
use PHPStan\Rules\Rule;
1010
use PHPStan\Rules\RuleLevelHelper;
1111
use PHPStan\Testing\RuleTestCase;
1212

1313
class AddAssociationMatchOptionsTypesRuleTest extends RuleTestCase
1414
{
15+
use AnalyseCheckLineStartsWithTrait;
16+
1517
/**
1618
* @return \PHPStan\Rules\Rule
1719
*/
@@ -38,14 +40,7 @@ protected function getRule(): Rule
3840
*/
3941
public function testRule(): void
4042
{
41-
$messageThrough = 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through" (Cake\ORM\Table|string|null) does not accept stdClass.';
42-
if (version_compare(Configure::version(), '5.0.5', '<')) {
43-
$messageThrough = 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through" (Cake\ORM\Table|string) does not accept stdClass.';
44-
}
45-
// first argument: path to the example file that contains some errors that should be reported by MyRule
46-
// second argument: an array of expected errors,
47-
// each error consists of the asserted error message, and the asserted error file line
48-
$this->analyse([__DIR__ . '/Fake/FailingRuleItemsTable.php'], [
43+
$this->analyseCheckLineStartsWith([__DIR__ . '/Fake/FailingRuleItemsTable.php'], [
4944
[
5045
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "className" (string) does not accept false.',
5146
66,
@@ -67,12 +62,14 @@ public function testRule(): void
6762
66,
6863
],
6964
[
70-
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "bindingKey" (array<string>|string) does not accept int.',
65+
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "bindingKey" ',
7166
66,
67+
'Type #1 from the union: 10 is not a list.',
7268
],
7369
[
74-
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "foreignKey" (array<string>|string|false) does not accept 11.',
70+
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "foreignKey" ',
7571
66,
72+
'Type #1 from the union: 11 is not a list.',
7673
],
7774
[
7875
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "joinType" (string) does not accept int.',
@@ -107,11 +104,12 @@ public function testRule(): void
107104
85,
108105
],
109106
[
110-
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "targetForeignKey" (array<string>|string|null) does not accept Closure.',
107+
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "targetForeignKey" ',
111108
98,
109+
'Type #1 from the union: Closure(): 10 is not a list.',
112110
],
113111
[
114-
$messageThrough,
112+
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through"',
115113
98,
116114
],
117115
[
@@ -147,12 +145,14 @@ public function testRule(): void
147145
120,
148146
],
149147
[
150-
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "bindingKey" (array<string>|string) does not accept int.',
148+
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "bindingKey" ',
151149
120,
150+
'Type #1 from the union: 10 is not a list.',
152151
],
153152
[
154-
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "foreignKey" (array<string>|string|false) does not accept 11.',
153+
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "foreignKey" ',
155154
120,
155+
'Type #1 from the union: 11 is not a list.',
156156
],
157157
[
158158
'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "joinType" (string) does not accept int.',
@@ -215,12 +215,14 @@ public function testRule(): void
215215
148,
216216
],
217217
[
218-
'Call to Cake\ORM\AssociationCollection::load with option "bindingKey" (array<string>|string) does not accept int.',
218+
'Call to Cake\ORM\AssociationCollection::load with option "bindingKey" ',
219219
148,
220+
'Type #1 from the union: 10 is not a list.',
220221
],
221222
[
222-
'Call to Cake\ORM\AssociationCollection::load with option "foreignKey" (array<string>|string|false) does not accept 11.',
223+
'Call to Cake\ORM\AssociationCollection::load with option "foreignKey" ',
223224
148,
225+
'Type #1 from the union: 11 is not a list.',
224226
],
225227
[
226228
'Call to Cake\ORM\AssociationCollection::load with option "joinType" (string) does not accept int.',

0 commit comments

Comments
 (0)