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/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ parameters:
reportPossiblyNonexistentGeneralArrayOffset: false
reportPossiblyNonexistentConstantArrayOffset: false
checkMissingOverrideMethodAttribute: false
checkMissingPurePhpDocTag: false
checkMissingOverridePropertyAttribute: null
mixinExcludeClasses: []
scanFiles: []
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ parametersSchema:
reportPossiblyNonexistentGeneralArrayOffset: bool()
reportPossiblyNonexistentConstantArrayOffset: bool()
checkMissingOverrideMethodAttribute: bool()
checkMissingPurePhpDocTag: bool()
checkMissingOverridePropertyAttribute: schema(bool(), nullable())
parallel: structure([
jobSize: int(),
Expand Down
5 changes: 5 additions & 0 deletions src/Node/FunctionReturnStatementsNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ public function getSubNodeNames(): array
return [];
}

public function getFunction(): Function_
{
return $this->function;
}

public function getFunctionReflection(): PhpFunctionFromParserNodeReflection
{
return $this->functionReflection;
Expand Down
5 changes: 5 additions & 0 deletions src/Node/MethodReturnStatementsNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ public function getYieldStatements(): array
return $this->yieldStatements;
}

public function getClassMethod(): ClassMethod
{
return $this->classMethod;
}

public function getClassReflection(): ClassReflection
{
return $this->classReflection;
Expand Down
126 changes: 126 additions & 0 deletions src/Rules/Pure/FunctionPurityCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Rules\Pure;

use PhpParser\Comment\Doc;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name;
Expand All @@ -10,12 +11,19 @@
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\ThrowPoint;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Fixable\PhpDoc\PhpDocEditor;
use PHPStan\PhpDocParser\Ast\Node as PhpDocAstNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Reflection\ExtendedParameterReflection;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\PassedByReference;
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 @@ -25,11 +33,18 @@
use function is_string;
use function lcfirst;
use function sprintf;
use function str_contains;
use function substr;
use function trim;

#[AutowiredService]
final class FunctionPurityCheck
{

public function __construct(private PhpDocEditor $phpDocEditor)
{
}

/**
* @param 'Function'|'Method'|'PropertyHook' $identifier
* @param ExtendedParameterReflection[] $parameters
Expand Down Expand Up @@ -226,6 +241,117 @@ public function check(
return $errors;
}

/**
* Whether a function without a purity tag could get @phpstan-pure without
* any error from check(), and without changing what calling it as a
* statement means. Overridable methods are left out, because the tag
* would bind the overriding methods too.
*
* @param ExtendedParameterReflection[] $parameters
* @param ImpurePoint[] $impurePoints
* @param ThrowPoint[] $throwPoints
*/
public function canBeMarkedPure(
FunctionReflection|ExtendedMethodReflection $functionReflection,
array $parameters,
Type $returnType,
array $impurePoints,
array $throwPoints,
bool $isGenerator,
bool $returnsByRef,
bool $isConstructor,
): bool
{
if (!$functionReflection->isPure()->maybe()) {
return false;
}

if (
$functionReflection->getPureUnlessCallableIsImpureParameters() !== []
|| $functionReflection->getPureUnlessParameterPassedParameters() !== []
) {
return false;
}

// a caller can write through a returned reference
if (!$returnType->isVoid()->no() || $isGenerator || $returnsByRef) {
return false;
}

if (count($impurePoints) !== 0) {
return false;
}

// a function that throws on purpose may be called only for that, and
// a call to a pure function as a statement is reported as useless;
// implicit throw points come from every call and do not count
foreach ($throwPoints as $throwPoint) {
if ($throwPoint->isExplicit()) {
return false;
}
}

$throwType = $functionReflection->getThrowType();
if ($throwType !== null && !$throwType->isVoid()->yes()) {
return false;
}

if (count($functionReflection->getAsserts()->getAll()) !== 0) {
return false;
}

foreach ($parameters as $parameter) {
if (!$parameter->passedByReference()->equals(PassedByReference::createNo())) {
return false;
}
}

if ($isConstructor) {
return false;
}

if ($functionReflection instanceof ExtendedMethodReflection) {
return $functionReflection->isPrivate()
|| $functionReflection->isFinal()->equals(TrinaryLogic::createYes())
|| $functionReflection->getDeclaringClass()->isFinal();
}

return true;
}

/**
* @param 'Function'|'Method' $identifier
*/
public function createMissingPureTagError(string $functionDescription, string $identifier, Stmt\Function_|Stmt\ClassMethod $node): IdentifierRuleError
{
return RuleErrorBuilder::message(sprintf(
'%s does not have any side effects and can be marked @phpstan-pure.',
$functionDescription,
))
->identifier(sprintf('pure%s.missing', $identifier))
->fixNode($node, function (Stmt\Function_|Stmt\ClassMethod $node): Stmt\Function_|Stmt\ClassMethod {
// a tag appended to a single-line PHPDoc would stay on its line
$docComment = $node->getDocComment();
if ($docComment !== null && !str_contains($docComment->getText(), "\n")) {
$text = trim(substr($docComment->getText(), 3, -2));
$node->setDocComment(new Doc($text === '' ? '/** */' : sprintf("/**\n * %s\n */", $text)));
}

$this->phpDocEditor->edit($node, static function (PhpDocAstNode $phpDocNode): ?PhpDocAstNode {
if (!$phpDocNode instanceof PhpDocNode) {
return null;
}

$phpDocNode->children[] = new PhpDocTagNode('@phpstan-pure', new GenericTagValueNode(''));

return $phpDocNode;
});

return $node;
})
->build();
}

/**
* @param ImpurePoint[] $impurePoints
* @param array<string, true> $pureUnlessCallableParamNames
Expand Down
30 changes: 27 additions & 3 deletions src/Rules/Pure/PureFunctionRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Node\FunctionReturnStatementsNode;
use PHPStan\Rules\Rule;
Expand All @@ -16,7 +17,11 @@
final class PureFunctionRule implements Rule
{

public function __construct(private FunctionPurityCheck $check)
public function __construct(
private FunctionPurityCheck $check,
#[AutowiredParameter]
private bool $checkMissingPurePhpDocTag,
)
{
}

Expand All @@ -28,10 +33,11 @@ public function getNodeType(): string
public function processNode(Node $node, Scope $scope): array
{
$function = $node->getFunctionReflection();
$description = sprintf('Function %s()', $function->getName());

return $this->check->check(
$errors = $this->check->check(
$scope,
sprintf('Function %s()', $function->getName()),
$description,
'Function',
$function,
$function->getParameters(),
Expand All @@ -41,6 +47,24 @@ public function processNode(Node $node, Scope $scope): array
$node->getStatements(),
false,
);

if (
$this->checkMissingPurePhpDocTag
&& $this->check->canBeMarkedPure(
$function,
$function->getParameters(),
$function->getReturnType(),
$node->getImpurePoints(),
$node->getStatementResult()->getThrowPoints(),
$node->isGenerator(),
$node->returnsByRef(),
false,
)
) {
$errors[] = $this->check->createMissingPureTagError($description, 'Function', $node->getFunction());
}

return $errors;
}

}
32 changes: 29 additions & 3 deletions src/Rules/Pure/PureMethodRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Node\MethodReturnStatementsNode;
use PHPStan\Rules\Rule;
Expand All @@ -16,7 +17,11 @@
final class PureMethodRule implements Rule
{

public function __construct(private FunctionPurityCheck $check)
public function __construct(
private FunctionPurityCheck $check,
#[AutowiredParameter]
private bool $checkMissingPurePhpDocTag,
)
{
}

Expand All @@ -28,10 +33,11 @@ public function getNodeType(): string
public function processNode(Node $node, Scope $scope): array
{
$method = $node->getMethodReflection();
$description = sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName());

return $this->check->check(
$errors = $this->check->check(
$scope,
sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()),
$description,
'Method',
$method,
$method->getParameters(),
Expand All @@ -41,6 +47,26 @@ public function processNode(Node $node, Scope $scope): array
$node->getStatements(),
$method->isConstructor(),
);

// a trait method is analysed once per using class, and its PHPDoc is shared by all of them
if (
$this->checkMissingPurePhpDocTag
&& !$scope->isInTrait()
&& $this->check->canBeMarkedPure(
$method,
$method->getParameters(),
$method->getReturnType(),
$node->getImpurePoints(),
$node->getStatementResult()->getThrowPoints(),
$node->isGenerator(),
$node->returnsByRef(),
$method->isConstructor(),
)
) {
$errors[] = $this->check->createMissingPureTagError($description, 'Method', $node->getClassMethod());
}

return $errors;
}

}
33 changes: 32 additions & 1 deletion tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
class PureFunctionRuleTest extends RuleTestCase
{

private bool $checkMissingPurePhpDocTag = false;

public function getRule(): Rule
{
return new PureFunctionRule(new FunctionPurityCheck());
return new PureFunctionRule(self::getContainer()->getByType(FunctionPurityCheck::class), $this->checkMissingPurePhpDocTag);
}

public function testRule(): void
Expand Down Expand Up @@ -541,4 +543,33 @@ public function testPureUnlessParameterPassedBuiltin(): void
]);
}

public function testMissingPureTag(): void
{
$this->checkMissingPurePhpDocTag = true;
$this->analyse([__DIR__ . '/data/missing-pure-tag.php'], [
[
'Function MissingPureTag\\add() does not have any side effects and can be marked @phpstan-pure.',
5,
],
[
'Function MissingPureTag\\addOne() does not have any side effects and can be marked @phpstan-pure.',
13,
],
[
'Function MissingPureTag\\markedImpure() is marked as impure but does not have any side effects.',
25,
],
[
'Function MissingPureTag\\callsPure() does not have any side effects and can be marked @phpstan-pure.',
132,
],
]);
}

public function testFixMissingPureTag(): void
{
$this->checkMissingPurePhpDocTag = true;
$this->fix(__DIR__ . '/data/missing-pure-function-tag-fix.php', __DIR__ . '/data/missing-pure-function-tag-fix.php.fixed');
}

}
Loading
Loading