Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ This library ships custom PHPStan rules (`src/PHPStan/Rules/`) and disallowed ca
Consumer projects get these automatically via `phpstan/extension-installer`.

The `phpstan.neon` in this repo includes additional rules enabled only for this project itself.
A rule enabled there needs an `ignoreErrors` entry scoped to `tests/PHPStan/data/`, since fixtures violate rules on purpose.

Test new rules with PHPStan's `RuleTestCase` against a fixture in `tests/PHPStan/data/`.
That directory is excluded from rector and php-cs-fixer — both would otherwise normalize away the violations under test.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

$finder = PhpCsFixer\Finder::create()
->notPath('vendor')
->exclude('tests/PHPStan/data') // Fixtures intentionally violate the rules under test
->in(__DIR__)
->name('*.php')
->ignoreDotFiles(true)
Expand Down
17 changes: 17 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ rules:
#- MLL\Utils\PHPStan\Rules\ThrowableClassNameRule
- MLL\Utils\PHPStan\Rules\VariableNameIdToIDRule
- MLL\Utils\PHPStan\Rules\MissingClosureParameterTypehintRule
- MLL\Utils\PHPStan\Rules\MissingClosureReturnTypehintRule
parameters:
level: max
paths:
Expand Down Expand Up @@ -36,6 +37,22 @@ parameters:
paths:
- tests/PHPStan/data/

# Test fixtures intentionally omit closure type hints
- message: '#is missing a native return type hint\.#'
paths:
- tests/PHPStan/data/
- message: '#is missing a native type hint\.#'
paths:
- tests/PHPStan/data/

# Test fixtures include untyped functions and methods to prove the closure rules ignore them
- message: '#has no return type specified\.#'
paths:
- tests/PHPStan/data/
- message: '#with no type specified\.#'
paths:
- tests/PHPStan/data/

# PHPStan internal API usage is acceptable in tests
- message: '#is not covered by backward compatibility promise#'
paths:
Expand Down
4 changes: 2 additions & 2 deletions phpstan/php-below-8.1.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ parameters:
- '#Unknown PHPDoc tag: @phpstan-ignore#'

# Older PHPStan has stricter/different closure parameter typehint checking
- '#Closure parameter .* is missing a native type hint\.#'
- '#(Closure|Arrow function) parameter .* is missing a native type hint\.#'

# Differences in type inference between PHPStan versions
- '#Cannot access property .* on mixed\.#'
Expand All @@ -29,7 +29,7 @@ parameters:
- '#PHPDoc tag @param has invalid value.*covariant.*#'

# Return type differences in older PHPStan rule interfaces
- '#Method MLL\\Utils\\PHPStan\\Rules\\MissingClosureParameterTypehintRule::processNode\(\) should return array<int, PHPStan\\Rules\\IdentifierRuleError> but returns array<int, PHPStan\\Rules\\RuleError>\.#'
- '#Method MLL\\Utils\\PHPStan\\Rules\\MissingClosure(Parameter|Return)TypehintRule::processClosure\(\) should return array<int, PHPStan\\Rules\\IdentifierRuleError> but returns array<int, PHPStan\\Rules\\RuleError>\.#'

