Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type checking for hook callbacks in add_action and add_filter #107

Merged
merged 57 commits into from Nov 9, 2022
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
f540828
Setup the hook callback rule and test.
johnbillion May 18, 2022
8add4de
Add the wp-hooks library.
johnbillion May 18, 2022
689306a
Start adding tests.
johnbillion May 18, 2022
55e8e06
Preparing more tests.
johnbillion May 18, 2022
6af5fcc
If we don't have enough arguments, bail out and let PHPStan handle th…
johnbillion May 18, 2022
c551b5c
If the callback is not valid, bail out and let PHPStan handle the error:
johnbillion May 18, 2022
e54097d
Simplify this.
johnbillion Jun 1, 2022
50388cb
More valid test cases.
johnbillion Jun 1, 2022
b3d590a
Update some test line numbers.
johnbillion Jun 1, 2022
66e4099
First pass at detecting mismatching accepted and expected argument co…
johnbillion Jun 1, 2022
b7f4a01
Remove some accidental duplicates.
johnbillion Jun 1, 2022
e6da76c
Fix some logic.
johnbillion Jun 1, 2022
3fdb331
Let's split this up before it gets out of hand.
johnbillion Jun 1, 2022
65c16ed
Reduce this down.
johnbillion Jun 1, 2022
9e60e0b
More tidying up.
johnbillion Jun 1, 2022
ebe7f95
We're going to be needing this in multiple methods soon.
johnbillion Jun 1, 2022
983e3dd
Merge branch 'master' into wp-hooks
szepeviktor Jun 1, 2022
8fa8ec0
Prep for callback return type checking, not yet functional.
johnbillion Jun 2, 2022
1bf7e61
Split action return type assertion into its own method.
johnbillion Jun 2, 2022
c1a6579
Bring this message more inline with those used by PHPStan.
johnbillion Jun 2, 2022
3eaaea4
Add detection for filter callbacks with no return value.
johnbillion Jun 2, 2022
7c00f0f
Merge branch 'wp-hooks' of github.com:johnbillion/phpstan-wordpress i…
johnbillion Jun 2, 2022
becc7a9
Don't need this just yet.
johnbillion Jul 23, 2022
1303e83
Use early exit to reduce code nesting.
johnbillion Jul 23, 2022
73fb4f7
Remove unused imports.
johnbillion Jul 23, 2022
a9991e4
Yoda comparisons are disallowed.
johnbillion Jul 23, 2022
7a39448
Coding standards.
johnbillion Jul 23, 2022
4f2a64f
Remove unused code.
johnbillion Jul 23, 2022
38e7c35
Use early return to reduce code nesting.
johnbillion Jul 23, 2022
05de0ef
Remove more unused code.
johnbillion Jul 23, 2022
723748c
Renaming.
johnbillion Jul 23, 2022
e4fb1e7
Use early return to reduce code nesting.
johnbillion Jul 23, 2022
75d103a
Tidying up.
johnbillion Jul 23, 2022
4bab338
Correct logic introduced during refactoring.
johnbillion Jul 23, 2022
96fcbe9
Exclude "maybe" callables.
johnbillion Jul 23, 2022
f4c856a
More handling for parameter counts.
johnbillion Jul 23, 2022
8335226
More handling for optional parameters in hook callbacks.
johnbillion Jul 24, 2022
6098644
Make the tests more manageable.
johnbillion Jul 24, 2022
85e1fdd
Tests for more callback types.
johnbillion Jul 24, 2022
13eff30
Tidying up.
johnbillion Jul 24, 2022
75b473e
This isn't used.
johnbillion Jul 25, 2022
99ad16e
More test cases.
johnbillion Jul 30, 2022
b3a72c8
Tests for variadic callbacks.
johnbillion Jul 30, 2022
ab7c1eb
More specific handling for variadic callbacks.
johnbillion Jul 30, 2022
ee22105
More tests.
johnbillion Jul 30, 2022
72cfaf5
The hooks names are not relevant to the tests.
johnbillion Jul 30, 2022
83516e9
Remove an `else`.
johnbillion Jul 30, 2022
4e16fd1
I'm still not entirely sure why this works, but it does. Props @herndlm.
johnbillion Jul 31, 2022
448546f
Another test for an action with a return value.
johnbillion Jul 31, 2022
b3d50cf
More variadic handling.
johnbillion Jul 31, 2022
463075a
Turns out PHPStan handles typed and untyped functions differently.
johnbillion Jul 31, 2022
e6513f1
Partly broken callbacks will trigger an error in PHPStan so we don't …
johnbillion Jul 31, 2022
08f34f9
Terminology.
johnbillion Jul 31, 2022
b36e9d6
Refactoring.
johnbillion Aug 1, 2022
f47feef
PHP 7.2 compatibility.
johnbillion Aug 1, 2022
300f322
Reorder the processing so the return type is checked first.
johnbillion Aug 16, 2022
fc456bb
More tests.
johnbillion Aug 16, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Expand Up @@ -12,6 +12,7 @@
],
"require": {
"php": "^7.2 || ^8.0",
"johnbillion/wp-hooks": "^0.8.0",
johnbillion marked this conversation as resolved.
Show resolved Hide resolved
"php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0",
"phpstan/phpstan": "^1.6",
"symfony/polyfill-php73": "^1.12.0"
Expand Down
1 change: 1 addition & 0 deletions extension.neon
Expand Up @@ -80,6 +80,7 @@ services:
tags:
- phpstan.parser.richParserNodeVisitor
rules:
- SzepeViktor\PHPStan\WordPress\HookCallbackRule
- SzepeViktor\PHPStan\WordPress\HookDocsRule
- SzepeViktor\PHPStan\WordPress\IsWpErrorRule
parameters:
Expand Down
129 changes: 129 additions & 0 deletions src/HookCallbackRule.php
@@ -0,0 +1,129 @@
<?php

/**
* Custom rule to validate the callback function for WordPress core actions and filters.
*/

declare(strict_types=1);

namespace SzepeViktor\PHPStan\WordPress;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;

/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Expr\FuncCall>
*/
class HookCallbackRule implements \PHPStan\Rules\Rule
{
private const SUPPORTED_FUNCTIONS = [
'add_filter',
'add_action',
];

/** @var \PHPStan\Rules\RuleLevelHelper */
protected $ruleLevelHelper;

/** @var \PhpParser\Node\Expr\FuncCall */
protected $currentNode;

/** @var \PHPStan\Analyser\Scope */
protected $currentScope;
johnbillion marked this conversation as resolved.
Show resolved Hide resolved

public function __construct(
RuleLevelHelper $ruleLevelHelper
) {
$this->ruleLevelHelper = $ruleLevelHelper;
}

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

/**
* @param \PhpParser\Node\Expr\FuncCall $node
* @param \PHPStan\Analyser\Scope $scope
* @return array<int, \PHPStan\Rules\RuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
$name = $node->name;

if (!($name instanceof \PhpParser\Node\Name)) {
return [];
}

if (!in_array($name->toString(), self::SUPPORTED_FUNCTIONS, true)) {
return [];
}

$args = $node->getArgs();

// If we don't have enough arguments, bail out and let PHPStan handle the error:
if (count($args) < 2) {
return [];
}

list(
$hookNameArg,
$callbackArg
) = $args;

$hookNameType = $scope->getType($hookNameArg->value);
$hookNameValue = null;

if ($hookNameType instanceof ConstantStringType) {
$hookNameValue = $hookNameType->getValue();
}

$callbackType = $scope->getType($callbackArg->value);

// If the callback is not valid, bail out and let PHPStan handle the error:
if ($callbackType->isCallable()->no()) {
johnbillion marked this conversation as resolved.
Show resolved Hide resolved
return [];
}

$acceptedArgs = 1;

if (isset($args[3])) {
$acceptedArgs = null;
$argumentType = $scope->getType($args[3]->value);

if ($argumentType instanceof ConstantIntegerType) {
$acceptedArgs = $argumentType->getValue();
}
}

if ($acceptedArgs !== null) {
$callbackAcceptor = $callbackType->getCallableParametersAcceptors($scope)[0];

$callbackParameters = $callbackAcceptor->getParameters();
$expectedArgs = count($callbackParameters);

if ($expectedArgs !== $acceptedArgs && ($expectedArgs !== 0 && $acceptedArgs !== 1)) {
$message = (1 === $expectedArgs)
? 'Callback expects %1$d argument, $accepted_args is set to %2$d.'
: 'Callback expects %1$d arguments, $accepted_args is set to %2$d.'
;

return [
RuleErrorBuilder::message(
sprintf(
$message,
$expectedArgs,
$acceptedArgs
)
)->build()
];
}
}

return [];
}
}
67 changes: 67 additions & 0 deletions tests/HookCallbackRuleTest.php
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace SzepeViktor\PHPStan\WordPress\Tests;

use PHPStan\Rules\RuleLevelHelper;
use SzepeViktor\PHPStan\WordPress\HookCallbackRule;

/**
* @extends \PHPStan\Testing\RuleTestCase<\SzepeViktor\PHPStan\WordPress\HookCallbackRule>
*/
class HookCallbackRuleTest extends \PHPStan\Testing\RuleTestCase
{
protected function getRule(): \PHPStan\Rules\Rule
{
/** @var \PHPStan\Rules\RuleLevelHelper */
$ruleLevelHelper = self::getContainer()->getByType(RuleLevelHelper::class);

// getRule() method needs to return an instance of the tested rule
return new HookCallbackRule($ruleLevelHelper);
}

// phpcs:ignore NeutronStandard.Functions.LongFunction.LongFunction
public function testRule(): void
{
// first argument: path to the example file that contains some errors that should be reported by HookCallbackRule
// second argument: an array of expected errors,
// each error consists of the asserted error message, and the asserted error file line
$this->analyse(
[
__DIR__ . '/data/hook-callback.php',
],
[
[
'Filter callback has no return value.',
17,
],
[
'Filter callback has no return value.',
20,
],
[
'Filter callback has no return value.',
21,
],
[
'Callback expects 1 argument, $accepted_args is set to 0.',
24,
],
[
'Callback expects 1 argument, $accepted_args is set to 2.',
27,
],
[
'Callback expects 2 arguments, $accepted_args is set to 1.',
30,
],
]
);
}

public static function getAdditionalConfigFiles(): array
{
return [dirname(__DIR__) . '/vendor/szepeviktor/phpstan-wordpress/extension.neon'];
}
}
95 changes: 95 additions & 0 deletions tests/data/hook-callback.php
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace SzepeViktor\PHPStan\WordPress\Tests;

use function add_filter;
use function add_action;

// phpcs:disable Squiz.NamingConventions.ValidFunctionName.NotCamelCaps,Squiz.NamingConventions.ValidVariableName.NotCamelCaps,Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps

/**
* Incorrect usage:
*/

// Not a core filter, but callback is missing a return value
add_filter('not_a_core_filter', function() {});

// Core filter, callback is missing a return value
add_filter('post_class', function() {});
add_filter('post_class', function(array $classes) {});

// Not a core filter, accepted args are incorrect
add_filter('not_a_core_filter', function($value) {
return 123;
}, 10, 0);
add_filter('not_a_core_filter', function($value) {
return 123;
}, 10, 2);
add_filter('not_a_core_filter', function($value1, $value2) {
return 123;
});

// Core filter, callback is missing a return value
add_filter('post_class', function() {});
add_filter('post_class', function(array $classes) {});

/**
* Incorrect usage that's handled by PHPStan:
*
* These are here to ensure the rule doesn't trigger unwanted errors.
*/

// Too few parameters:
add_filter('post_class');
add_filter();

// Invalid callback:
add_filter('post_class','i_do_not_exist');

// Invalid parameters:
add_filter('post_class', function() {
return 123;
}, false);
add_filter('post_class', function() {
return 123;
}, 10, false);

/**
* Correct usage:
*/

// Not a core filter, but callback is ok
add_filter('not_a_core_filter', function() {
return 123;
}, 10, 0);
add_filter('not_a_core_filter', function() {
// We're allowing 0 parameters when `$accepted_args` is default value.
// This might change in the future to get more strict.
return 123;
});
add_filter('not_a_core_filter', function($value) {
return 123;
});
add_filter('not_a_core_filter', function($value) {
return 123;
}, 10, 1);
add_filter('not_a_core_filter', function($value1, $value2) {
return 123;
}, 10, 2);

// Various callback types
add_filter('not_a_core_filter', '__return_false');
add_filter('not_a_core_filter', __NAMESPACE__ . '\\filter_callback');
add_filter('not_a_core_filter', new TestInvokable(), 10, 2);

function filter_callback() {
return 123;
}

class TestInvokable {
public function __invoke($one, $two) {
return 123;
}
}