diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java index e07c2567c..9c42162b0 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java @@ -16,6 +16,7 @@ package org.springframework.shell.core.command; import org.jspecify.annotations.Nullable; +import org.springframework.util.StringUtils; /** * Record representing the definition as well as the runtime information about a command @@ -24,10 +25,16 @@ * @author Janne Valkealahti * @author Piotr Olaszewski * @author Mahmoud Ben Hassine + * @author David Pilar */ public record CommandOption(char shortName, @Nullable String longName, @Nullable String description, @Nullable Boolean required, @Nullable String defaultValue, @Nullable String value, Class type) { + public boolean isOptionEqual(String optionName) { + return StringUtils.hasLength(longName) && optionName.equals("--" + longName) + || shortName != ' ' && optionName.equals("-" + shortName); + } + public static Builder with() { return new Builder(); } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java index c02e6f238..cd8088800 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java @@ -17,6 +17,7 @@ import java.util.Collections; import java.util.List; +import java.util.Optional; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -102,11 +103,16 @@ public ParsedInput parse(String input) { } else { // use next word as option value if (nextWord == null || isOption(nextWord) || isArgumentSeparator(nextWord)) { - throw new IllegalArgumentException("Option '" + currentWord + "' requires a value"); + if (!isBooleanOption(commandName, currentWord)) { + throw new IllegalArgumentException("Option '" + currentWord + "' requires a value"); + } + nextWord = "true"; + } + else { + i++; // skip next word as it was used as option value } CommandOption commandOption = parseOption(currentWord + "=" + nextWord); parsedInputBuilder.addOption(commandOption); - i++; // skip next word as it was used as option value } } else { @@ -171,4 +177,13 @@ private String unquoteAndUnescapeQuoted(String s) { return s; } + private boolean isBooleanOption(String commandName, String currentWord) { + return Optional.ofNullable(commandRegistry.getCommandByName(commandName)) + .map(Command::getOptions) + .orElse(List.of()) + .stream() + .filter(o -> o.isOptionEqual(currentWord)) + .anyMatch(o -> o.type() == boolean.class || o.type() == Boolean.class); + } + } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java index 448f6113f..e8cebf30e 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java @@ -269,6 +269,51 @@ static Stream parseWithQuotedArgumentData() { Arguments.of("mycommand -- value", "value"), Arguments.of("mycommand -- \"value\"", "value")); } + @ParameterizedTest + @MethodSource("parseWithBooleanOptionData") + void testParseWithBooleanOption(String input, String longName, char shortName, Class type, + String expectedValue) { + // given + Command command = createCommand("mycommand", "My test command"); + command.getOptions().add(CommandOption.with().longName(longName).shortName(shortName).type(type).build()); + commandRegistry.registerCommand(command); + // when + ParsedInput parsedInput = parser.parse(input); + + // then + assertEquals("mycommand", parsedInput.commandName()); + assertEquals(1, parsedInput.options().size()); + assertEquals(longName, parsedInput.options().get(0).longName()); + assertEquals(shortName, parsedInput.options().get(0).shortName()); + assertEquals(expectedValue, parsedInput.options().get(0).value()); + } + + static Stream parseWithBooleanOptionData() { + return Stream.of(Arguments.of("mycommand --option=true", "option", ' ', boolean.class, "true"), + Arguments.of("mycommand --option=false", "option", ' ', boolean.class, "false"), + Arguments.of("mycommand --option true", "option", ' ', boolean.class, "true"), + Arguments.of("mycommand --option false", "option", ' ', boolean.class, "false"), + Arguments.of("mycommand --option", "option", ' ', boolean.class, "true"), + + Arguments.of("mycommand -on=true", "", 'o', boolean.class, "true"), + Arguments.of("mycommand -o=false", "", 'o', boolean.class, "false"), + Arguments.of("mycommand -o true", "", 'o', boolean.class, "true"), + Arguments.of("mycommand -o false", "", 'o', boolean.class, "false"), + Arguments.of("mycommand -o", "", 'o', boolean.class, "true"), + + Arguments.of("mycommand --option=true", "option", ' ', Boolean.class, "true"), + Arguments.of("mycommand --option=false", "option", ' ', Boolean.class, "false"), + Arguments.of("mycommand --option true", "option", ' ', Boolean.class, "true"), + Arguments.of("mycommand --option false", "option", ' ', Boolean.class, "false"), + Arguments.of("mycommand --option", "option", ' ', Boolean.class, "true"), + + Arguments.of("mycommand -on=true", "", 'o', Boolean.class, "true"), + Arguments.of("mycommand -o=false", "", 'o', Boolean.class, "false"), + Arguments.of("mycommand -o true", "", 'o', Boolean.class, "true"), + Arguments.of("mycommand -o false", "", 'o', Boolean.class, "false"), + Arguments.of("mycommand -o", "", 'o', Boolean.class, "true")); + } + private static Command createCommand(String name, String description) { return new AbstractCommand(name, description) { @Override diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java index 4ab918bf3..c5a9d5dbc 100644 --- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java +++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java @@ -116,7 +116,7 @@ private boolean isOptionPresent(ParsedLine line, CommandOption option) { CommandOption option; if (reversed.get(0).isEmpty()) { // the option name was completed, but no value provided ---> "--optionName " - option = findOption(options, o -> isOptionEqual(reversed.get(1), o)); + option = findOption(options, o -> o.isOptionEqual(reversed.get(1))); } else { // the option uses key-value pair ---> "--optionName=someValue" @@ -124,7 +124,7 @@ private boolean isOptionPresent(ParsedLine line, CommandOption option) { // the option uses completion on the value level ---> "--optionName someValue" if (option == null) { - option = findOption(options, o -> isOptionEqual(reversed.get(1), o)); + option = findOption(options, o -> o.isOptionEqual(reversed.get(1))); } } @@ -135,13 +135,8 @@ private boolean isOptionPresent(ParsedLine line, CommandOption option) { return options.stream().filter(optionFilter).findFirst().orElse(null); } - private static boolean isOptionEqual(String optionName, CommandOption option) { - return option.longName() != null && optionName.equals("--" + option.longName()) - || option.shortName() != ' ' && optionName.equals("-" + option.shortName()); - } - private static boolean isOptionStartWith(String optionName, CommandOption option) { - return option.longName() != null && optionName.startsWith("--" + option.longName() + "=") + return StringUtils.hasLength(option.longName()) && optionName.startsWith("--" + option.longName() + "=") || option.shortName() != ' ' && optionName.startsWith("-" + option.shortName() + "="); }