Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 [

Check failure on line 69 in src/PHPStan/WPCliAddHookCallbackRule.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPStan

Method WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule::processNode() should return list<PHPStan\Rules\IdentifierRuleError> but returns array{PHPStan\Rules\RuleError}.
RuleErrorBuilder::message(
sprintf(
'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, %s given.',
$callbackType->describe( VerbosityLevel::typeOnly() )
)
)->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 [

Check failure on line 107 in src/PHPStan/WPCliAddHookCallbackRule.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPStan

Method WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule::processNode() should return list<PHPStan\Rules\IdentifierRuleError> but returns array{PHPStan\Rules\RuleError}.
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'
)
)->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