Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ parameters:
staticVariablesFromUsages: true
unusedVariable: true
unusedParameters: true
reportConflictingPurityTags: true
5 changes: 5 additions & 0 deletions conf/config.level2.neon
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ conditionalTags:
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule:
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
PHPStan\Rules\Pure\ConflictingPurityTagsRule:
phpstan.rules.rule: %featureToggles.reportConflictingPurityTags%

services:
-
Expand All @@ -32,3 +34,6 @@ services:

-
class: PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule

-
class: PHPStan\Rules\Pure\ConflictingPurityTagsRule
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ parameters:
staticVariablesFromUsages: false
unusedVariable: false
unusedParameters: false
reportConflictingPurityTags: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ parametersSchema:
staticVariablesFromUsages: bool()
unusedVariable: bool()
unusedParameters: bool()
reportConflictingPurityTags: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
87 changes: 87 additions & 0 deletions src/Rules/Pure/ConflictingPurityTagsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Pure;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\FileTypeMapper;
use function array_keys;
use function sprintf;
use function trim;

/**
* Reports a docblock that marks a function or method pure or impure and also
* makes its purity conditional with a @pure-unless-* tag. Only the docblock
* itself counts, not tags inherited from a parent or set on the class.
*
* @implements Rule<Node\FunctionLike>
*/
final class ConflictingPurityTagsRule implements Rule
{

public function __construct(private FileTypeMapper $fileTypeMapper)
{
}

public function getNodeType(): string
{
return Node\FunctionLike::class;
}

public function processNode(Node $node, Scope $scope): array
{
if ($node instanceof Node\Stmt\ClassMethod) {
if (!$scope->isInClass()) {
return [];
}
$functionName = $node->name->name;
$description = sprintf('Method %s::%s()', $scope->getClassReflection()->getDisplayName(), $functionName);
$identifier = 'pureMethod.conflictingPurityTags';
} elseif ($node instanceof Node\Stmt\Function_) {
$functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\');
$description = sprintf('Function %s()', $functionName);
$identifier = 'pureFunction.conflictingPurityTags';
} else {
return [];
}

$docComment = $node->getDocComment();
if ($docComment === null) {
return [];
}

$resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
$scope->getFile(),
$scope->isInClass() ? $scope->getClassReflection()->getName() : null,
$scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
$functionName,
$docComment->getText(),
);

$isPure = $resolvedPhpDoc->isPure();
if ($isPure === null) {
return [];
}

$errors = [];
foreach ([
'@pure-unless-callable-is-impure' => $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(),
'@pure-unless-parameter-passed' => $resolvedPhpDoc->getParamsPureUnlessParameterPassed(),
] as $tagName => $parameters) {
foreach (array_keys($parameters) as $parameterName) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s is marked as %s, which conflicts with %s for parameter $%s.',
$description,
$isPure ? 'pure' : 'impure',
$tagName,
$parameterName,
))->identifier($identifier)->build();
}
}

return $errors;
}

}
54 changes: 54 additions & 0 deletions tests/PHPStan/Rules/Pure/ConflictingPurityTagsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Pure;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPStan\Type\FileTypeMapper;

/**
* @extends RuleTestCase<ConflictingPurityTagsRule>
*/
class ConflictingPurityTagsRuleTest extends RuleTestCase
{

public function getRule(): Rule
{
return new ConflictingPurityTagsRule(self::getContainer()->getByType(FileTypeMapper::class));
}

public function testRule(): void
{
$this->analyse([__DIR__ . '/data/conflicting-purity-tags.php'], [
[
'Function ConflictingPurityTags\pureWithParameterPassed() is marked as pure, which conflicts with @pure-unless-parameter-passed for parameter $count.',
10,
],
[
'Function ConflictingPurityTags\impureWithCallable() is marked as impure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
20,
],
[
'Method ConflictingPurityTags\Replacer::both() is marked as impure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
46,
],
[
'Method ConflictingPurityTags\Replacer::both() is marked as impure, which conflicts with @pure-unless-parameter-passed for parameter $count.',
46,
],
[
'Function ConflictingPurityTags\pureWithCallable() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
86,
],
[
'Function ConflictingPurityTags\prefixedTags() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
96,
],
[
'Function ConflictingPurityTags\pureAndImpure() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.',
107,
],
]);
}

}
110 changes: 110 additions & 0 deletions tests/PHPStan/Rules/Pure/data/conflicting-purity-tags.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php declare(strict_types = 1);

namespace ConflictingPurityTags;

/**
* @param-out int $count
* @phpstan-pure
* @pure-unless-parameter-passed $count
*/
function pureWithParameterPassed(string $subject, int &$count = 0): string
{
return $subject;
}

/**
* @param callable(string): string $cb
* @phpstan-impure
* @pure-unless-callable-is-impure $cb
*/
function impureWithCallable(callable $cb, string $subject): string
{
return $cb($subject);
}

/**
* @param-out int $count
* @pure-unless-parameter-passed $count
*/
function onlyConditional(string $subject, int &$count = 0): string
{
$count = 1;

return $subject;
}

interface Replacer
{

/**
* @param-out int $count
* @param callable(string): string $cb
* @impure
* @pure-unless-parameter-passed $count
* @pure-unless-callable-is-impure $cb
*/
public function both(callable $cb, string $subject, int &$count = 0): string;

/**
* @param-out int $count
* @pure-unless-parameter-passed $count
*/
public function onlyConditional(string $subject, int &$count = 0): string;

}

class Child implements Replacer
{

/**
* The inherited @pure-unless-* tags are not part of this docblock.
*
* @phpstan-impure
*/
public function both(callable $cb, string $subject, int &$count = 0): string
{
echo $subject;

return $cb($subject);
}

/**
* @phpstan-pure
*/
public function onlyConditional(string $subject, int &$count = 0): string
{
return $subject;
}

}

/**
* @param callable(string): string $cb
* @phpstan-pure
* @pure-unless-callable-is-impure $cb
*/
function pureWithCallable(callable $cb, string $subject): string
{
return $cb($subject);
}

/**
* @param callable(string): string $cb
* @psalm-pure
* @phpstan-pure-unless-callable-is-impure $cb
*/
function prefixedTags(callable $cb, string $subject): string
{
return $cb($subject);
}

/**
* @param callable(string): string $cb
* @phpstan-pure
* @phpstan-impure
* @pure-unless-callable-is-impure $cb
*/
function pureAndImpure(callable $cb, string $subject): string
{
return $cb($subject);
}
Loading