Skip to content

Commit b3fbed4

Browse files
committed
PHPStan: Add TypesAssignedByHasMutatorRule and fix issues reported
1 parent 1d5caa5 commit b3fbed4

File tree

11 files changed

+283
-9
lines changed

11 files changed

+283
-9
lines changed

phpstan.neon.dist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ parameters:
2626
paths:
2727
- tests/fixtures/Toolkit/PHPStan/Utility/Rules/GetCoalesceRuleFailures.php
2828
- tests/fixtures/Toolkit/PHPStan/Utility/Type/GetCoalesceReturnTypeExtensionAssertions.php
29+
-
30+
identifier: salient.property.notFound
31+
paths:
32+
- tests/fixtures/Toolkit/PHPStan/Core/Rules/TypesAssignedByHasMutatorRuleFailures.php
33+
-
34+
identifier: salient.property.type
35+
paths:
36+
- tests/fixtures/Toolkit/PHPStan/Core/Rules/TypesAssignedByHasMutatorRuleFailures.php
2937
-
3038
identifier: arguments.count
3139
paths:

src/Toolkit/Console/Support/ConsoleTagFormats.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public function getWrapAfterApply(): bool
7373
*/
7474
public function withFormat($tag, Format $format)
7575
{
76+
// @phpstan-ignore salient.property.type
7677
return $this->with('Formats', Arr::set($this->Formats, $tag, $format));
7778
}
7879

src/Toolkit/Core/Pipeline.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,15 @@ private function withPayload($payload, $arg, bool $stream)
120120
// @codeCoverageIgnoreEnd
121121
}
122122

123-
/** @var static<TInput&T0,TOutput,TArgument&T1> */
124-
return $this
123+
/** @var static<T0,TOutput,T1> */
124+
$pipeline = $this;
125+
$pipeline = $pipeline
125126
->with('HasPayload', true)
126127
->with('HasStream', $stream)
127128
->with('Payload', $payload)
128129
->with('Arg', $arg);
130+
/** @var static<TInput&T0,TOutput,TArgument&T1> */
131+
return $pipeline;
129132
}
130133

