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 @@ -34,3 +34,4 @@ parameters:
unusedVariable: true
unusedParameters: true
reuseCollectedDataRuleErrors: true
reportAbstractMethodPurity: 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\PureAbstractMethodRule:
phpstan.rules.rule: %featureToggles.reportAbstractMethodPurity%

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

-
class: PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule

-
class: PHPStan\Rules\Pure\PureAbstractMethodRule
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ parameters:
unusedVariable: false
unusedParameters: false
reuseCollectedDataRuleErrors: false
reportAbstractMethodPurity: 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 @@ -63,6 +63,7 @@ parametersSchema:
unusedVariable: bool()
unusedParameters: bool()
reuseCollectedDataRuleErrors: bool()
reportAbstractMethodPurity: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
71 changes: 56 additions & 15 deletions src/Rules/Pure/FunctionPurityCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use PHPStan\Rules\Functions\CallToFunctionStatementWithoutSideEffectsRule;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Type;
use function array_filter;
use function array_key_exists;
Expand All @@ -31,38 +32,31 @@ final class FunctionPurityCheck
{

/**
* The checks of a purity declaration that need only the signature, so they
* also apply to methods without a body.
*
* @param 'Function'|'Method'|'PropertyHook' $identifier
* @param ExtendedParameterReflection[] $parameters
* @param ImpurePoint[] $impurePoints
* @param ThrowPoint[] $throwPoints
* @param Stmt[] $statements
* @return list<IdentifierRuleError>
*/
public function check(
public function checkSignature(
Scope $scope,
string $functionDescription,
string $identifier,
FunctionReflection|ExtendedMethodReflection $functionReflection,
array $parameters,
Type $returnType,
array $impurePoints,
array $throwPoints,
array $statements,
bool $isConstructor,
): array
{
$errors = [];
$isPure = $functionReflection->isPure();

$pureUnlessCallableParameters = $functionReflection->getPureUnlessCallableIsImpureParameters();
$pureUnlessCallableParamNames = [];
foreach ($parameters as $parameter) {
if (!array_key_exists($parameter->getName(), $pureUnlessCallableParameters)) {
continue;
}

$pureUnlessCallableParamNames[$parameter->getName()] = true;

$acceptors = $parameter->getType()->getCallableParametersAcceptors($scope);
if (count($acceptors) === 0) {
continue;
Expand Down Expand Up @@ -92,14 +86,11 @@ public function check(
}

$pureUnlessParameterPassedParameters = $functionReflection->getPureUnlessParameterPassedParameters();
$pureUnlessParameterPassedParamNames = [];
foreach ($parameters as $parameter) {
if (!array_key_exists($parameter->getName(), $pureUnlessParameterPassedParameters)) {
continue;
}

$pureUnlessParameterPassedParamNames[$parameter->getName()] = true;

if (!$parameter->isOptional()) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s is marked @pure-unless-parameter-passed for parameter $%s, but $%s is not optional, so %s is never pure.',
Expand Down Expand Up @@ -127,7 +118,7 @@ public function check(
))->identifier(sprintf('pure%s.parameterPassedNotByRef', $identifier))->build();
}

if ($isPure->yes()) {
if ($functionReflection->isPure()->yes()) {
foreach ($parameters as $parameter) {
if (!$parameter->passedByReference()->createsNewVariable()) {
continue;
Expand All @@ -152,7 +143,38 @@ public function check(
$functionDescription,
))->identifier(sprintf('pure%s.void', $identifier))->build();
}
}

return $errors;
}

/**
* @param 'Function'|'Method'|'PropertyHook' $identifier
* @param ExtendedParameterReflection[] $parameters
* @param ImpurePoint[] $impurePoints
* @param ThrowPoint[] $throwPoints
* @param Stmt[] $statements
* @return list<IdentifierRuleError>
*/
public function check(
Scope $scope,
string $functionDescription,
string $identifier,
FunctionReflection|ExtendedMethodReflection $functionReflection,
array $parameters,
Type $returnType,
array $impurePoints,
array $throwPoints,
array $statements,
bool $isConstructor,
): array
{
$errors = $this->checkSignature($scope, $functionDescription, $identifier, $functionReflection, $parameters, $returnType, $isConstructor);
$isPure = $functionReflection->isPure();
$pureUnlessCallableParamNames = $this->collectFlaggedParameterNames($parameters, $functionReflection->getPureUnlessCallableIsImpureParameters());
$pureUnlessParameterPassedParamNames = $this->collectFlaggedParameterNames($parameters, $functionReflection->getPureUnlessParameterPassedParameters());

if ($isPure->yes()) {
$errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription));
} elseif ($pureUnlessCallableParamNames !== [] || $pureUnlessParameterPassedParamNames !== []) {
// A function declared @pure-unless-callable-is-impure is pure except
Expand Down Expand Up @@ -226,6 +248,25 @@ public function check(
return $errors;
}

/**
* @param ExtendedParameterReflection[] $parameters
* @param array<string, TrinaryLogic> $flaggedParameters
* @return array<string, true>
*/
private function collectFlaggedParameterNames(array $parameters, array $flaggedParameters): array
{
$names = [];
foreach ($parameters as $parameter) {
if (!array_key_exists($parameter->getName(), $flaggedParameters)) {
continue;
}

$names[$parameter->getName()] = true;
}

return $names;
}

/**
* @param ImpurePoint[] $impurePoints
* @param array<string, true> $pureUnlessCallableParamNames
Expand Down
48 changes: 48 additions & 0 deletions src/Rules/Pure/PureAbstractMethodRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Pure;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassMethodNode;
use PHPStan\Rules\Rule;
use function sprintf;

/**
* Applies the signature checks of a purity declaration to methods without a
* body (abstract and interface methods), which PureMethodRule never sees.
*
* @implements Rule<InClassMethodNode>
*/
final class PureAbstractMethodRule implements Rule
{

public function __construct(private FunctionPurityCheck $check)
{
}

public function getNodeType(): string
{
return InClassMethodNode::class;
}

public function processNode(Node $node, Scope $scope): array
{
if ($node->getOriginalNode()->stmts !== null) {
return [];
}

$method = $node->getMethodReflection();

return $this->check->checkSignature(
$scope,
sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()),
'Method',
$method,
$method->getParameters(),
$method->getReturnType(),
$method->isConstructor(),
);
}

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

namespace PHPStan\Rules\Pure;

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

/**
* @extends RuleTestCase<PureAbstractMethodRule>
*/
class PureAbstractMethodRuleTest extends RuleTestCase
{

public function getRule(): Rule
{
return new PureAbstractMethodRule(new FunctionPurityCheck());
}

public function testRule(): void
{
$this->analyse([__DIR__ . '/data/pure-abstract-method.php'], [
[
'Method PureAbstractMethod\Foo::pureByRef() is marked as pure but parameter $p is passed by reference.',
11,
],
[
'Method PureAbstractMethod\Foo::pureVoid() is marked as pure but returns void.',
16,
],
[
'Method PureAbstractMethod\Foo::requiredCount() is marked @pure-unless-parameter-passed for parameter $count, but $count is not optional, so method PureAbstractMethod\Foo::requiredCount() is never pure.',
33,
],
[
'Method PureAbstractMethod\Foo::byValueFlag() is marked @pure-unless-parameter-passed for parameter $flag, but $flag is not passed by reference.',
38,
],
[
'Method PureAbstractMethod\Foo::alreadyPureCallback() is marked @pure-unless-callable-is-impure for parameter $cb, but $cb is already a pure callable, so method PureAbstractMethod\Foo::alreadyPureCallback() can be marked @phpstan-pure instead.',
50,
],
[
'Method PureAbstractMethod\Bar::pureByRef() is marked as pure but parameter $p is passed by reference.',
66,
],
[
'Method PureAbstractMethod\Bar::requiredCount() is marked @pure-unless-parameter-passed for parameter $count, but $count is not optional, so method PureAbstractMethod\Bar::requiredCount() is never pure.',
72,
],
]);
}

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

namespace PureAbstractMethod;

interface Foo
{

/**
* @phpstan-pure
*/
public function pureByRef(int &$p): int;

/**
* @phpstan-pure
*/
public function pureVoid(): void;

/**
* @phpstan-pure
* @throws \Exception
*/
public function pureVoidThrowing(): void;

/**
* @phpstan-pure
*/
public function pureFine(int $p): int;

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

/**
* @pure-unless-parameter-passed $flag
*/
public function byValueFlag(string $subject, bool $flag = false): string;

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

/**
* @param pure-callable(int): int $cb
* @pure-unless-callable-is-impure $cb
*/
public function alreadyPureCallback(callable $cb): int;

/**
* @param callable(int): int $cb
* @pure-unless-callable-is-impure $cb
*/
public function callback(callable $cb): int;

}

abstract class Bar
{

/**
* @phpstan-pure
*/
abstract public function pureByRef(int &$p): int;

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

/**
* A method with a body is left to PureMethodRule.
*
* @phpstan-pure
*/
public function withBody(int &$p): int
{
return $p;
}

}
Loading