Skip to content

Commit

Permalink
First-class callable rules
Browse files Browse the repository at this point in the history
  • Loading branch information
ondrejmirtes committed Nov 11, 2021
1 parent 083c9b5 commit 30ff227
Show file tree
Hide file tree
Showing 10 changed files with 362 additions and 0 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ lint:
--exclude tests/PHPStan/Rules/Constants/data/overriding-final-constant.php \
--exclude tests/PHPStan/Rules/Properties/data/intersection-types.php \
--exclude tests/PHPStan/Rules/Classes/data/first-class-instantiation-callable.php \
--exclude tests/PHPStan/Rules/Classes/data/instantiation-callable.php \
src tests compiler/src

cs:
Expand Down
9 changes: 9 additions & 0 deletions conf/config.level0.neon
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ rules:
- PHPStan\Rules\Classes\ExistingClassesInInterfaceExtendsRule
- PHPStan\Rules\Classes\ExistingClassInTraitUseRule
- PHPStan\Rules\Classes\InstantiationRule
- PHPStan\Rules\Classes\InstantiationCallableRule
- PHPStan\Rules\Classes\InvalidPromotedPropertiesRule
- PHPStan\Rules\Classes\NewStaticRule
- PHPStan\Rules\Classes\NonClassAttributeClassRule
Expand Down Expand Up @@ -164,6 +165,14 @@ services:
checkClassCaseSensitivity: %checkClassCaseSensitivity%
checkThisOnly: %checkThisOnly%

-
class: PHPStan\Rules\Functions\FunctionCallableRule
arguments:
checkFunctionNameCase: %checkFunctionNameCase%
reportMaybes: %reportMaybes%
tags:
- phpstan.rules.rule

-
class: PHPStan\Rules\Properties\OverridingPropertyRule
arguments:
Expand Down
5 changes: 5 additions & 0 deletions src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,9 @@ public function hasTentativeReturnTypes(): bool
return $this->versionId >= 80100;
}

public function supportsFirstClassCallables(): bool
{
return $this->versionId >= 80100;
}

}
28 changes: 28 additions & 0 deletions src/Rules/Classes/InstantiationCallableRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InstantiationCallableNode;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements \PHPStan\Rules\Rule<InstantiationCallableNode>
*/
class InstantiationCallableRule implements \PHPStan\Rules\Rule
{

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

public function processNode(Node $node, Scope $scope): array
{
return [
RuleErrorBuilder::message('Cannot create callable from the new operator.')->nonIgnorable()->build(),
];
}

}
125 changes: 125 additions & 0 deletions src/Rules/Functions/FunctionCallableRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PhpParser\Node;
use PHPStan\Analyser\NullsafeOperatorHelper;
use PHPStan\Analyser\Scope;
use PHPStan\Node\FunctionCallableNode;
use PHPStan\Php\PhpVersion;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Type\ErrorType;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;

/**
* @implements \PHPStan\Rules\Rule<FunctionCallableNode>
*/
class FunctionCallableRule implements \PHPStan\Rules\Rule
{

private ReflectionProvider $reflectionProvider;

private RuleLevelHelper $ruleLevelHelper;

private PhpVersion $phpVersion;

private bool $checkFunctionNameCase;

private bool $reportMaybes;

public function __construct(ReflectionProvider $reflectionProvider, RuleLevelHelper $ruleLevelHelper, PhpVersion $phpVersion, bool $checkFunctionNameCase, bool $reportMaybes)
{
$this->reflectionProvider = $reflectionProvider;
$this->ruleLevelHelper = $ruleLevelHelper;
$this->phpVersion = $phpVersion;
$this->checkFunctionNameCase = $checkFunctionNameCase;
$this->reportMaybes = $reportMaybes;
}

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

public function processNode(Node $node, Scope $scope): array
{
if (!$this->phpVersion->supportsFirstClassCallables()) {
return [
RuleErrorBuilder::message('First-class callables are supported only on PHP 8.1 and later.')
->nonIgnorable()
->build(),
];
}

$functionName = $node->getName();
if ($functionName instanceof Node\Name) {
$functionNameName = $functionName->toString();
if ($this->reflectionProvider->hasFunction($functionName, $scope)) {
if ($this->checkFunctionNameCase) {
$function = $this->reflectionProvider->getFunction($functionName, $scope);

/** @var string $calledFunctionName */
$calledFunctionName = $this->reflectionProvider->resolveFunctionName($functionName, $scope);
if (
strtolower($function->getName()) === strtolower($calledFunctionName)
&& $function->getName() !== $calledFunctionName
) {
return [
RuleErrorBuilder::message(sprintf(
'Call to function %s() with incorrect case: %s',
$function->getName(),
$functionNameName
))->build(),
];
}
}

return [];
}

if ($scope->isInFunctionExists($functionNameName)) {
return [];
}

return [
RuleErrorBuilder::message(sprintf('Function %s not found.', $functionNameName))
->build(),
];
}

$typeResult = $this->ruleLevelHelper->findTypeToCheck(
$scope,
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $functionName),
'Creating callable from an unknown class %s.',
static function (Type $type): bool {
return $type->isCallable()->yes();
}
);
$type = $typeResult->getType();
if ($type instanceof ErrorType) {
return $typeResult->getUnknownClassErrors();
}

$isCallable = $type->isCallable();
if ($isCallable->no()) {
return [
RuleErrorBuilder::message(
sprintf('Trying to create callable from %s but it\'s not a callable.', $type->describe(VerbosityLevel::value()))
)->build(),
];
}
if ($this->reportMaybes && $isCallable->maybe()) {
return [
RuleErrorBuilder::message(
sprintf('Trying to create callable from %s but it might not be a callable.', $type->describe(VerbosityLevel::value()))
)->build(),
];
}

return [];
}

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

