Skip to content

Commit d02ed5c

Browse files
committed
Report a docblock that combines a purity tag with a @pure-unless-* tag
1 parent b0e7a99 commit d02ed5c

7 files changed

Lines changed: 259 additions & 0 deletions

File tree

‎conf/bleedingEdge.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ parameters:
3232
staticVariablesFromUsages: true
3333
unusedVariable: true
3434
unusedParameters: true
35+
reportConflictingPurityTags: true

‎conf/config.level2.neon‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ conditionalTags:
1919
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
2020
PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule:
2121
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
22+
PHPStan\Rules\Pure\ConflictingPurityTagsRule:
23+
phpstan.rules.rule: %featureToggles.reportConflictingPurityTags%
2224

2325
services:
2426
-
@@ -32,3 +34,6 @@ services:
3234

3335
-
3436
class: PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule
37+
38+
-
39+
class: PHPStan\Rules\Pure\ConflictingPurityTagsRule

‎conf/config.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ parameters:
6464
staticVariablesFromUsages: false
6565
unusedVariable: false
6666
unusedParameters: false
67+
reportConflictingPurityTags: false
6768
fileExtensions:
6869
- php
6970
checkAdvancedIsset: false

‎conf/parametersSchema.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ parametersSchema:
6161
staticVariablesFromUsages: bool()
6262
unusedVariable: bool()
6363
unusedParameters: bool()
64+
reportConflictingPurityTags: bool()
6465
])
6566
fileExtensions: listOf(string())
6667
checkAdvancedIsset: bool()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Pure;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Rules\Rule;
8+
use PHPStan\Rules\RuleErrorBuilder;
9+
use PHPStan\Type\FileTypeMapper;
10+
use function array_keys;
11+
use function sprintf;
12+
use function trim;
13+
14+
/**
15+
* Reports a docblock that marks a function or method pure or impure and also
16+
* makes its purity conditional with a @pure-unless-* tag. Only the docblock
17+
* itself counts, not tags inherited from a parent or set on the class.
18+
*
19+
* @implements Rule<Node\FunctionLike>
20+
*/
21+
final class ConflictingPurityTagsRule implements Rule
22+
{
23+
24+
public function __construct(private FileTypeMapper $fileTypeMapper)
25+
{
26+
}
27+
28+
public function getNodeType(): string
29+
{
30+
return Node\FunctionLike::class;
31+
}
32+
33+
public function processNode(Node $node, Scope $scope): array
34+
{
35+
if ($node instanceof Node\Stmt\ClassMethod) {
36+
if (!$scope->isInClass()) {
37+
return [];
38+
}
39+
$functionName = $node->name->name;
40+
$description = sprintf('Method %s::%s()', $scope->getClassReflection()->getDisplayName(), $functionName);
41+
$identifier = 'pureMethod.conflictingPurityTags';
42+
} elseif ($node instanceof Node\Stmt\Function_) {
43+
$functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\');
44+
$description = sprintf('Function %s()', $functionName);
45+
$identifier = 'pureFunction.conflictingPurityTags';
46+
} else {
47+
return [];
48+
}
49+
50+
$docComment = $node->getDocComment();
51+
if ($docComment === null) {
52+
return [];
53+
}
54+
55+
$resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
56+
$scope->getFile(),
57+
$scope->isInClass() ? $scope->getClassReflection()->getName() : null,
58+
$scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
59+
$functionName,
60+
$docComment->getText(),
61+
);
62+
63+
$isPure = $resolvedPhpDoc->isPure();
64+
if ($isPure === null) {
65+
return [];
66+
}
67+
68+
$errors = [];
69+
foreach ([
70+
'@pure-unless-callable-is-impure' => $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(),
71+
'@pure-unless-parameter-passed' => $resolvedPhpDoc->getParamsPureUnlessParameterPassed(),
72+
] as $tagName => $parameters) {
73+
foreach (array_keys($parameters) as $parameterName) {
74+
$errors[] = RuleErrorBuilder::message(sprintf(
75+
'%s is marked as %s, which conflicts with %s for parameter $%s.',
76+
$description,
77+
$isPure ? 'pure' : 'impure',
78+
$tagName,
79+
$parameterName,
80+
))->identifier($identifier)->build();
81+
}
82+
}
83+
84+
return $errors;
85+
}
86+
87+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Pure;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\FileTypeMapper;
8+
9+
/**
10+
* @extends RuleTestCase<ConflictingPurityTagsRule>
11+
*/
12+
class ConflictingPurityTagsRuleTest extends RuleTestCase
13+
{
14+
15+
public function getRule(): Rule
16+
{
17+
return new ConflictingPurityTagsRule(self::getContainer()->getByType(FileTypeMapper::class));
18+
}
19+
20+
public function testRule(): void
21+
{
22+
$this->analyse([__DIR__ . '/data/conflicting-purity-tags.php'], [
23+
[
24+
'Function ConflictingPurityTags\pureWithParameterPassed() is marked as pure, which conflicts with @pure-unless-parameter-passed for parameter $count.',
25+
10,
26+
],
27+
[
28+
'Function ConflictingPurityTags\impureWithCallable() is marked as impure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
29+
20,
30+
],
31+
[
32+
'Method ConflictingPurityTags\Replacer::both() is marked as impure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
33+
46,
34+
],
35+
[
36+
'Method ConflictingPurityTags\Replacer::both() is marked as impure, which conflicts with @pure-unless-parameter-passed for parameter $count.',
37+
46,
38+
],
39+
[
40+
'Function ConflictingPurityTags\pureWithCallable() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
41+
86,
42+
],
43+
[
44+
'Function ConflictingPurityTags\prefixedTags() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
45+
96,
46+
],
47+
[
48+
'Function ConflictingPurityTags\pureAndImpure() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
49+
107,
50+
],
51+
]);
52+
}
53+
54+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ConflictingPurityTags;
4+
5+
/**
6+
* @param-out int $count
7+
* @phpstan-pure
8+
* @pure-unless-parameter-passed $count
9+
*/
10+
function pureWithParameterPassed(string $subject, int &$count = 0): string
11+
{
12+
return $subject;
13+
}
14+
15+
/**
16+
* @param callable(string): string $cb
17+
* @phpstan-impure
18+
* @pure-unless-callable-is-impure $cb
19+
*/
20+
function impureWithCallable(callable $cb, string $subject): string
21+
{
22+
return $cb($subject);
23+
}
24+
25+
/**
26+
* @param-out int $count
27+
* @pure-unless-parameter-passed $count
28+
*/
29+
function onlyConditional(string $subject, int &$count = 0): string
30+
{
31+
$count = 1;
32+
33+
return $subject;
34+
}
35+
36+
interface Replacer
37+
{
38+
39+
/**
40+
* @param-out int $count
41+
* @param callable(string): string $cb
42+
* @impure
43+
* @pure-unless-parameter-passed $count
44+
* @pure-unless-callable-is-impure $cb
45+
*/
46+
public function both(callable $cb, string $subject, int &$count = 0): string;
47+
48+
/**
49+
* @param-out int $count
50+
* @pure-unless-parameter-passed $count
51+
*/
52+
public function onlyConditional(string $subject, int &$count = 0): string;
53+
54+
}
55+
56+
class Child implements Replacer
57+
{
58+
59+
/**
60+
* The inherited @pure-unless-* tags are not part of this docblock.
61+
*
62+
* @phpstan-impure
63+
*/
64+
public function both(callable $cb, string $subject, int &$count = 0): string
65+
{
66+
echo $subject;
67+
68+
return $cb($subject);
69+
}
70+
71+
/**
72+
* @phpstan-pure
73+
*/
74+
public function onlyConditional(string $subject, int &$count = 0): string
75+
{
76+
return $subject;
77+
}
78+
79+
}
80+
81+
/**
82+
* @param callable(string): string $cb
83+
* @phpstan-pure
84+
* @pure-unless-callable-is-impure $cb
85+
*/
86+
function pureWithCallable(callable $cb, string $subject): string
87+
{
88+
return $cb($subject);
89+
}
90+
91+
/**
92+
* @param callable(string): string $cb
93+
* @psalm-pure
94+
* @phpstan-pure-unless-callable-is-impure $cb
95+
*/
96+
function prefixedTags(callable $cb, string $subject): string
97+
{
98+
return $cb($subject);
99+
}
100+
101+
/**
102+
* @param callable(string): string $cb
103+
* @phpstan-pure
104+
* @phpstan-impure
105+
* @pure-unless-callable-is-impure $cb
106+
*/
107+
function pureAndImpure(callable $cb, string $subject): string
108+
{
109+
return $cb($subject);
110+
}

0 commit comments

Comments
 (0)