2828use Symfony \Component \Console \Input \InputInterface ;
2929use Symfony \Component \Console \Input \InputOption ;
3030use Symfony \Component \Console \Output \OutputInterface ;
31- use Symfony \Component \Console \Question \Question ;
3231use Symfony \Component \Console \Style \SymfonyStyle ;
3332use 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