namespace PHPStan\Rules\Classes;

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

/**
* @extends RuleTestCase<InstantiationCallableRule>
*/
class InstantiationCallableRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new InstantiationCallableRule();
}

public function testRule(): void
{
if (!self::$useStaticReflectionProvider) {
self::markTestSkipped('Test requires static reflection.');
}
$this->analyse([__DIR__ . '/data/instantiation-callable.php'], [
[
'Cannot create callable from the new operator.',
11,
],
]);
}

}
14 changes: 14 additions & 0 deletions tests/PHPStan/Rules/Classes/data/instantiation-callable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace InstantiationCallable;

class Foo
{

public function doFoo()
{
$a = new self();
$f = new self(...);
}

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

namespace PHPStan\Rules\Functions;

use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<FunctionCallableRule>
*/
class FunctionCallableRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
$reflectionProvider = $this->createReflectionProvider();

return new FunctionCallableRule(
$reflectionProvider,
new RuleLevelHelper($reflectionProvider, true, false, true, false),
new PhpVersion(PHP_VERSION_ID),
true,
true
);
}

public function testNotSupportedOnOlderVersions(): void
{
if (PHP_VERSION_ID >= 80100) {
self::markTestSkipped('Test runs on PHP < 8.1.');
}
if (!self::$useStaticReflectionProvider) {
self::markTestSkipped('Test requires static reflection.');
}

$this->analyse([__DIR__ . '/data/function-callable-not-supported.php'], [
[
'First-class callables are supported only on PHP 8.1 and later.',
10,
],
]);
}

public function testRule(): void
{
if (PHP_VERSION_ID < 80100) {
self::markTestSkipped('Test requires PHP 8.1.');
}

$this->analyse([__DIR__ . '/data/function-callable.php'], [
[
'Function nonexistent not found.',
13,
],
[
'Trying to create callable from string but it might not be a callable.',
19,
],
[
'Trying to create callable from 1 but it\'s not a callable.',
33,
],
[
'Call to function strlen() with incorrect case: StrLen',
38,
],
[
'Trying to create callable from 1|(callable(): mixed) but it might not be a callable.',
47,
],
[
'Creating callable from an unknown class FunctionCallable\Nonexistent.',
52,
],
]);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php // lint >= 8.1

namespace FunctionCallableNotSupported;

class Foo
{

public function doFoo(): void
{
$f = json_encode(...);
}

}
55 changes: 55 additions & 0 deletions tests/PHPStan/Rules/Functions/data/function-callable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php // lint >= 8.1

namespace FunctionCallable;

use function function_exists;

class Foo
{

public function doFoo(string $s): void
{
strlen(...);
nonexistent(...);

if (function_exists('blabla')) {
blabla(...);
}

$s(...);
if (function_exists($s)) {
$s(...);
}
}

public function doBar(): void
{
$f = function (): void {

};
$f(...);

$i = 1;
$i(...);
}

public function doBaz(): void
{
StrLen(...);
}

public function doLorem(callable $cb): void
{
if (rand(0, 1)) {
$cb = 1;
}

$f = $cb(...);
}

public function doIpsum(Nonexistent $obj): void
{
$f = $obj(...);
}

}

0 comments on commit 30ff227

Please sign in to comment.