Skip to content

Commit 7eaba17

Browse files
authored
PHPStan: Add rule for WP_CLI::add_hook() usage (#345)
1 parent d339d2d commit 7eaba17

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

‎extension.neon‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ services:
1919
class: SzepeViktor\PHPStan\WordPress\HookDocsVisitor
2020
tags:
2121
- phpstan.parser.richParserNodeVisitor
22+
rules:
23+
- WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule
24+
2225
parameters:
2326
dynamicConstantNames:
2427
- FOO
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WP_CLI\Tests\PHPStan;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\StaticCall;
9+
use PHPStan\Analyser\Scope;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use PHPStan\Type\VerbosityLevel;
13+
14+
use function count;
15+
use function sprintf;
16+
use function strpos;
17+
18+
/**
19+
* @implements Rule<StaticCall>
20+
*/
21+
final class WPCliAddHookCallbackRule implements Rule {
22+
23+
private const KNOWN_HOOK_ARGS = [
24+
'find_command_to_run_pre' => 0,
25+
'before_wp_load' => 0,
26+
'before_wp_config_load' => 0,
27+
'after_wp_config_load' => 0,
28+
'after_wp_load' => 0,
29+
'before_ssh' => 0,
30+
'before_registering_contexts' => 1,
31+
'formatter_available_formats' => 1,
32+
'http_request_options' => 5,
33+
'before_run_command' => 3,
34+
'search_replace_unserialize_options' => 1,
35+
];
36+
37+
private const DYNAMIC_HOOK_PREFIXES = [
38+
'before_add_command:' => 1,
39+
'after_add_command:' => 0,
40+
'before_invoke:' => 1,
41+
'after_invoke:' => 1,
42+
];
43+
44+
public function getNodeType(): string {
45+
return StaticCall::class;
46+
}
47+
48+
public function processNode( Node $node, Scope $scope ): array {
49+
if ( ! $node instanceof StaticCall ) {
50+
return [];
51+
}
52+
53+
if ( ! $node->name instanceof Node\Identifier || 'add_hook' !== $node->name->name ) {
54+
return [];
55+
}
56+
57+
if ( ! $node->class instanceof Node\Name || 'WP_CLI' !== $scope->resolveName( $node->class ) ) {
58+
return [];
59+
}
60+
61+
$args = $node->getArgs();
62+
if ( count( $args ) < 2 ) {
63+
return [];
64+
}
65+
66+
$callbackType = $scope->getType( $args[1]->value );
67+
if ( ! $callbackType->isCallable()->yes() ) {
68+
if ( $callbackType->isCallable()->no() ) {
69+
return [
70+
RuleErrorBuilder::message(
71+
sprintf(
72+
'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, %s given.',
73+
$callbackType->describe( VerbosityLevel::typeOnly() )
74+
)
75+
)->identifier( 'wpCli.addHookCallback.invalidCallback' )->build(),
76+
];
77+
}
78+
return [];
79+
}
80+
81+
$hookNameType = $scope->getType( $args[0]->value );
82+
$hookNameStrings = $hookNameType->getConstantStrings();
83+
if ( count( $hookNameStrings ) !== 1 ) {
84+
return [];
85+
}
86+
87+
$hookName = $hookNameStrings[0]->getValue();
88+
$expectedArgs = $this->getExpectedArgCountForHook( $hookName );
89+
if ( null === $expectedArgs ) {
90+
return [];
91+
}
92+
93+
$callableParametersAcceptors = $callbackType->getCallableParametersAcceptors( $scope );
94+
if ( count( $callableParametersAcceptors ) === 0 ) {
95+
return [];
96+
}
97+
98+
$parametersAcceptor = $callableParametersAcceptors[0];
99+
$requiredParams = 0;
100+
foreach ( $parametersAcceptor->getParameters() as $parameter ) {
101+
if ( ! $parameter->isOptional() ) {
102+
++$requiredParams;
103+
}
104+
}
105+
106+
if ( $requiredParams > $expectedArgs ) {
107+
return [
108+
RuleErrorBuilder::message(
109+
sprintf(
110+
'Callback for hook "%s" expects %d required %s, but only %d %s passed by WP_CLI::do_hook().',
111+
$hookName,
112+
$requiredParams,
113+
1 === $requiredParams ? 'argument' : 'arguments',
114+
$expectedArgs,
115+
1 === $expectedArgs ? 'argument is' : 'arguments are'
116+
)
117+
)->identifier( 'wpCli.addHookCallback.insufficientParameters' )->build(),
118+
];
119+
}
120+
121+
return [];
122+
}
123+
124+
private function getExpectedArgCountForHook( string $hookName ): ?int {
125+
if ( isset( self::KNOWN_HOOK_ARGS[ $hookName ] ) ) {
126+
return self::KNOWN_HOOK_ARGS[ $hookName ];
127+
}
128+
129+
foreach ( self::DYNAMIC_HOOK_PREFIXES as $prefix => $count ) {
130+
if ( 0 === strpos( $hookName, $prefix ) ) {
131+
return $count;
132+
}
133+
}
134+
135+
return null;
136+
}
137+
}

‎tests/data/add_hook_rule.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/**
4+
* Test data for WPCliAddHookCallbackRule.
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace WP_CLI\Tests\Tests\PHPStan;
10+
11+
use WP_CLI;
12+
13+
// Valid: before_wp_load with 0 required args.
14+
WP_CLI::add_hook(
15+
'before_wp_load',
16+
static function () {
17+
// valid
18+
}
19+
);
20+
21+
// Valid: before_wp_load with optional arg.
22+
WP_CLI::add_hook(
23+
'before_wp_load',
24+
static function ( $optional = null ) {
25+
// valid
26+
}
27+
);
28+
29+
// Invalid: before_wp_load requires 1 arg, but do_hook passes 0.
30+
WP_CLI::add_hook(
31+
'before_wp_load',
32+
static function ( $required_arg ) {
33+
// invalid
34+
}
35+
);
36+
37+
// Valid: before_invoke:cmd with 1 required arg.
38+
WP_CLI::add_hook(
39+
'before_invoke:user list',
40+
static function ( $cmd ) {
41+
// valid
42+
}
43+
);
44+
45+
// Invalid: before_invoke:cmd requires 2 args, but do_hook passes 1.
46+
WP_CLI::add_hook(
47+
'before_invoke:user list',
48+
static function ( $cmd, $extra_arg ) {
49+
// invalid
50+
}
51+
);
52+
53+
// Invalid: callback is not callable.
54+
WP_CLI::add_hook( 'before_wp_load', 'non_existent_function_12345' );
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WP_CLI\Tests\Tests\PHPStan;
6+
7+
use PHPStan\Rules\Rule;
8+
use PHPStan\Testing\RuleTestCase;
9+
use WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule;
10+
11+
/**
12+
* @extends RuleTestCase<WPCliAddHookCallbackRule>
13+
*/
14+
class TestWPCliAddHookCallbackRule extends RuleTestCase {
15+
16+
protected function getRule(): Rule {
17+
return new WPCliAddHookCallbackRule();
18+
}
19+
20+
public function testRule(): void {
21+
$this->analyse(
22+
[ __DIR__ . '/../../data/add_hook_rule.php' ],
23+
[
24+
[
25+
'Callback for hook "before_wp_load" expects 1 required argument, but only 0 arguments are passed by WP_CLI::do_hook().',
26+
30,
27+
],
28+
[
29+
'Callback for hook "before_invoke:user list" expects 2 required arguments, but only 1 argument is passed by WP_CLI::do_hook().',
30+
46,
31+
],
32+
[
33+
'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, string given.',
34+
54,
35+
],
36+
]
37+
);
38+
}
39+
}

0 commit comments

Comments
 (0)