1414namespace CodeIgniter \CLI ;
1515
1616use CodeIgniter \Autoloader \FileLocatorInterface ;
17+ use CodeIgniter \CLI \Attributes \Command ;
18+ use CodeIgniter \CLI \Exceptions \CommandNotFoundException ;
1719use CodeIgniter \Events \Events ;
20+ use CodeIgniter \Exceptions \LogicException ;
1821use CodeIgniter \Log \Logger ;
22+ use ReflectionAttribute ;
1923use ReflectionClass ;
2024use ReflectionException ;
2125
2226/**
23- * Core functionality for running, listing, etc commands .
27+ * Command discovery and execution class .
2428 *
25- * @phpstan-type commands_list array<string, array{'class': class-string<BaseCommand>, 'file': string, 'group': string,'description': string}>
29+ * @phpstan-type legacy_commands array<string, array{class: class-string<BaseCommand>, file: string, group: string, description: string}>
30+ * @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string}>
2631 */
2732class Commands
2833{
2934 /**
30- * The found commands.
31- *
32- * @var commands_list
35+ * @var legacy_commands
3336 */
3437 protected $ commands = [];
3538
3639 /**
37- * Logger instance.
38- *
3940 * @var Logger
4041 */
4142 protected $ logger ;
4243
4344 /**
44- * Constructor
45+ * Discovered modern commands keyed by command name. Kept `private` so
46+ * subclasses do not mutate the registry directly; use {@see getModernCommands()}.
4547 *
48+ * @var modern_commands
49+ */
50+ private array $ modernCommands = [];
51+
52+ /**
53+ * Guards {@see discoverCommands()} from re-scanning the filesystem on repeat calls.
54+ */
55+ private bool $ discovered = false ;
56+
57+ /**
4658 * @param Logger|null $logger
4759 */
4860 public function __construct ($ logger = null )
@@ -52,24 +64,39 @@ public function __construct($logger = null)
5264 }
5365
5466 /**
55- * Runs a command given
67+ * Runs a legacy command.
68+ *
69+ * @deprecated 4.8.0 Use {@see runLegacy()} instead.
5670 *
5771 * @param array<int|string, string|null> $params
5872 *
59- * @return int Exit code
73+ * @return int
6074 */
6175 public function run (string $ command , array $ params )
6276 {
63- if (! $ this ->verifyCommand ($ command , $ this ->commands )) {
77+ @trigger_error (sprintf (
78+ 'Since v4.8.0, "%s()" is deprecated. Use "%s::runLegacy()" instead. ' ,
79+ __METHOD__ ,
80+ self ::class,
81+ ), E_USER_DEPRECATED );
82+
83+ return $ this ->runLegacy ($ command , $ params );
84+ }
85+
86+ /**
87+ * Runs a legacy command.
88+ *
89+ * @param array<int|string, string|null> $params
90+ */
91+ public function runLegacy (string $ command , array $ params ): int
92+ {
93+ if (! $ this ->verifyCommand ($ command )) {
6494 return EXIT_ERROR ;
6595 }
6696
67- $ className = $ this ->commands [$ command ]['class ' ];
68- $ class = new $ className ($ this ->logger , $ this );
69-
7097 Events::trigger ('pre_command ' );
7198
72- $ exitCode = $ class ->run ($ params );
99+ $ exitCode = $ this -> getCommand ( $ command ) ->run ($ params );
73100
74101 Events::trigger ('post_command ' );
75102
@@ -79,22 +106,75 @@ public function run(string $command, array $params)
79106 $ command ,
80107 get_debug_type ($ exitCode ),
81108 ), E_USER_DEPRECATED );
82- $ exitCode = EXIT_SUCCESS ;
109+ $ exitCode = EXIT_SUCCESS ; // @codeCoverageIgnore
83110 }
84111
85112 return $ exitCode ;
86113 }
87114
88115 /**
89- * Provide access to the list of commands .
116+ * Runs a modern command .
90117 *
91- * @return commands_list
118+ * @param list<string> $arguments
119+ * @param array<string, list<string|null>|string|null> $options
120+ */
121+ public function runCommand (string $ command , array $ arguments , array $ options ): int
122+ {
123+ if (! $ this ->verifyCommand ($ command , legacy: false )) {
124+ return EXIT_ERROR ;
125+ }
126+
127+ Events::trigger ('pre_command ' );
128+
129+ $ exitCode = $ this ->getCommand ($ command , legacy: false )->run ($ arguments , $ options );
130+
131+ Events::trigger ('post_command ' );
132+
133+ return $ exitCode ;
134+ }
135+
136+ /**
137+ * Provide access to the list of legacy commands.
138+ *
139+ * @return legacy_commands
92140 */
93141 public function getCommands ()
94142 {
95143 return $ this ->commands ;
96144 }
97145
146+ /**
147+ * Provide access to the list of modern commands.
148+ *
149+ * @return modern_commands
150+ */
151+ public function getModernCommands (): array
152+ {
153+ return $ this ->modernCommands ;
154+ }
155+
156+ /**
157+ * @return ($legacy is true ? BaseCommand : AbstractCommand)
158+ *
159+ * @throws CommandNotFoundException
160+ */
161+ public function getCommand (string $ command , bool $ legacy = true ): AbstractCommand |BaseCommand
162+ {
163+ if ($ legacy && isset ($ this ->commands [$ command ])) {
164+ $ className = $ this ->commands [$ command ]['class ' ];
165+
166+ return new $ className ($ this ->logger , $ this );
167+ }
168+
169+ if (! $ legacy && isset ($ this ->modernCommands [$ command ])) {
170+ $ className = $ this ->modernCommands [$ command ]['class ' ];
171+
172+ return new $ className ($ this );
173+ }
174+
175+ throw new CommandNotFoundException ($ command );
176+ }
177+
98178 /**
99179 * Discovers all commands in the framework and within user code,
100180 * and collects instances of them to work with.
@@ -103,68 +183,72 @@ public function getCommands()
103183 */
104184 public function discoverCommands ()
105185 {
106- if ($ this ->commands !== [] ) {
186+ if ($ this ->discovered ) {
107187 return ;
108188 }
109189
110- /** @var FileLocatorInterface */
111- $ locator = service ('locator ' );
112- $ files = $ locator ->listFiles ('Commands/ ' );
190+ $ this ->discovered = true ;
113191
114- if ($ files === []) {
115- return ;
116- }
192+ /** @var FileLocatorInterface $locator */
193+ $ locator = service ('locator ' );
117194
118- foreach ($ files as $ file ) {
119- /** @var class-string<BaseCommand>|false */
195+ foreach ($ locator ->listFiles ('Commands/ ' ) as $ file ) {
120196 $ className = $ locator ->findQualifiedNameFromPath ($ file );
121197
122198 if ($ className === false || ! class_exists ($ className )) {
123199 continue ;
124200 }
125201
126- try {
127- $ class = new ReflectionClass ($ className );
202+ $ class = new ReflectionClass ($ className );
128203
129- if (! $ class ->isInstantiable () || ! $ class ->isSubclassOf (BaseCommand::class)) {
130- continue ;
131- }
132-
133- $ class = new $ className ($ this ->logger , $ this );
134-
135- if ($ class ->group !== null && ! isset ($ this ->commands [$ class ->name ])) {
136- $ this ->commands [$ class ->name ] = [
137- 'class ' => $ className ,
138- 'file ' => $ file ,
139- 'group ' => $ class ->group ,
140- 'description ' => $ class ->description ,
141- ];
142- }
204+ if (! $ class ->isInstantiable ()) {
205+ continue ;
206+ }
143207
144- unset($ class );
145- } catch (ReflectionException $ e ) {
146- $ this ->logger ->error ($ e ->getMessage ());
208+ if ($ class ->isSubclassOf (BaseCommand::class)) {
209+ $ this ->registerLegacyCommand ($ class , $ file );
210+ } elseif ($ class ->isSubclassOf (AbstractCommand::class)) {
211+ $ this ->registerModernCommand ($ class , $ file );
147212 }
148213 }
149214
150- asort ($ this ->commands );
215+ ksort ($ this ->commands );
216+ ksort ($ this ->modernCommands );
217+
218+ foreach (array_keys (array_intersect_key ($ this ->commands , $ this ->modernCommands )) as $ name ) {
219+ CLI ::write (
220+ lang ('Commands.duplicateCommandName ' , [
221+ $ name ,
222+ $ this ->commands [$ name ]['class ' ],
223+ $ this ->modernCommands [$ name ]['class ' ],
224+ ]),
225+ 'yellow ' ,
226+ );
227+ }
151228 }
152229
153230 /**
154- * Verifies if the command being sought is found
155- * in the commands list.
231+ * Verifies if the command being sought is found in the commands list.
156232 *
157- * @param commands_list $commands
233+ * @param legacy_commands $commands (no longer used)
158234 */
159- public function verifyCommand (string $ command , array $ commands ): bool
235+ public function verifyCommand (string $ command , array $ commands = [], bool $ legacy = true ): bool
160236 {
161- if (isset ($ commands [$ command ])) {
237+ if ($ commands !== []) {
238+ @trigger_error (sprintf ('Since v4.8.0, the $commands parameter of %s() is no longer used. ' , __METHOD__ ), E_USER_DEPRECATED );
239+ }
240+
241+ if (isset ($ this ->commands [$ command ]) && $ legacy ) {
242+ return true ;
243+ }
244+
245+ if (isset ($ this ->modernCommands [$ command ]) && ! $ legacy ) {
162246 return true ;
163247 }
164248
165249 $ message = lang ('CLI.commandNotFound ' , [$ command ]);
166250
167- $ alternatives = $ this ->getCommandAlternatives ($ command , $ commands );
251+ $ alternatives = $ this ->getCommandAlternatives ($ command , legacy: $ legacy );
168252
169253 if ($ alternatives !== []) {
170254 $ message = sprintf (
@@ -181,20 +265,24 @@ public function verifyCommand(string $command, array $commands): bool
181265 }
182266
183267 /**
184- * Finds alternative of `$name` among collection
185- * of commands.
268+ * Finds alternative of `$name` among collection of commands.
186269 *
187- * @param commands_list $collection
270+ * @param legacy_commands $collection (no longer used)
188271 *
189272 * @return list<string>
190273 */
191- protected function getCommandAlternatives (string $ name , array $ collection ): array
274+ protected function getCommandAlternatives (string $ name , array $ collection = [], bool $ legacy = true ): array
192275 {
276+ if ($ collection !== []) {
277+ @trigger_error (sprintf ('Since v4.8.0, the $collection parameter of %s() is no longer used. ' , __METHOD__ ), E_USER_DEPRECATED );
278+ }
279+
280+ $ commandCollection = $ legacy ? $ this ->commands : $ this ->modernCommands ;
281+
193282 /** @var array<string, int> */
194283 $ alternatives = [];
195284
196- /** @var string $commandName */
197- foreach (array_keys ($ collection ) as $ commandName ) {
285+ foreach (array_keys ($ commandCollection ) as $ commandName ) {
198286 $ lev = levenshtein ($ name , $ commandName );
199287
200288 if ($ lev <= strlen ($ commandName ) / 3 || str_contains ($ commandName , $ name )) {
@@ -206,4 +294,62 @@ protected function getCommandAlternatives(string $name, array $collection): arra
206294
207295 return array_keys ($ alternatives );
208296 }
297+
298+ /**
299+ * @param ReflectionClass<BaseCommand> $class
300+ */
301+ private function registerLegacyCommand (ReflectionClass $ class , string $ file ): void
302+ {
303+ try {
304+ /** @var BaseCommand $instance */
305+ $ instance = $ class ->newInstance ($ this ->logger , $ this );
306+ } catch (ReflectionException $ e ) {
307+ $ this ->logger ->error ($ e ->getMessage ());
308+
309+ return ;
310+ }
311+
312+ if ($ instance ->group === null || isset ($ this ->commands [$ instance ->name ])) {
313+ return ;
314+ }
315+
316+ $ this ->commands [$ instance ->name ] = [
317+ 'class ' => $ class ->getName (),
318+ 'file ' => $ file ,
319+ 'group ' => $ instance ->group ,
320+ 'description ' => $ instance ->description ,
321+ ];
322+ }
323+
324+ /**
325+ * @param ReflectionClass<AbstractCommand> $class
326+ */
327+ private function registerModernCommand (ReflectionClass $ class , string $ file ): void
328+ {
329+ /** @var list<ReflectionAttribute<Command>> $attributes */
330+ $ attributes = $ class ->getAttributes (Command::class);
331+
332+ if ($ attributes === []) {
333+ return ;
334+ }
335+
336+ try {
337+ $ attribute = $ attributes [0 ]->newInstance ();
338+ } catch (LogicException $ e ) {
339+ $ this ->logger ->error ($ e ->getMessage ());
340+
341+ return ;
342+ }
343+
344+ if ($ attribute ->group === '' || isset ($ this ->modernCommands [$ attribute ->name ])) {
345+ return ;
346+ }
347+
348+ $ this ->modernCommands [$ attribute ->name ] = [
349+ 'class ' => $ class ->getName (),
350+ 'file ' => $ file ,
351+ 'group ' => $ attribute ->group ,
352+ 'description ' => $ attribute ->description ,
353+ ];
354+ }
209355}
0 commit comments