Skip to content

Commit 06c8a3d

Browse files
committed
Implement ArrayColumnRule
1 parent 0cbd46b commit 06c8a3d

File tree

8 files changed

+288
-0
lines changed

8 files changed

+288
-0
lines changed

conf/bleedingEdge.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ parameters:
55
explicitMixedInUnknownGenericNew: true
66
explicitMixedForGlobalVariables: true
77
explicitMixedViaIsArray: true
8+
arrayColumn: true
89
arrayFilter: true
910
arrayUnpacking: true
1011
arrayValues: true

conf/config.level5.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ parameters:
66
checkArgumentsPassedByReference: true
77

88
conditionalTags:
9+
PHPStan\Rules\Functions\ArrayColumnRule:
10+
phpstan.rules.rule: %featureToggles.arrayColumn%
911
PHPStan\Rules\Functions\ArrayFilterRule:
1012
phpstan.rules.rule: %featureToggles.arrayFilter%
1113
PHPStan\Rules\Functions\ArrayValuesRule:
@@ -56,3 +58,9 @@ services:
5658
class: PHPStan\Rules\Functions\ImplodeParameterCastableToStringRule
5759
-
5860
class: PHPStan\Rules\Functions\SortParameterCastableToStringRule
61+
62+
-
63+
class: PHPStan\Rules\Functions\ArrayColumnRule
64+
arguments:
65+
treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain%
66+
treatPhpDocTypesAsCertainTip: %tips.treatPhpDocTypesAsCertain%

conf/config.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ parameters:
3939
explicitMixedInUnknownGenericNew: false
4040
explicitMixedForGlobalVariables: false
4141
explicitMixedViaIsArray: false
42+
arrayColumn: false
4243
arrayFilter: false
4344
arrayUnpacking: false
4445
arrayValues: false
@@ -1192,6 +1193,9 @@ services:
11921193
tags:
11931194
- phpstan.broker.dynamicFunctionReturnTypeExtension
11941195

