Skip to content

Commit 2c2dac3

Browse files
committed
Add the functionality to interactively generate command inputs
1 parent ef87db0 commit 2c2dac3

File tree

2 files changed

+334
-25
lines changed

2 files changed

+334
-25
lines changed

src/Maker/MakeCommand.php

Lines changed: 251 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@
2828
use Symfony\Component\Console\Input\InputInterface;
2929
use Symfony\Component\Console\Input\InputOption;
3030
use Symfony\Component\Console\Output\OutputInterface;
31-
use Symfony\Component\Console\Question\Question;
3231
use Symfony\Component\Console\Style\SymfonyStyle;
3332
use Symfony\Component\HttpKernel\Kernel;
33+
use function is_string;
34+
use function sprintf;
3435

3536
/**
3637
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
@@ -44,7 +45,7 @@ public function __construct(private ?PhpCompatUtil $phpCompatUtil = null)
4445
@trigger_deprecation(
4546
'symfony/maker-bundle',
4647
'1.55.0',
47-
\sprintf('Initializing MakeCommand while providing an instance of "%s" is deprecated. The $phpCompatUtil param will be removed in a future version.', PhpCompatUtil::class),
48+
sprintf('Initializing MakeCommand while providing an instance of "%s" is deprecated. The $phpCompatUtil param will be removed in a future version.', PhpCompatUtil::class),
4849
);
4950
}
5051
}
@@ -62,9 +63,8 @@ public static function getCommandDescription(): string
6263
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
6364
{
6465
$command
65-
->addArgument('name', InputArgument::OPTIONAL, \sprintf('Choose a command name (e.g. <fg=yellow>app:%s</>)', Str::asCommand(Str::getRandomTerm())))
66-
->setHelp($this->getHelpFileContents('MakeCommand.txt'))
67-
;
66+
->addArgument('name', InputArgument::OPTIONAL, sprintf('Choose a command name (e.g. <fg=yellow>app:%s</>)', Str::asCommand(Str::getRandomTerm())))
67+
->setHelp($this->getHelpFileContents('MakeCommand.txt'));
6868

6969
if ($this->supportsInvokableCommand()) {
7070
$command->addOption('invokable', 'i', InputOption::VALUE_NONE, 'Use this option to create an invokable command');
@@ -85,20 +85,12 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
8585
$commandNameHasAppPrefix ? substr($commandName, 4) : $commandName,
8686
'Command\\',
8787
'Command',
88-
\sprintf('The "%s" command name is not valid because it would be implemented by "%s" class, which is not valid as a PHP class name (it must start with a letter or underscore, followed by any number of letters, numbers, or underscores).', $commandName, Str::asClassName($commandName, 'Command'))
88+
sprintf('The "%s" command name is not valid because it would be implemented by "%s" class, which is not valid as a PHP class name (it must start with a letter or underscore, followed by any number of letters, numbers, or underscores).', $commandName, Str::asClassName($commandName, 'Command'))
8989
);
9090

91-
$input->getOption('invokable') ?
92-
$this->generateInvokableCommand($commandName, $commandClassNameDetails, $io, $generator) :
91+
$input->getOption('invokable') ?
92+
$this->generateInvokableCommand($commandName, $commandClassNameDetails, $io, $generator) :
9393
$this->generateInheritanceCommand($commandName, $commandClassNameDetails, $io, $generator);
94-
95-
$generator->writeChanges();
96-
97-
$this->writeSuccessMessage($io);
98-
$io->text([
99-
'Next: open your new command class and customize it!',
100-
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/console.html</>',
101-
]);
10294
}
10395

10496
private function generateInheritanceCommand(string $commandName, ClassNameDetails $commandClassNameDetails, ConsoleStyle $io, Generator $generator): void
@@ -122,15 +114,36 @@ private function generateInheritanceCommand(string $commandName, ClassNameDetail
122114
'set_description' => !class_exists(LazyCommand::class),
123115
]
124116
);
117+
118+
$generator->writeChanges();
119+
120+
$this->writeSuccessMessage($io);
121+
$io->text([
122+
'Next: open your new command class and customize it!',
123+
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/console.html</>',
124+
]);
125125
}
126126

127127
private function generateInvokableCommand(string $commandName, ClassNameDetails $commandClassNameDetails, ConsoleStyle $io, Generator $generator): void
128128
{
129-
$description = $io->ask('Enter a short description for your command');
129+
if (class_exists($commandClassNameDetails->getFullName())) {
130+
$io->error('This command already exists.');
131+
132+
return;
133+
}
134+
135+
$description = $io->ask('What is the command description?');
136+
if (false === is_string($description)) {
137+
$description = (string)$description;
138+
}
139+
140+
$arguments = $this->askForArguments($io);
141+
$options = $this->askForOptions($io);
130142

131143
$useStatements = new UseStatementGenerator([
132144
AsCommand::class,
133145
Argument::class,
146+
Command::class,
134147
Option::class,
135148
SymfonyStyle::class,
136149
]);
@@ -142,8 +155,228 @@ private function generateInvokableCommand(string $commandName, ClassNameDetails
142155
'use_statements' => $useStatements,
143156
'command_name' => $commandName,
144157
'command_description' => $description,
158+
'command_parameters' => $this->mergeAndSortParameters($arguments, $options),
145159
]
146160
);
161+
162+
$generator->writeChanges();
163+
164+
$this->writeSuccessMessage($io);
165+
$io->text([
166+
'Next: open your new command class and customize it!',
167+
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/console.html</>',
168+
]);
169+
}
170+
171+
/**
172+
* @param array<int, array{name: string, type: string, description: string|null, default: mixed, nullable: bool}> $arguments
173+
* @param array<int, array{name: string, shortcut: string|null, type: string, description: string|null, default: mixed}> $options
174+
* @return array<int, array{name: string, type: string, description: string|null, default: mixed, nullable?: bool, shortcut?: string|null, param_type: string}>
175+
*/
176+
private function mergeAndSortParameters(array $arguments, array $options): array
177+
{
178+
// Merge arguments and options, marking each with its type
179+
$parameters = [];
180+
foreach ($arguments as $arg) {
181+
$parameters[] = array_merge($arg, ['param_type' => 'argument']);
182+
}
183+
foreach ($options as $opt) {
184+
$parameters[] = array_merge($opt, ['param_type' => 'option']);
185+
}
186+
187+
// Sort parameters: required parameters (no defaults) must come before optional ones (with defaults)
188+
usort($parameters, function ($a, $b) {
189+
$aHasDefault = $this->parameterHasDefault($a);
190+
$bHasDefault = $this->parameterHasDefault($b);
191+
192+
if ($aHasDefault === $bHasDefault) {
193+
return 0; // Keep original order within same group (required with required, optional with optional)
194+
}
195+
196+
// Required (no default) comes before optional (has default)
197+
return $aHasDefault ? 1 : -1;
198+
});
199+
200+
return $parameters;
201+
}
202+
203+
private function parameterHasDefault(array $param): bool
204+
{
205+
if ('argument' === $param['param_type']) {
206+
// Arguments have defaults if explicitly set or if nullable
207+
return null !== $param['default'] || ($param['nullable'] ?? false);
208+
}
209+
210+
// Options always have defaults
211+
return true;
212+
}
213+
214+
/**
215+
* @return array<int, array{name: string, type: string, description: string|null, default: mixed, nullable: bool}>
216+
*/
217+
private function askForArguments(ConsoleStyle $io): array
218+
{
219+
$arguments = [];
220+
$isFirst = true;
221+
222+
while (true) {
223+
$io->writeln('');
224+
225+
if ($isFirst) {
226+
$questionText = 'Argument name? (press <return> to stop adding arguments)';
227+
} else {
228+
$questionText = 'Add another argument? Enter the argument name (or press <return> to stop adding arguments)';
229+
}
230+
231+
$name = $io->ask($questionText, null, function ($name) use ($arguments) {
232+
// allow it to be empty
233+
if (!$name) {
234+
return $name;
235+
}
236+
237+
foreach ($arguments as $arg) {
238+
if ($arg['name'] === $name) {
239+
throw new \InvalidArgumentException(sprintf('The "%s" argument already exists.', $name));
240+
}
241+
}
242+
243+
return $name;
244+
});
245+
246+
if (!$name) {
247+
break;
248+
}
249+
250+
$isFirst = false;
251+
252+
$type = $io->choice(
253+
'What is the argument type?',
254+
['string', 'int', 'float', 'bool', 'array'],
255+
'string'
256+
);
257+
258+
$nullable = $io->confirm('Is this argument nullable?', false);
259+
260+
$description = $io->ask('What is the argument description?', null);
261+
if (!is_string($description) && null !== $description) {
262+
$description = (string)$description;
263+
}
264+
265+
$hasDefault = $io->confirm('Does this argument have a default value?', false);
266+
$default = null;
267+
if ($hasDefault) {
268+
if ('bool' === $type) {
269+
$default = $io->confirm('What is the default value?', false);
270+
} elseif ('int' === $type) {
271+
$default = (int)$io->ask('What is the default value?', '0');
272+
} elseif ('float' === $type) {
273+
$default = (float)$io->ask('What is the default value?', '0.0');
274+
} elseif ('array' === $type) {
275+
$defaultValue = $io->ask('What is the default value?', '[]');
276+
$default = '[]' === $defaultValue ? [] : $defaultValue;
277+
} else {
278+
$default = $io->ask('What is the default value?', '');
279+
if (!is_string($default)) {
280+
$default = (string)$default;
281+
}
282+
}
283+
} elseif ($nullable) {
284+
$default = null;
285+
}
286+
287+
$arguments[] = [
288+
'name' => $name,
289+
'type' => $type,
290+
'description' => $description,
291+
'default' => $default,
292+
'nullable' => $nullable,
293+
];
294+
}
295+
296+
return $arguments;
297+
}
298+
299+
/**
300+
* @return array<int, array{name: string, shortcut: string|null, type: string, description: string|null, default: mixed}>
301+
*/
302+
private function askForOptions(ConsoleStyle $io): array
303+
{
304+
$options = [];
305+
$isFirst = true;
306+
307+
while (true) {
308+
$io->writeln('');
309+
310+
if ($isFirst) {
311+
$questionText = 'What is the option name?';
312+
} else {
313+
$questionText = 'What is the next option name?';
314+
}
315+
316+
$name = $io->ask($questionText, null, function ($name) use ($options) {
317+
// allow it to be empty
318+
if (!$name) {
319+
return $name;
320+
}
321+
322+
foreach ($options as $opt) {
323+
if ($opt['name'] === $name) {
324+
throw new \InvalidArgumentException(sprintf('The "%s" option already exists.', $name));
325+
}
326+
}
327+
328+
return $name;
329+
});
330+
331+
if (!$name) {
332+
break;
333+
}
334+
335+
$isFirst = false;
336+
337+
$shortcut = $io->ask('What is the option shortcut?', null);
338+
if (!is_string($shortcut) && null !== $shortcut) {
339+
$shortcut = (string)$shortcut;
340+
}
341+
342+
$type = $io->choice(
343+
'What is the option type?',
344+
['bool', 'string', 'int', 'float', 'array'],
345+
'bool'
346+
);
347+
348+
$description = $io->ask('What is the option description?', null);
349+
if (!is_string($description) && null !== $description) {
350+
$description = (string)$description;
351+
}
352+
353+
$default = null;
354+
if ('bool' === $type) {
355+
$default = $io->confirm('What is the default value?', false);
356+
} elseif ('int' === $type) {
357+
$default = (int)$io->ask('What is the default value?', '0');
358+
} elseif ('float' === $type) {
359+
$default = (float)$io->ask('What is the default value?', '0.0');
360+
} elseif ('array' === $type) {
361+
$defaultValue = $io->ask('What is the default value?', '[]');
362+
$default = '[]' === $defaultValue ? [] : $defaultValue;
363+
} else {
364+
$default = $io->ask('What is the default value?', '');
365+
if (!is_string($default)) {
366+
$default = (string)$default;
367+
}
368+
}
369+
370+
$options[] = [
371+
'name' => $name,
372+
'shortcut' => $shortcut,
373+
'type' => $type,
374+
'description' => $description,
375+
'default' => $default,
376+
];
377+
}
378+
379+
return $options;
147380
}
148381

149382
public function configureDependencies(DependencyBuilder $dependencies): void
@@ -153,7 +386,7 @@ public function configureDependencies(DependencyBuilder $dependencies): void
153386
'console'
154387
);
155388
}
156-
389+
157390
private function supportsInvokableCommand(): bool
158391
{
159392
return Kernel::VERSION_ID >= 70300;

0 commit comments

Comments
 (0)