Skip to content

Commit 8b43341

Browse files
committed
refactor Console and Commands to cater modern commands
1 parent 1b6cc7d commit 8b43341

4 files changed

Lines changed: 493 additions & 96 deletions

File tree

system/CLI/Commands.php

Lines changed: 205 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,47 @@
1414
namespace CodeIgniter\CLI;
1515

1616
use CodeIgniter\Autoloader\FileLocatorInterface;
17+
use CodeIgniter\CLI\Attributes\Command;
18+
use CodeIgniter\CLI\Exceptions\CommandNotFoundException;
1719
use CodeIgniter\Events\Events;
20+
use CodeIgniter\Exceptions\LogicException;
1821
use CodeIgniter\Log\Logger;
22+
use ReflectionAttribute;
1923
use ReflectionClass;
2024
use 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
*/
2732
class 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

Comments
 (0)