# Existing code with @phpstan-ignore that older versions don't understand
- message: '#Cannot access property \$name on SimpleXMLElement\|null\.#'
Expand Down
1 change: 1 addition & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitSelfCallRector::class,
])
->withSkip([
__DIR__ . '/tests/PHPStan/data', // fixtures intentionally violate the rules under test
Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitThisCallRector::class, // breaks tests
Rector\CodeQuality\Rector\Concat\JoinStringConcatRector::class => [
__DIR__ . '/tests/CSVArrayTest.php', // keep `\r\n` for readability
Expand Down
1 change: 1 addition & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ rules:
#- MLL\Utils\PHPStan\Rules\VariableNameIdToIDRule
#- MLL\Utils\PHPStan\Rules\PropertyNameIdToIDRule
#- MLL\Utils\PHPStan\Rules\MissingClosureParameterTypehintRule
#- MLL\Utils\PHPStan\Rules\MissingClosureReturnTypehintRule
parameters:
# https://github.com/spaze/phpstan-disallowed-calls/blob/main/docs/custom-rules.md
disallowedFunctionCalls:
Expand Down
51 changes: 51 additions & 0 deletions src/PHPStan/Rules/ClosureTypehintRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php declare(strict_types=1);

namespace MLL\Utils\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Closure;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;

/**
* @implements Rule<Node\FunctionLike>
*/
abstract class ClosureTypehintRule implements Rule
{
/**
* @param Closure|ArrowFunction $closure
*
* @return list<IdentifierRuleError>
*/
abstract protected function processClosure(Node\FunctionLike $closure): array;

/** @param Closure|ArrowFunction $closure */
protected function closureKind(Node\FunctionLike $closure): string
{
return $closure instanceof ArrowFunction
? 'Arrow function'
: 'Closure';
}

/** @return class-string<Node\FunctionLike> */
public function getNodeType(): string
{
return Node\FunctionLike::class;
}

/**
* @param Node\FunctionLike $node
*
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $node instanceof Closure && ! $node instanceof ArrowFunction) {
return [];
}

return $this->processClosure($node);
}
}
38 changes: 9 additions & 29 deletions src/PHPStan/Rules/MissingClosureParameterTypehintRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,17 @@
namespace MLL\Utils\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\Variable;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<Node\Expr>
*/
final class MissingClosureParameterTypehintRule implements Rule
final class MissingClosureParameterTypehintRule extends ClosureTypehintRule
{
/** @return class-string<Node\Expr> */
public function getNodeType(): string
protected function processClosure(Node\FunctionLike $closure): array
{
return Node\Expr::class;
}

/**
* @param Node\Expr $node
*
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $node instanceof Closure && ! $node instanceof ArrowFunction) {
return [];
}
$kind = $this->closureKind($closure);

$errors = [];
foreach ($node->params as $param) {
foreach ($closure->getParams() as $param) {
if ($param->type !== null) {
continue;
}
Expand All @@ -45,14 +24,15 @@ public function processNode(Node $node, Scope $scope): array
continue;
}

if (! is_string($paramVar->name)) {
$varName = $paramVar->name;

if (! is_string($varName)) {
continue;
}

$varName = $paramVar->name;

$errors[] = RuleErrorBuilder::message("Closure parameter {$varName} is missing a native type hint.")
$errors[] = RuleErrorBuilder::message("{$kind} parameter {$varName} is missing a native type hint.")
->identifier('missingType.parameter')
->line($param->getStartLine())
->build();
}

Expand Down
29 changes: 29 additions & 0 deletions src/PHPStan/Rules/MissingClosureReturnTypehintRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types=1);

namespace MLL\Utils\PHPStan\Rules;

use PhpParser\Node;
use PHPStan\Rules\RuleErrorBuilder;

/**
* Assumes PHP 8.0+, where every return type is natively expressible.
* On PHP 7.4 a closure returning `mixed` has no native type to declare,
* which is why `phpstan/include-by-php-version.php` gates `rules.neon`.
*/
final class MissingClosureReturnTypehintRule extends ClosureTypehintRule
{
protected function processClosure(Node\FunctionLike $closure): array
{
if ($closure->getReturnType() instanceof Node) {
return [];
}

$kind = $this->closureKind($closure);

return [
RuleErrorBuilder::message("{$kind} is missing a native return type hint.")
->identifier('missingType.closureReturn')
Comment thread
spawnia marked this conversation as resolved.
->build(),
];
}
}
28 changes: 28 additions & 0 deletions tests/PHPStan/MissingClosureParameterTypehintRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types=1);

namespace MLL\Utils\Tests\PHPStan;

use MLL\Utils\PHPStan\Rules\MissingClosureParameterTypehintRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<MissingClosureParameterTypehintRule>
*/
final class MissingClosureParameterTypehintRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new MissingClosureParameterTypehintRule();
}

public function testMissingParameterTypes(): void
{
$this->analyse([__DIR__ . '/data/closure-parameter-types.php'], [
['Closure parameter factor is missing a native type hint.', 3],
['Arrow function parameter factor is missing a native type hint.', 7],
['Closure parameter first is missing a native type hint.', 16],
['Closure parameter second is missing a native type hint.', 17],
]);
}
}
26 changes: 26 additions & 0 deletions tests/PHPStan/MissingClosureReturnTypehintRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types=1);

namespace MLL\Utils\Tests\PHPStan;

use MLL\Utils\PHPStan\Rules\MissingClosureReturnTypehintRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<MissingClosureReturnTypehintRule>
*/
final class MissingClosureReturnTypehintRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new MissingClosureReturnTypehintRule();
}

public function testMissingReturnTypes(): void
{
$this->analyse([__DIR__ . '/data/closure-return-types.php'], [
['Closure is missing a native return type hint.', 3],
['Arrow function is missing a native return type hint.', 7],
]);
}
}
33 changes: 33 additions & 0 deletions tests/PHPStan/data/closure-parameter-types.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types=1);

$missingClosureParameter = static function ($factor): int {
return 2;
};

$missingArrowParameter = static fn ($factor): int => 2;

$typedClosureParameter = static function (int $factor): int {
return 2 * $factor;
};

$typedArrowParameter = static fn (int $factor): int => 2 * $factor;

$missingMultiLineParameters = static function (
$first,
$second
): int {
return 2;
};

function plainFunctionWithoutParameterType($factor): int
{
return 2;
}

class MethodWithoutParameterType
{
public function untyped($factor): int
{
return 2;
}
}
26 changes: 26 additions & 0 deletions tests/PHPStan/data/closure-return-types.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types=1);

$missingClosure = static function (int $value) {
return $value * 2;
};

$missingArrow = static fn (int $value) => $value * 2;

$typedClosure = static function (int $value): int {
return $value * 2;
};

$typedArrow = static fn (int $value): int => $value * 2;

function plainFunctionWithoutReturnType()
{
return 1;
}

class MethodWithoutReturnType
{
public function untyped()
{
return 1;
}
}
Loading