131134
/**

src/Toolkit/Curler/Curler.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,7 @@ public function withAccessToken(
825825
*/
826826
public function withSensitiveHeader(string $name)
827827
{
828+
// @phpstan-ignore salient.property.type
828829
return $this->with(
829830
'SensitiveHeaders',
830831
Arr::set($this->SensitiveHeaders, Str::lower($name), true)
@@ -836,6 +837,7 @@ public function withSensitiveHeader(string $name)
836837
*/
837838
public function withoutSensitiveHeader(string $name)
838839
{
840+
// @phpstan-ignore salient.property.type
839841
return $this->with(
840842
'SensitiveHeaders',
841843
Arr::unset($this->SensitiveHeaders, Str::lower($name))
@@ -984,6 +986,7 @@ public function withPostResponseCache(bool $cachePostResponses = true)
984986
*/
985987
public function withCacheKeyCallback(?callable $callback)
986988
{
989+
// @phpstan-ignore salient.property.type
987990
return $this->with('CacheKeyClosure', Get::closure($callback));
988991
}
989992

src/Toolkit/Http/HttpHeaders.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class HttpHeaders implements HttpHeadersInterface
5959
/**
6060
* [ [ Name => value ], ... ]
6161
*
62-
* @var array<non-empty-array<string,string>>
62+
* @var array<int,non-empty-array<string,string>>
6363
*/
6464
protected array $Headers = [];
6565

@@ -861,7 +861,7 @@ protected function filterValue(string $value): string
861861
}
862862

863863
/**
864-
* @param array<non-empty-array<string,string>>|null $headers
864+
* @param array<int,non-empty-array<string,string>>|null $headers
865865
* @param array<string,int[]> $index
866866
* @return static
867867
*/
@@ -881,7 +881,7 @@ protected function maybeReplaceHeaders(?array $headers, array $index, bool $filt
881881
}
882882

883883
/**
884-
* @param array<non-empty-array<string,string>>|null $headers
884+
* @param array<int,non-empty-array<string,string>>|null $headers
885885
* @param array<string,int[]> $index
886886
* @return static
887887
*/
@@ -919,7 +919,7 @@ protected function doGetHeaders(bool $preserveCase = false): array
919919

920920
/**
921921
* @param array<string,int[]> $index
922-
* @return array<non-empty-array<string,string>>
922+
* @return array<int,non-empty-array<string,string>>
923923
*/
924924
protected function getIndexHeaders(array $index): array
925925
{
@@ -932,8 +932,8 @@ protected function getIndexHeaders(array $index): array
932932
}
933933

934934
/**
935-
* @param array<non-empty-array<string,string>> $headers
936-
* @return array<non-empty-array<string,string>>
935+
* @param array<int,non-empty-array<string,string>> $headers
936+
* @return array<int,non-empty-array<string,string>>
937937
*/
938938
private function filterHeaders(array $headers): array
939939
{

src/Toolkit/Http/HttpServerRequest.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ public static function fromPsr7(MessageInterface $message): HttpServerRequest
6969
throw new InvalidArgumentTypeException(1, 'message', ServerRequestInterface::class, $message);
7070
}
7171

72+
/** @var array<string,mixed> */
73+
$attributes = $message->getAttributes();
74+
7275
return (new self(
7376
$message->getMethod(),
7477
$message->getUri(),
@@ -82,7 +85,7 @@ public static function fromPsr7(MessageInterface $message): HttpServerRequest
8285
->withQueryParams($message->getQueryParams())
8386
->withUploadedFiles($message->getUploadedFiles())
8487
->withParsedBody($message->getParsedBody())
85-
->with('Attributes', $message->getAttributes());
88+
->with('Attributes', $attributes);
8689
}
8790

8891
/**
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Salient\PHPStan\Core\Rules;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PhpParser\Node\Identifier;
7+
use PhpParser\Node;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\Php\PhpMethodReflection;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use PHPStan\Type\NeverType;
13+
use PHPStan\Type\Type;
14+
use PHPStan\Type\VerbosityLevel;
15+
use Salient\Core\Concern\HasMutator;
16+
17+
/**
18+
* @implements Rule<MethodCall>
19+
*/
20+
class TypesAssignedByHasMutatorRule implements Rule
21+
{
22+
public function getNodeType(): string
23+
{
24+
return MethodCall::class;
25+
}
26+
27+
public function processNode(Node $node, Scope $scope): array
28+
{
29+
/** @var MethodCall $node */
30+
$methodName = $node->name;
31+
if (!$methodName instanceof Identifier) {
32+
return [];
33+
}
34+
$calledOnType = $scope->getType($node->var);
35+
$methodReflection = $scope->getMethodReflection($calledOnType, $methodName->toString());
36+
if (!$methodReflection) {
37+
return [];
38+
}
39+
$prototypeReflection = $methodReflection->getPrototype();
40+
if (!$prototypeReflection instanceof PhpMethodReflection) {
41+
return [];
42+
}
43+
$traitReflection = $prototypeReflection->getDeclaringTrait();
44+
if (!$traitReflection || $traitReflection->getName() !== HasMutator::class) {
45+
return [];
46+
}
47+
$classReflection = $prototypeReflection->getDeclaringClass();
48+
$aliases = array_change_key_case($classReflection->getNativeReflection()->getTraitAliases());
49+
$name = $methodName->toLowerString();
50+
if (isset($aliases[$name])) {
51+
$name = explode('::', $aliases[$name])[1];
52+
}
53+
if ($name !== 'with' && $name !== 'without') {
54+
return [];
55+
}
56+
$args = $node->getArgs();
57+
if (!$args) {
58+
return [];
59+
}
60+
$propertyName = $scope->getType($args[0]->value);
61+
if (
62+
!$propertyName->isConstantScalarValue()->yes()
63+
|| !$propertyName->isString()->yes()
64+
) {
65+
return [];
66+
}
67+
/** @var string */
68+
$propertyName = $propertyName->getConstantScalarValues()[0];
69+
$has = $calledOnType->hasProperty($propertyName);
70+
if (!$has->yes() && !($has->maybe() && $this->allowsDynamicProperties($calledOnType))) {
71+
return [
72+
RuleErrorBuilder::message(sprintf(
73+
'Access to an undefined property %s::$%s.',
74+
$calledOnType->describe(VerbosityLevel::typeOnly()),
75+
$propertyName,
76+
))
77+
->identifier('salient.property.notFound')
78+
->build(),
79+
];
80+
}
81+
if ($name !== 'with' || count($args) < 2) {
82+
return [];
83+
}
84+
$propertyReflection = $calledOnType->getProperty($propertyName, $scope);
85+
$propertyType = $propertyReflection->getWritableType();
86+
if ($propertyType instanceof NeverType) {
87+
$propertyType = $propertyReflection->getReadableType();
88+
}
89+
$valueType = $scope->getType($args[1]->value);
90+
$accepts = $propertyType->isSuperTypeOf($valueType);
91+
if (!$accepts->yes()) {
92+
return [
93+
RuleErrorBuilder::message(sprintf(
94+
'Property %s::$%s (%s) does not accept %s.',
95+
$calledOnType->describe(VerbosityLevel::typeOnly()),
96+
$propertyName,
97+
$propertyType->describe(VerbosityLevel::precise()),
98+
$valueType->describe(VerbosityLevel::precise()),
99+
))
100+
->identifier('salient.property.type')
101+
->build(),
102+
];
103+
}
104+
return [];
105+
}
106+
107+
private function allowsDynamicProperties(Type $type): bool
108+
{
109+
foreach ($type->getObjectClassReflections() as $reflection) {
110+
if ($reflection->allowsDynamicProperties()) {
111+
return true;
112+
}
113+
}
114+
return false;
115+
}
116+
}

src/Toolkit/PHPStan/phpstan.extension.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
services:
2+
-
3+
class: Salient\PHPStan\Core\Rules\TypesAssignedByHasMutatorRule
4+
tags:
5+
- phpstan.rules.rule
6+
27
-
38
class: Salient\PHPStan\Utility\Rules\GetCoalesceRule
49
tags:

tests/bootstrap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?php declare(strict_types=1);
22

3+
require __DIR__ . '/fixtures/Toolkit/PHPStan/Core/Rules/TypesAssignedByHasMutatorRuleFailures.php';
34
require __DIR__ . '/fixtures/Toolkit/Utility/Debug/GetCallerFile1.php';
45
require __DIR__ . '/fixtures/Toolkit/Utility/Debug/GetCallerFile2.php';
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Salient\Tests\PHPStan\Core\Rules;
4+
5+
use Salient\Contract\Core\Immutable;
6+
use Salient\Core\Concern\HasMutator;
7+
use stdClass;
8+
9+
/**
10+
* @property-read bool $Bar
11+
*/
12+
class MyClassWithMutator implements Immutable
13+
{
14+
use HasMutator;
15+
16+
/** @var array-key */
17+
private $Foo;
18+
private bool $Bar;
19+
20+
/**
21+
* @param mixed $foo
22+
* @return static
23+
*/
24+
public function withFoo($foo)
25+
{
26+
$mutant = $this->with('Foo', $foo);
27+
return $mutant->with('Bar', 0);
28+
}
29+
30+
/**
31+
* @return static
32+
*/
33+
public function withBar(bool $bar = true)
34+
{
35+
return $this->with('Bar', $bar);
36+
}
37+
38+
/**
39+
* @return static
40+
*/
41+
public function withoutBar()
42+
{
43+
return $this->without('Bar');
44+
}
45+
46+
/**
47+
* @return static
48+
*/
49+
public function withoutMultiple()
50+
{
51+
$mutant = $this->without('qux');
52+
$mutant = $mutant->without('bar');
53+
return $mutant->without('Foo');
54+
}
55+
}
56+
57+
class MyClassWithMutatorAlias implements Immutable
58+
{
59+
use HasMutator {
60+
with as withPropertyValue;
61+
}
62+
63+
private int $Foo;
64+
65+
/**
66+
* @return static
67+
*/
68+
public function withFoo(string $foo)
69+
{
70+
return $this->withPropertyValue('Foo', $foo);
71+
}
72+
73+
/**
74+
* @return static
75+
*/
76+
public function withFoo2(int $foo)
77+
{
78+
return $this->withPropertyValue('Foo', $foo);
79+
}
80+
}
81+
82+
class MyDynamicClassWithMutator extends stdClass implements Immutable
83+
{
84+
use HasMutator;
85+
86+
/**
87+
* @return static
88+
*/
89+
public function withFoo(string $foo)
90+
{
91+
return $this->with('Foo', $foo);
92+
}
93+
}

0 commit comments

Comments
 (0)