Skip to content

Commit 095ae22

Browse files
authored
Register commands given by class name lazily (#6419)
1 parent d22846e commit 095ae22

7 files changed

Lines changed: 345 additions & 8 deletions

File tree

‎features/command.feature‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2294,3 +2294,48 @@ Feature: WP-CLI Commands
22942294
"""
22952295
Warning: The `--old` argument for `deprecated-cmd` is deprecated. Use `--new` instead.
22962296
"""
2297+
2298+
Scenario: Commands registered by class name are only materialized when needed
2299+
Given an empty directory
2300+
And a lazy-cmd.php file:
2301+
"""
2302+
<?php
2303+
/**
2304+
* My lazy command.
2305+
*/
2306+
class My_Lazy_Command extends WP_CLI_Command {
2307+
/**
2308+
* Says hello.
2309+
*
2310+
* @when before_wp_load
2311+
*/
2312+
public function hello() {
2313+
WP_CLI::success( 'Hello from lazy command.' );
2314+
}
2315+
}
2316+
2317+
WP_CLI::add_command( 'my-lazy', 'My_Lazy_Command' );
2318+
"""
2319+
2320+
When I run `wp --require=lazy-cmd.php --debug=commands cli version 2>&1`
2321+
Then STDOUT should contain:
2322+
"""
2323+
Registering lazy command: my-lazy
2324+
"""
2325+
And STDOUT should not contain:
2326+
"""
2327+
Adding command: my-lazy
2328+
"""
2329+
2330+
When I run `wp --require=lazy-cmd.php my-lazy hello`
2331+
Then STDOUT should be:
2332+
"""
2333+
Success: Hello from lazy command.
2334+
"""
2335+
2336+
When I run `wp --require=lazy-cmd.php help my-lazy`
2337+
Then STDOUT should contain:
2338+
"""
2339+
My lazy command.
2340+
"""
2341+
And STDOUT should match /hello\s+Says hello\./

‎php/WP_CLI/Dispatcher/CompositeCommand.php‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ public function can_have_subcommands() {
133133
* @return array<string, Subcommand|CompositeCommand>
134134
*/
135135
public function get_subcommands() {
136+
foreach ( $this->subcommands as $name => $subcommand ) {
137+
if ( $subcommand instanceof LazyCommand ) {
138+
$this->materialize_subcommand( $name );
139+
}
140+
}
141+
136142
ksort( $this->subcommands );
137143

138144
return $this->subcommands;
@@ -290,6 +296,10 @@ public function find_subcommand( &$args ) {
290296
return false;
291297
}
292298

299+
if ( isset( $this->subcommands[ $name ] ) ) {
300+
return $this->materialize_subcommand( $name );
301+
}
302+
293303
$subcommands = $this->get_subcommands();
294304

295305
if ( ! isset( $subcommands[ $name ] ) ) {
@@ -307,6 +317,33 @@ public function find_subcommand( &$args ) {
307317
return $subcommands[ $name ];
308318
}
309319

320+
/**
321+
* Get a directly registered subcommand without materializing it.
322+
*
323+
* @param string $name Subcommand name.
324+
* @return Subcommand|CompositeCommand|false
325+
*/
326+
public function get_registered_subcommand( $name ) {
327+
return isset( $this->subcommands[ $name ] ) ? $this->subcommands[ $name ] : false;
328+
}
329+
330+
/**
331+
* Replace a lazy subcommand with the real command it stands for.
332+
*
333+
* @param string $name Subcommand name.
334+
* @return Subcommand|CompositeCommand
335+
*/
336+
protected function materialize_subcommand( $name ) {
337+
$subcommand = $this->subcommands[ $name ];
338+
339+
if ( $subcommand instanceof LazyCommand ) {
340+
$subcommand = $subcommand->materialize();
341+
$this->subcommands[ $name ] = $subcommand;
342+
}
343+
344+
return $subcommand;
345+
}
346+
310347
/**
311348
* Get any registered aliases for this composite command's
312349
* subcommands.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace WP_CLI\Dispatcher;
4+
5+
use WP_CLI;
6+
use WP_CLI\DocParser;
7+
8+
/**
9+
* Placeholder for a command registered via a class name, materialized on first use.
10+
*
11+
* Registering a command eagerly means autoloading its class and parsing the doc
12+
* comments of all its methods, even if a different command ends up being run.
13+
* A lazy command only records the class names registered for its name, plus any
14+
* subcommands attached to it in the meantime, and builds the real command tree
15+
* once it is looked up.
16+
*
17+
* @package WP_CLI
18+
*/
19+
class LazyCommand extends CompositeCommand {
20+
21+
/**
22+
* Class names registered for this command, in registration order.
23+
*
24+
* @var array<int, class-string>
25+
*/
26+
private $classes = [];
27+
28+
/**
29+
* @param RootCommand|CompositeCommand $parent_command Parent command.
30+
* @param string $name Command name.
31+
* @param class-string $class_name Command class.
32+
*/
33+
public function __construct( $parent_command, $name, $class_name ) {
34+
parent::__construct( $parent_command, $name, new DocParser( '' ) );
35+
36+
$this->classes[] = $class_name;
37+
}
38+
39+
/**
40+
* Register another class for the same command name.
41+
*
42+
* @param class-string $class_name Command class.
43+
* @return void
44+
*/
45+
public function add_class( $class_name ) {
46+
$this->classes[] = $class_name;
47+
}
48+
49+
/**
50+
* Build the real command, replicating the merge rules of `WP_CLI::add_command()`.
51+
*
52+
* @return Subcommand|CompositeCommand
53+
*/
54+
public function materialize() {
55+
/** @var RootCommand|CompositeCommand $parent */
56+
$parent = $this->parent;
57+
$current = null;
58+
59+
foreach ( $this->classes as $class_name ) {
60+
if ( ! class_exists( $class_name ) ) {
61+
WP_CLI::error( sprintf( 'Callable %s does not exist, and cannot be registered as `wp %s`.', (string) json_encode( $class_name ), implode( ' ', array_slice( get_path( $this ), 1 ) ) ) );
62+
}
63+
64+
$command = CommandFactory::create( $this->name, $class_name, $parent );
65+
66+
$command_type = $command instanceof CommandNamespace ? 'namespace' : 'command';
67+
WP_CLI::debug( "Adding {$command_type}: {$this->name} ({$class_name}, lazily)", 'commands' );
68+
69+
if ( null !== $current && $command instanceof CommandNamespace ) {
70+
continue;
71+
}
72+
73+
if ( $current instanceof CompositeCommand && $current->can_have_subcommands() ) {
74+
$keep = ( $command instanceof CommandNamespace || ! $command->can_have_subcommands() ) ? $current : $command;
75+
foreach ( $current->get_subcommands() as $subname => $subcommand ) {
76+
$keep->add_subcommand( $subname, $subcommand, false );
77+
}
78+
foreach ( $command->get_subcommands() as $subname => $subcommand ) {
79+
$keep->add_subcommand( $subname, $subcommand, true );
80+
}
81+
}
82+
83+
$current = $command;
84+
}
85+
86+
/** @var Subcommand|CompositeCommand $current */
87+
88+
if ( ! empty( $this->subcommands ) ) {
89+
if ( ! $current->can_have_subcommands() ) {
90+
throw new \Exception(
91+
sprintf( "'%s' can't have subcommands.", implode( ' ', get_path( $current ) ) )
92+
);
93+
}
94+
95+
foreach ( $this->subcommands as $subname => $subcommand ) {
96+
$subcommand->parent = $current;
97+
$current->add_subcommand( $subname, $subcommand );
98+
}
99+
}
100+
101+
return $current;
102+
}
103+
}

‎php/WP_CLI/Dispatcher/RootCommand.php‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ public function get_longdesc() {
3131
return $this->get_global_params( true );
3232
}
3333

34+
/**
35+
* Get a directly registered subcommand without materializing it.
36+
*
37+
* @param string $name Subcommand name.
38+
* @return Subcommand|CompositeCommand|false
39+
*/
40+
public function get_registered_subcommand( $name ) {
41+
Utils\load_command( $name );
42+
43+
return parent::get_registered_subcommand( $name );
44+
}
45+
3446
/**
3547
* Find a subcommand registered on the root
3648
* command.
@@ -50,6 +62,6 @@ public function find_subcommand( &$args ) {
5062
return false;
5163
}
5264

53-
return $this->subcommands[ $command ];
65+
return $this->materialize_subcommand( $command );
5466
}
5567
}

‎php/WP_CLI/Runner.php‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,9 @@ public function register_early_invoke( $when, $command ) {
188188
*/
189189
private function do_early_invoke( $when ): void {
190190
WP_CLI::debug( "Executing hook: {$when}", 'hooks' );
191-
if ( ! isset( $this->early_invoke[ $when ] ) ) {
192-
return;
193-
}
191+
192+
// Lazily registered commands only attach their @when hook once they are
193+
// materialized, which finding the command to run takes care of.
194194

195195
// Search the value of @when from the command method.
196196
// Use 'none' for autocorrect to avoid suggesting wrong alternatives
@@ -209,6 +209,10 @@ private function do_early_invoke( $when ): void {
209209
}
210210
}
211211

212+
if ( ! isset( $this->early_invoke[ $when ] ) ) {
213+
return;
214+
}
215+
212216
/** @var array<array<string>> $invoke_cmds */
213217
$invoke_cmds = (array) $this->early_invoke[ $when ];
214218
foreach ( (array) $invoke_cmds as $path ) {

‎php/class-wp-cli.php‎

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use WP_CLI\Dispatcher\CommandAddition;
88
use WP_CLI\Dispatcher\CommandFactory;
99
use WP_CLI\Dispatcher\DisabledCommand;
10+
use WP_CLI\Dispatcher\LazyCommand;
1011
use WP_CLI\Dispatcher\CommandNamespace;
1112
use WP_CLI\Dispatcher\CompositeCommand;
1213
use WP_CLI\Dispatcher\RootCommand;
@@ -470,6 +471,27 @@ private static function wp_hook_build_unique_id( $function ) {
470471
return false;
471472
}
472473

474+
/**
475+
* Check whether a class is loaded, or can be located by a Composer autoloader, without loading it.
476+
*
477+
* @param string $class_name Class name.
478+
* @return bool
479+
*/
480+
private static function is_class_loadable_without_autoloading( $class_name ) {
481+
if ( class_exists( $class_name, false ) ) {
482+
return true;
483+
}
484+
485+
foreach ( spl_autoload_functions() ?: [] as $autoloader ) {
486+
if ( is_array( $autoloader ) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader
487+
&& $autoloader[0]->findFile( ltrim( $class_name, '\\' ) ) ) {
488+
return true;
489+
}
490+
}
491+
492+
return false;
493+
}
494+
473495
/**
474496
* Register a command to WP-CLI.
475497
*
@@ -529,8 +551,20 @@ public static function add_command( $name, $callable, $args = [] ) {
529551
return false;
530552
}
531553

554+
// Commands registered by class name are only loaded once they are actually needed,
555+
// unless arguments are passed that require inspecting the command right away.
556+
$is_lazy = is_string( $callable )
557+
&& false === strpos( $callable, '::' )
558+
&& ! function_exists( $callable )
559+
&& self::is_class_loadable_without_autoloading( $callable )
560+
&& ! array_intersect( [ 'shortdesc', 'longdesc', 'synopsis', 'when' ], array_keys( $args ) )
561+
&& ! getenv( 'WP_CLI_DISABLE_LAZY_COMMANDS' );
562+
532563
$valid = false;
533-
if ( is_callable( $callable ) ) {
564+
if ( $is_lazy ) {
565+
// Validated once the command is materialized.
566+
$valid = true;
567+
} elseif ( is_callable( $callable ) ) {
534568
$valid = true;
535569
} elseif ( is_string( $callable ) && class_exists( $callable ) ) {
536570
$valid = true;
@@ -575,7 +609,12 @@ public static function add_command( $name, $callable, $args = [] ) {
575609
while ( ! empty( $path ) ) {
576610
$subcommand_name = $path[0];
577611
$parent = implode( ' ', $path );
578-
$subcommand = $command->find_subcommand( $path );
612+
$subcommand = $command->get_registered_subcommand( $subcommand_name );
613+
if ( $subcommand ) {
614+
array_shift( $path );
615+
} else {
616+
$subcommand = $command->find_subcommand( $path );
617+
}
579618

580619
// Parent not found. Defer addition or create an empty container as
581620
// needed.
@@ -614,6 +653,29 @@ public static function add_command( $name, $callable, $args = [] ) {
614653
$command = $subcommand;
615654
}
616655

656+
$existing_command = $command->get_registered_subcommand( $leaf_name );
657+
658+
if ( $is_lazy && ! $addition->was_aborted() && ( ! $existing_command || $existing_command instanceof LazyCommand ) ) {
659+
/** @var class-string $callable */
660+
if ( $existing_command instanceof LazyCommand ) {
661+
$existing_command->add_class( $callable );
662+
} else {
663+
$command->add_subcommand( $leaf_name, new LazyCommand( $command, $leaf_name, $callable ) );
664+
}
665+
666+
self::debug( "Registering lazy command: {$name}", 'commands' );
667+
668+
/**
669+
* Action triggered after a command has been added to WP-CLI.
670+
*/
671+
self::do_hook( "after_add_command:{$name}" );
672+
return true;
673+
}
674+
675+
if ( $is_lazy && ! class_exists( $callable ) ) {
676+
self::error( sprintf( 'Callable %s does not exist, and cannot be registered as `wp %s`.', (string) json_encode( $callable ), $name ) );
677+
}
678+
617679
$leaf_command = CommandFactory::create( $leaf_name, $callable, $command );
618680

619681
if ( $addition->was_aborted() ) {
@@ -622,13 +684,16 @@ public static function add_command( $name, $callable, $args = [] ) {
622684

623685
// Only add a command namespace if the command itself does not exist yet.
624686
if ( $leaf_command instanceof CommandNamespace
625-
&& array_key_exists( $leaf_name, $command->get_subcommands() ) ) {
687+
&& false !== $command->get_registered_subcommand( $leaf_name ) ) {
626688
return false;
627689
}
628690

629691
// Reattach commands attached to namespace to real command.
630692
$subcommand_name = (array) $leaf_name;
631-
$existing_command = $command->find_subcommand( $subcommand_name );
693+
$existing_command = $command->get_registered_subcommand( $leaf_name );
694+
if ( $existing_command instanceof LazyCommand ) {
695+
$existing_command = $command->find_subcommand( $subcommand_name );
696+
}
632697
if ( $existing_command instanceof CompositeCommand && $existing_command->can_have_subcommands() ) {
633698
if ( $leaf_command instanceof CommandNamespace || ! $leaf_command->can_have_subcommands() ) {
634699
$command_to_keep = $existing_command;

0 commit comments

Comments
 (0)