1196+
-
1197+
class: PHPStan\Type\Php\ArrayColumnHelper
1198+
11951199
-
11961200
class: PHPStan\Type\Php\ArrayCombineFunctionReturnTypeExtension
11971201
tags:

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ parametersSchema:
3434
explicitMixedInUnknownGenericNew: bool(),
3535
explicitMixedForGlobalVariables: bool(),
3636
explicitMixedViaIsArray: bool(),
37+
arrayColumn: bool(),
3738
arrayFilter: bool(),
3839
arrayUnpacking: bool(),
3940
arrayValues: bool(),
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\Type\NeverType;
12+
use PHPStan\Type\Php\ArrayColumnHelper;
13+
use PHPStan\Type\VerbosityLevel;
14+
use function count;
15+
use function sprintf;
16+
17+
/**
18+
* @implements Rule<Node\Expr\FuncCall>
19+
*/
20+
final class ArrayColumnRule implements Rule
21+
{
22+
23+
public function __construct(
24+
private readonly ReflectionProvider $reflectionProvider,
25+
private readonly bool $treatPhpDocTypesAsCertain,
26+
private bool $treatPhpDocTypesAsCertainTip,
27+
private ArrayColumnHelper $arrayColumnHelper,
28+
)
29+
{
30+
}
31+
32+
public function getNodeType(): string
33+
{
34+
return FuncCall::class;
35+
}
36+
37+
public function processNode(Node $node, Scope $scope): array
38+
{
39+
if (!($node->name instanceof Node\Name)) {
40+
return [];
41+
}
42+
43+
$args = $node->getArgs();
44+
if (count($args) < 2) {
45+
return [];
46+
}
47+
48+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
49+
return [];
50+
}
51+
52+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
53+
if ($functionReflection->getName() !== 'array_column') {
54+
return [];
55+
}
56+
57+
$indexKeyType = null;
58+
if ($this->treatPhpDocTypesAsCertain) {
59+
$arrayType = $scope->getType($args[0]->value);
60+
$columnKeyType = $scope->getType($args[1]->value);
61+
if (count($args) >= 3) {
62+
$indexKeyType = $scope->getType($args[2]->value);
63+
}
64+
} else {
65+
$arrayType = $scope->getNativeType($args[0]->value);
66+
$columnKeyType = $scope->getNativeType($args[1]->value);
67+
if (count($args) >= 3) {
68+
$indexKeyType = $scope->getNativeType($args[2]->value);
69+
}
70+
}
71+
72+
$errors = [];
73+
if ($columnKeyType->isNull()->no()) {
74+
[$returnValueType, $iterableAtLeastOnce] = $this->arrayColumnHelper->getReturnValueType($arrayType, $columnKeyType, $scope);
75+
if ($returnValueType instanceof NeverType || $iterableAtLeastOnce->no()) {
76+
$errorBuilder = RuleErrorBuilder::message(sprintf(
77+
'Cannot access column %s on %s.',
78+
$columnKeyType->describe(VerbosityLevel::value()),
79+
$arrayType->getIterableValueType()->describe(VerbosityLevel::value()),
80+
))->identifier('arrayColumn.column');
81+
82+
if ($this->treatPhpDocTypesAsCertainTip) {
83+
$errorBuilder->treatPhpDocTypesAsCertainTip();
84+
}
85+
86+
$errors[] = $errorBuilder->build();
87+
}
88+
}
89+
90+
if ($indexKeyType !== null && $indexKeyType->isNull()->no()) {
91+
$returnValueType = $this->arrayColumnHelper->getReturnIndexType($arrayType, $indexKeyType, $scope);
92+
if ($returnValueType instanceof NeverType) {
93+
$errorBuilder = RuleErrorBuilder::message(sprintf(
94+
'Cannot access column %s on %s.',
95+
$indexKeyType->describe(VerbosityLevel::value()),
96+
$arrayType->getIterableValueType()->describe(VerbosityLevel::value()),
97+
))->identifier('arrayColumn.index');
98+
99+
$nativeArrayType = $scope->getNativeType($args[0]->value);
100+
if ($this->treatPhpDocTypesAsCertainTip) {
101+
$errorBuilder->treatPhpDocTypesAsCertainTip();
102+
}
103+
104+
$errors[] = $errorBuilder->build();
105+
}
106+
}
107+
108+
return $errors;
109+
}
110+
111+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\Php\ArrayColumnHelper;
8+
use const PHP_VERSION_ID;
9+
10+
/**
11+
* @extends RuleTestCase<ArrayColumnRule>
12+
*/
13+
class ArrayColumnRuleTest extends RuleTestCase
14+
{
15+
16+
private bool $treatPhpDocTypesAsCertain = true;
17+
18+
protected function getRule(): Rule
19+
{
20+
return new ArrayColumnRule(
21+
$this->createReflectionProvider(),
22+
$this->treatPhpDocTypesAsCertain,
23+
true,
24+
self::getContainer()->getByType(ArrayColumnHelper::class),
25+
);
26+
}
27+
28+
public function testFile(): void
29+
{
30+
$expectedErrors = [];
31+
32+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/array-column-php7.php'], $expectedErrors);
33+
}
34+
35+
public function testFilePhp82(): void
36+
{
37+
if (PHP_VERSION_ID < 80200) {
38+
$this->markTestSkipped('Test requires PHP 8.2');
39+
}
40+
41+
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
42+
$expectedErrors = [
43+
[
44+
"Cannot access column 'column' on *NEVER*.",
45+
30,
46+
$tipText,
47+
],
48+
[
49+
"Cannot access column 'column' on *NEVER*.",
50+
31,
51+
$tipText,
52+
],
53+
[
54+
"Cannot access column 'key' on *NEVER*.",
55+
31,
56+
$tipText,
57+
],
58+
[
59+
"Cannot access column 'key' on *NEVER*.",
60+
32,
61+
$tipText,
62+
],
63+
[
64+
"Cannot access column 'foo' on array{column: string, key: string}.",
65+
76,
66+
$tipText,
67+
],
68+
[
69+
"Cannot access column 'foo' on array{column: string, key: string}.",
70+
77,
71+
$tipText,
72+
],
73+
[
74+
"Cannot access column 'nodeName' on ArrayColumn82\Foo.",
75+
216,
76+
$tipText,
77+
],
78+
[
79+
"Cannot access column 'nodeName' on ArrayColumn82\Foo.",
80+
217,
81+
$tipText,
82+
],
83+
[
84+
"Cannot access column 'tagName' on ArrayColumn82\Foo.",
85+
217,
86+
$tipText,
87+
],
88+
];
89+
90+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/array-column-php82.php'], $expectedErrors);
91+
}
92+
93+
public function testBug5101(): void
94+
{
95+
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
96+
$expectedErrors = [
97+
[
98+
"Cannot access column 'y' on Bug5101\FinalFooBar.",
99+
22,
100+
$tipText,
101+
],
102+
];
103+
104+
$this->analyse([__DIR__ . '/data/bug-5101.php'], $expectedErrors);
105+
}
106+
107+
public function testBug12188(): void
108+
{
109+
if (PHP_VERSION_ID < 80100) {
110+
$this->markTestSkipped('Test requires PHP 8.1');
111+
}
112+
113+
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
114+
$expectedErrors = [
115+
[
116+
"Cannot access column 'value' on Bug12188\Foo::A|Bug12188\Foo::B.",
117+
14,
118+
$tipText,
119+
],
120+
];
121+
122+
$this->analyse([__DIR__ . '/data/bug-12188.php'], $expectedErrors);
123+
}
124+
125+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace Bug12188;
4+
5+
enum Foo
6+
{
7+
case A;
8+
case B;
9+
}
10+
11+
function doFoo() {
12+
$arr = [Foo::A, Foo::B];
13+
14+
var_dump(array_column($arr, 'value'));
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php declare(strict_types = 1); // lint >= 7.4
2+
3+
namespace Bug5101;
4+
5+
class FooBar
6+
{
7+
public $x;
8+
}
9+
10+
final class FinalFooBar
11+
{
12+
public $x;
13+
}
14+
15+
/** @param array<FooBar> $arrClass */
16+
function doFoo(array $arrClass) {
17+
$arrFinalClass = [new FinalFooBar()];
18+
19+
var_dump(array_column($arrClass, 'x'));
20+
var_dump(array_column($arrClass, 'y'));
21+
var_dump(array_column($arrFinalClass, 'x'));
22+
var_dump(array_column($arrFinalClass, 'y'));
23+
}

0 commit comments

Comments
 (0)