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
3 changes: 3 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ services:
class: SzepeViktor\PHPStan\WordPress\HookDocsVisitor
tags:
- phpstan.parser.richParserNodeVisitor
rules:
- WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule

parameters:
dynamicConstantNames:
- FOO
Expand Down
137 changes: 137 additions & 0 deletions src/PHPStan/WPCliAddHookCallbackRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);

namespace WP_CLI\Tests\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\VerbosityLevel;

use function count;
use function sprintf;
use function strpos;

/**
* @implements Rule<StaticCall>
*/
final class WPCliAddHookCallbackRule implements Rule {

private const KNOWN_HOOK_ARGS = [
'find_command_to_run_pre' => 0,
'before_wp_load' => 0,
'before_wp_config_load' => 0,
'after_wp_config_load' => 0,
'after_wp_load' => 0,
'before_ssh' => 0,
'before_registering_contexts' => 1,
'formatter_available_formats' => 1,
'http_request_options' => 5,
'before_run_command' => 3,
'search_replace_unserialize_options' => 1,
];

private const DYNAMIC_HOOK_PREFIXES = [
'before_add_command:' => 1,
'after_add_command:' => 0,
'before_invoke:' => 1,
'after_invoke:' => 1,
];

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

public function processNode( Node $node, Scope $scope ): array {
if ( ! $node instanceof StaticCall ) {
return [];
}

if ( ! $node->name instanceof Node\Identifier || 'add_hook' !== $node->name->name ) {
return [];
}

if ( ! $node->class instanceof Node\Name || 'WP_CLI' !== $scope->resolveName( $node->class ) ) {
return [];
}

$args = $node->getArgs();
if ( count( $args ) < 2 ) {
return [];
}

$callbackType = $scope->getType( $args[1]->value );
if ( ! $callbackType->isCallable()->yes() ) {
if ( $callbackType->isCallable()->no() ) {
return [
RuleErrorBuilder::message(
sprintf(
'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, %s given.',
$callbackType->describe( VerbosityLevel::typeOnly() )
)
)->identifier( 'wpCli.addHookCallback.invalidCallback' )->build(),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return [];
}

$hookNameType = $scope->getType( $args[0]->value );
$hookNameStrings = $hookNameType->getConstantStrings();
if ( count( $hookNameStrings ) !== 1 ) {
return [];
}

$hookName = $hookNameStrings[0]->getValue();
$expectedArgs = $this->getExpectedArgCountForHook( $hookName );
if ( null === $expectedArgs ) {
return [];
}

$callableParametersAcceptors = $callbackType->getCallableParametersAcceptors( $scope );
if ( count( $callableParametersAcceptors ) === 0 ) {
return [];
}

$parametersAcceptor = $callableParametersAcceptors[0];
$requiredParams = 0;
foreach ( $parametersAcceptor->getParameters() as $parameter ) {
if ( ! $parameter->isOptional() ) {
++$requiredParams;
}
}

if ( $requiredParams > $expectedArgs ) {
return [
RuleErrorBuilder::message(
sprintf(
'Callback for hook "%s" expects %d required %s, but only %d %s passed by WP_CLI::do_hook().',
$hookName,
$requiredParams,
1 === $requiredParams ? 'argument' : 'arguments',
$expectedArgs,
1 === $expectedArgs ? 'argument is' : 'arguments are'
)
)->identifier( 'wpCli.addHookCallback.insufficientParameters' )->build(),
];
}

return [];
}

private function getExpectedArgCountForHook( string $hookName ): ?int {
if ( isset( self::KNOWN_HOOK_ARGS[ $hookName ] ) ) {
return self::KNOWN_HOOK_ARGS[ $hookName ];
}

foreach ( self::DYNAMIC_HOOK_PREFIXES as $prefix => $count ) {
if ( 0 === strpos( $hookName, $prefix ) ) {
return $count;
}
}

return null;
}
}
54 changes: 54 additions & 0 deletions tests/data/add_hook_rule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/**
* Test data for WPCliAddHookCallbackRule.
*/

declare(strict_types=1);

namespace WP_CLI\Tests\Tests\PHPStan;

use WP_CLI;

// Valid: before_wp_load with 0 required args.
WP_CLI::add_hook(
'before_wp_load',
static function () {
// valid
}
);

// Valid: before_wp_load with optional arg.
WP_CLI::add_hook(
'before_wp_load',
static function ( $optional = null ) {
// valid
}
);

// Invalid: before_wp_load requires 1 arg, but do_hook passes 0.
WP_CLI::add_hook(
'before_wp_load',
static function ( $required_arg ) {
// invalid
}
);

// Valid: before_invoke:cmd with 1 required arg.
WP_CLI::add_hook(
'before_invoke:user list',
static function ( $cmd ) {
// valid
}
);

// Invalid: before_invoke:cmd requires 2 args, but do_hook passes 1.
WP_CLI::add_hook(
'before_invoke:user list',
static function ( $cmd, $extra_arg ) {
// invalid
}
);

// Invalid: callback is not callable.
WP_CLI::add_hook( 'before_wp_load', 'non_existent_function_12345' );
39 changes: 39 additions & 0 deletions tests/tests/PHPStan/TestWPCliAddHookCallbackRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace WP_CLI\Tests\Tests\PHPStan;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule;

/**
* @extends RuleTestCase<WPCliAddHookCallbackRule>
*/
class TestWPCliAddHookCallbackRule extends RuleTestCase {

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

public function testRule(): void {
$this->analyse(
[ __DIR__ . '/../../data/add_hook_rule.php' ],
[
[
'Callback for hook "before_wp_load" expects 1 required argument, but only 0 arguments are passed by WP_CLI::do_hook().',
30,
],
[
'Callback for hook "before_invoke:user list" expects 2 required arguments, but only 1 argument is passed by WP_CLI::do_hook().',
46,
],
[
'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, string given.',
54,
],
]
);
}
}
Loading