Skip to content

Commit e9a13f4

Browse files
committed
Add support for sub commands
1 parent 2bdf843 commit e9a13f4

File tree

16 files changed

+405
-54
lines changed

16 files changed

+405
-54
lines changed

spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,20 @@
2323
* Default implementation of {@link CommandParser}. Supports options in the form -o=value
2424
* and --option=value. Options and arguments can be specified in any order. Arguments are
2525
* 0-based indexed among other arguments. <pre>
26-
* CommandSyntax ::= CommandName (Option | Argument)*
27-
* CommandName ::= String
28-
* Option ::= ShortOption | LongOption
29-
* ShortOption ::= '-' Char ('=' String)?
30-
* LongOption ::= '--' String ('=' String)?
31-
* Argument ::= String
26+
* CommandSyntax ::= CommandName [SubCommandName]* [Option | Argument]*
27+
* CommandName ::= String
28+
* SubCommandName ::= String
29+
* Option ::= ShortOption | LongOption
30+
* ShortOption ::= '-' Char ('=' String)?
31+
* LongOption ::= '--' String ('=' String)?
32+
* Argument ::= String
3233
*
33-
* Example: mycommand --option1=value1 arg1 -o2=value2 arg2
34+
* Example: mycommand mysubcommand --option1=value1 arg1 -o2=value2 arg2
3435
* </pre>
3536
*
3637
* @author Mahmoud Ben Hassine
3738
* @since 4.0.0
3839
*/
39-
// TODO add support for subcommands
4040
public class DefaultCommandParser implements CommandParser {
4141

4242
@Override
@@ -51,6 +51,23 @@ public ParsedInput parse(Input input) {
5151
}
5252

5353
List<String> remainingWords = words.subList(1, words.size());
54+
55+
// parse sub commands: if no options, then need to use -- to separate sub commands
56+
// from arguments (POSIX style)
57+
int subCommandCount = 0;
58+
for (String word : remainingWords) {
59+
if (!isOption(word) && !isArgumentSeparator(word)) {
60+
subCommandCount++;
61+
parsedInputBuilder.addSubCommand(word);
62+
}
63+
else {
64+
break;
65+
}
66+
}
67+
if (subCommandCount > 0) {
68+
remainingWords = remainingWords.subList(subCommandCount, remainingWords.size());
69+
}
70+
5471
// parse options
5572
List<String> options = remainingWords.stream().filter(this::isOption).toList();
5673
for (String option : options) {
@@ -66,8 +83,13 @@ public ParsedInput parse(Input input) {
6683
return parsedInputBuilder.build();
6784
}
6885

86+
// Check if the word is the argument separator, ie empty "--" (POSIX style)
87+
private boolean isArgumentSeparator(String word) {
88+
return word.equals("--");
89+
}
90+
6991
private boolean isOption(String word) {
70-
return word.startsWith("-") || word.startsWith("--");
92+
return word.startsWith("-") || (word.startsWith("--") && !isArgumentSeparator(word));
7193
}
7294

7395
private CommandOption parseOption(String word) {

spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ public Command getObject() {
5757
org.springframework.shell.core.command.annotation.Command command = MergedAnnotations.from(this.method)
5858
.get(org.springframework.shell.core.command.annotation.Command.class)
5959
.synthesize();
60-
// TODO handle aliases, sub commands, hidden flag.
61-
String name = command.name()[0];
60+
// TODO handle aliases, hidden flag.
61+
String name = String.join(" ", command.name());
6262
String description = command.description();
6363
String help = command.help();
6464
String group = command.group();

spring-shell-core/src/main/java/org/springframework/shell/core/commands/Help.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public String getDescription() {
3737

3838
@Override
3939
public ExitStatus execute(CommandContext commandContext) throws Exception {
40-
String helpMessage = CommandUtils.getAvailableCommands(commandContext.commandRegistry());
40+
String helpMessage = CommandUtils.formatAvailableCommands(commandContext.commandRegistry());
4141

4242
commandContext.terminal().writer().println(helpMessage);
4343
commandContext.terminal().flush();

spring-shell-core/src/main/java/org/springframework/shell/core/commands/Script.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ private void executeCommand(CommandContext commandContext, Input input) throws E
6060
String commandName = input.words().get(0);
6161
Command command = commandContext.commandRegistry().getCommandByName(commandName);
6262
if (command == null) {
63-
String availableCommands = CommandUtils.getAvailableCommands(commandContext.commandRegistry());
63+
String availableCommands = CommandUtils.formatAvailableCommands(commandContext.commandRegistry());
6464
throw new CommandNotFoundException("No command found for name: " + commandName + ". " + availableCommands);
6565
}
6666
CommandContext singleCommandContext = new CommandContext(commandContext.options(), commandContext.arguments(),

spring-shell-core/src/main/java/org/springframework/shell/core/jline/InteractiveShellRunner.java

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
package org.springframework.shell.core.jline;
1818

19-
import java.util.stream.Collectors;
19+
import java.util.List;
2020

2121
import org.apache.commons.logging.Log;
2222
import org.apache.commons.logging.LogFactory;
@@ -77,10 +77,35 @@ public void run(String[] args) throws Exception {
7777
}
7878
ParsedInput parsedInput = commandParser.parse(input);
7979
String commandName = parsedInput.commandName();
80+
if (!parsedInput.subCommands().isEmpty()) {
81+
commandName += " " + String.join(" ", parsedInput.subCommands());
82+
}
8083
Command command = this.commandRegistry.getCommandByName(commandName);
84+
// the user typed a non recognized command or a root command with subcommands
8185
if (command == null) {
82-
String availableCommands = CommandUtils.getAvailableCommands(this.commandRegistry);
83-
this.terminal.writer().println("No command found for name: " + commandName + ". " + availableCommands);
86+
// check if there are subcommands for the given root command
87+
List<String> candidateSubCommands = CommandUtils.getAvailableCommands(this.commandRegistry)
88+
.stream()
89+
.filter(candidateSubCommand -> candidateSubCommand.startsWith(parsedInput.commandName()))
90+
.toList();
91+
if (!candidateSubCommands.isEmpty() && parsedInput.subCommands().isEmpty()) {
92+
List<String> availableSubCommands = CommandUtils.getAvailableSubCommands(commandName,
93+
this.commandRegistry);
94+
this.terminal.writer().println("Available sub commands for " + commandName + ": ");
95+
for (String availableSubCommand : availableSubCommands) {
96+
this.terminal.writer()
97+
.println(" " + availableSubCommand + ": "
98+
+ this.commandRegistry.getCommandByName(commandName + " " + availableSubCommand)
99+
.getDescription());
100+
}
101+
}
102+
else {
103+
// the user typed an incorrect command, print general available
104+
// commands
105+
String availableCommands = CommandUtils.formatAvailableCommands(this.commandRegistry);
106+
this.terminal.writer()
107+
.println("No command found for name: " + commandName + ". " + availableCommands);
108+
}
84109
this.terminal.writer().flush();
85110
continue;
86111
}
@@ -101,12 +126,4 @@ public void setCommandParser(CommandParser commandParser) {
101126
this.commandParser = commandParser;
102127
}
103128

104-
private String getAvailableCommands() {
105-
return this.commandRegistry.getCommands()
106-
.stream()
107-
.map(Command::getName)
108-
.sorted()
109-
.collect(Collectors.joining(", "));
110-
}
111-
112129
}

spring-shell-core/src/main/java/org/springframework/shell/core/jline/NonInteractiveShellRunner.java

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
*/
1616
package org.springframework.shell.core.jline;
1717

18-
import java.util.stream.Collectors;
19-
2018
import org.jline.terminal.Terminal;
2119

2220
import org.springframework.shell.core.ShellRunner;
@@ -29,6 +27,7 @@
2927
* @author Janne Valkealahti
3028
* @author Chris Bono
3129
* @author Piotr Olaszewski
30+
* @author Mahmoud Ben Hassine
3231
*/
3332
public class NonInteractiveShellRunner implements ShellRunner {
3433

@@ -49,9 +48,13 @@ public NonInteractiveShellRunner(String primaryCommand, Terminal terminal, Comma
4948
@Override
5049
public void run(String[] args) throws Exception {
5150
ParsedInput parsedInput = commandParser.parse(() -> primaryCommand);
52-
Command command = commandRegistry.getCommandByName(parsedInput.commandName());
51+
String commandName = parsedInput.commandName();
52+
if (!parsedInput.subCommands().isEmpty()) {
53+
commandName += " " + String.join(" ", parsedInput.subCommands());
54+
}
55+
Command command = this.commandRegistry.getCommandByName(commandName);
5356
if (command == null) {
54-
String availableCommands = CommandUtils.getAvailableCommands(commandRegistry);
57+
String availableCommands = CommandUtils.formatAvailableCommands(commandRegistry);
5558
throw new CommandNotFoundException(
5659
"No command found for name: " + primaryCommand + ". " + availableCommands);
5760
}
@@ -66,14 +69,6 @@ public void run(String[] args) throws Exception {
6669
}
6770
}
6871

69-
private String getAvailableCommands() {
70-
return this.commandRegistry.getCommands()
71-
.stream()
72-
.map(Command::getName)
73-
.sorted()
74-
.collect(Collectors.joining(", "));
75-
}
76-
7772
public void setCommandParser(CommandParser commandParser) {
7873
this.commandParser = commandParser;
7974
}

spring-shell-core/src/main/java/org/springframework/shell/core/jline/ScriptShellRunner.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
* which are then interpreted as references to script files to run and exit.
3838
*
3939
* @author Eric Bottard
40+
* @author Mahmoud Ben Hassine
4041
*/
4142
// tag::documentation[]
4243
public class ScriptShellRunner implements ShellRunner {
@@ -98,9 +99,12 @@ private void execute(FileInputProvider inputProvider) throws Exception {
9899
}
99100
ParsedInput parsedInput = commandParser.parse(input);
100101
String commandName = parsedInput.commandName();
102+
if (!parsedInput.subCommands().isEmpty()) {
103+
commandName += " " + String.join(" ", parsedInput.subCommands());
104+
}
101105
Command command = this.commandRegistry.getCommandByName(commandName);
102106
if (command == null) {
103-
String availableCommands = CommandUtils.getAvailableCommands(this.commandRegistry);
107+
String availableCommands = CommandUtils.formatAvailableCommands(this.commandRegistry);
104108
throw new CommandNotFoundException(
105109
"No command found for name: " + commandName + ". " + availableCommands);
106110
}

spring-shell-core/src/main/java/org/springframework/shell/core/utils/CommandUtils.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,14 @@ public class CommandUtils {
4242
}
4343

4444
/**
45-
* Get a comma-separated string of available command names from the command registry.
45+
* Get a formatted string of available commands from the command registry.
4646
* @param commandRegistry the command registry
47-
* @return a string of available command names
47+
* @return a string of available commands with their descriptions
4848
*/
49-
public static String getAvailableCommands(CommandRegistry commandRegistry) {
49+
public static String formatAvailableCommands(CommandRegistry commandRegistry) {
5050
StringBuilder stringBuilder = new StringBuilder("Available commands: ");
5151
stringBuilder.append(System.lineSeparator());
52+
// TODO list commands by their groups
5253
for (Command command : commandRegistry.getCommands()) {
5354
stringBuilder.append("\t")
5455
.append(command.getName())
@@ -59,4 +60,28 @@ public static String getAvailableCommands(CommandRegistry commandRegistry) {
5960
return stringBuilder.toString();
6061
}
6162

63+
/**
64+
* Get a list of available commands from the command registry.
65+
* @param commandRegistry the command registry
66+
* @return a list of available commands
67+
*/
68+
public static List<String> getAvailableCommands(CommandRegistry commandRegistry) {
69+
return commandRegistry.getCommands().stream().map(Command::getName).toList();
70+
}
71+
72+
/**
73+
* Get a list of available sub-commands for a given command name from the command
74+
* registry.
75+
* @param commandName the name of the command
76+
* @param commandRegistry the command registry
77+
* @return a list of available sub-commands
78+
*/
79+
public static List<String> getAvailableSubCommands(String commandName, CommandRegistry commandRegistry) {
80+
return commandRegistry.getCommands()
81+
.stream()
82+
.filter(command -> command.getName().startsWith(commandName + " "))
83+
.map(command -> command.getName().substring(commandName.length()).trim())
84+
.toList();
85+
}
86+
6287
}

spring-shell-samples/spring-shell-sample-petclinic/src/main/java/org/springframework/shell/samples/petclinic/SpringShellApplication.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,16 @@
2424
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
2525
import org.springframework.shell.core.ShellRunner;
2626
import org.springframework.shell.core.command.annotation.EnableCommand;
27-
import org.springframework.shell.samples.petclinic.commands.OwnerDetailsCommand;
28-
import org.springframework.shell.samples.petclinic.commands.OwnersListCommand;
27+
import org.springframework.shell.samples.petclinic.commands.*;
2928

3029
/**
3130
* @author Mahmoud Ben Hassine
3231
*/
33-
@EnableCommand(SpringShellApplication.class)
32+
@EnableCommand({ SpringShellApplication.class, PetCommands.class })
3433
public class SpringShellApplication {
3534

3635
public static void main(String[] args) throws Exception {
37-
Class<?>[] classes = { SpringShellApplication.class };
36+
Class<?>[] classes = { SpringShellApplication.class, PetCommands.class };
3837
ApplicationContext context = new AnnotationConfigApplicationContext(classes);
3938
ShellRunner runner = context.getBean(ShellRunner.class);
4039
runner.run(args);
@@ -50,6 +49,16 @@ public OwnerDetailsCommand ownerDetailsCommand(JdbcClient jdbcClient) {
5049
return new OwnerDetailsCommand(jdbcClient);
5150
}
5251

52+
@Bean
53+
public VetsListCommand vetsCommand(JdbcClient jdbcClient) {
54+
return new VetsListCommand(jdbcClient);
55+
}
56+
57+
@Bean
58+
public VetDetailsCommand vetDetailsCommand(JdbcClient jdbcClient) {
59+
return new VetDetailsCommand(jdbcClient);
60+
}
61+
5362
@Bean
5463
public JdbcClient jdbcClient() {
5564
EmbeddedDatabase database = new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2)

spring-shell-samples/spring-shell-sample-petclinic/src/main/java/org/springframework/shell/samples/petclinic/commands/OwnerDetailsCommand.java

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717

1818
import java.io.PrintWriter;
1919

20+
import org.springframework.dao.EmptyResultDataAccessException;
2021
import org.springframework.jdbc.core.DataClassRowMapper;
2122
import org.springframework.jdbc.core.simple.JdbcClient;
2223
import org.springframework.shell.core.command.CommandContext;
24+
import org.springframework.shell.core.command.CommandOption;
2325
import org.springframework.shell.core.command.ExitStatus;
2426
import org.springframework.shell.core.commands.AbstractCommand;
2527
import org.springframework.shell.samples.petclinic.domain.Owner;
@@ -34,25 +36,49 @@ public class OwnerDetailsCommand extends AbstractCommand {
3436
private final JdbcClient jdbcClient;
3537

3638
public OwnerDetailsCommand(JdbcClient jdbcClient) {
37-
super("owner", "Show details of a given owner", "Pet Clinic", "Command to show the details of a given owner");
39+
super("owners info", "Show details of a given owner", "owners", "show the details of a given owner");
3840
this.jdbcClient = jdbcClient;
3941
}
4042

4143
@Override
4244
public ExitStatus doExecute(CommandContext commandContext) {
4345
PrintWriter writer = commandContext.terminal().writer();
44-
if (commandContext.arguments().isEmpty()) {
46+
if (commandContext.options().isEmpty()) {
4547
writer.println("Owner ID is required");
46-
writer.println("Usage: owner <ownerId>");
48+
writer.println("Usage: owners info --ownerId=<id>");
4749
writer.flush();
4850
return ExitStatus.USAGE_ERROR;
4951
}
50-
String ownerId = commandContext.arguments().get(0).value();
51-
Owner owner = this.jdbcClient.sql("SELECT * FROM OWNERS where id = " + ownerId)
52-
.query(new DataClassRowMapper<>(Owner.class))
53-
.single();
54-
writer.println(owner);
55-
writer.flush();
52+
CommandOption commandOption = commandContext.options().get(0);
53+
String longName = commandOption.longName();
54+
if (!"ownerId".equalsIgnoreCase(longName)) {
55+
writer.println("Unrecognized option: " + longName);
56+
writer.println("Usage: owners info --ownerId=<id>");
57+
writer.flush();
58+
return ExitStatus.USAGE_ERROR;
59+
}
60+
String ownerId = commandOption.value();
61+
try {
62+
Integer.parseInt(ownerId);
63+
}
64+
catch (NumberFormatException e) {
65+
writer.println("Invalid owner ID: " + ownerId + ". It must be a number.");
66+
writer.println("Usage: owners info --ownerId=<id>");
67+
writer.flush();
68+
return ExitStatus.USAGE_ERROR;
69+
}
70+
try {
71+
Owner owner = this.jdbcClient.sql("SELECT * FROM OWNERS where id = " + ownerId)
72+
.query(new DataClassRowMapper<>(Owner.class))
73+
.single();
74+
writer.println(owner);
75+
}
76+
catch (EmptyResultDataAccessException exception) {
77+
writer.println("No owner found with ID: " + ownerId);
78+
}
79+
finally {
80+
writer.flush();
81+
}
5682
return ExitStatus.OK;
5783
}
5884

0 commit comments

Comments
 (0)