diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java index 04a002cc4..c5cfdcd13 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java @@ -77,6 +77,58 @@ public DefaultLexer(CommandModel commandModel, ParserConfig config) { private record ArgumentsSplit(List before, List after) { } + + /** + * Loops over a list of arguments and if any of the arguments would follow the pattern ".*=.*" + * it splits those arguments. + * @param arguments the original arguments list + * @return List containing the arguments without key-value separator + */ + private static List splitKeyAndValueBySeparator(List arguments) { + if(arguments != null){ + if(arguments.isEmpty()){ + return arguments; + }else{ + List result = new ArrayList<>(); + + for (String argument : arguments) { + if (argument == null) { + result.add(null); + } else if (argument.isEmpty()) { + result.add(""); + } else if (argument.matches(".*=.*")) { + String[] parts = argument.split("=", 2); + result.add(parts[0].trim()); + result.add(parts[1].trim()); + } else { + result.add(argument); // Add non-key-value arguments as-is + } + } + return result; + } + }else{ + return null; + } + + } + + /** + * Returns the argument split with the before and after list. In case isKeyValueSeparatorEnabled is true, it will however first separate the keys from values, adding + * each as an argument to the list so the tokenize method can work with this them as previously implemented. + * @param before before list + * @param after after list + * @param isKeyValueSeparatorEnabled true if key=value is allowed, otherwise not. + * @return ArgumentsSplit + */ + private ArgumentsSplit prepareArgumentSplitWithoutKeyValueSeparator(List before, List after, boolean isKeyValueSeparatorEnabled){ + if(isKeyValueSeparatorEnabled){ + return new ArgumentsSplit(splitKeyAndValueBySeparator(before), splitKeyAndValueBySeparator(after)); + }else{ + return new ArgumentsSplit(before, after); + } + } + + /** * Splits arguments from a point first valid command is found, where * {@code before} is everything before commands and {@code after} what's @@ -100,11 +152,12 @@ private ArgumentsSplit splitArguments(List arguments, Map } else if (i == 0) { if (foundSplit) { - return new ArgumentsSplit(Collections.emptyList(), arguments); + return prepareArgumentSplitWithoutKeyValueSeparator(Collections.emptyList(), arguments, config.isEnabled(Feature.ALLOW_KEY_VALUE_SEPARATOR)); } - return new ArgumentsSplit(arguments, Collections.emptyList()); + return prepareArgumentSplitWithoutKeyValueSeparator(arguments, Collections.emptyList(), config.isEnabled(Feature.ALLOW_KEY_VALUE_SEPARATOR)); } - return new ArgumentsSplit(arguments.subList(0, i), arguments.subList(i, arguments.size())); + + return prepareArgumentSplitWithoutKeyValueSeparator(arguments.subList(0, i), arguments.subList(i, arguments.size()), config.isEnabled(Feature.ALLOW_KEY_VALUE_SEPARATOR)); } private List extractDirectives(List arguments) { diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java index 3ecec5090..9a96e609d 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java @@ -62,8 +62,12 @@ public static enum Feature { /** * Defines if options are parsed using case-sensitivity, enabled on default. */ - CASE_SENSITIVE_OPTIONS(true) - ; + CASE_SENSITIVE_OPTIONS(true), + + + ALLOW_KEY_VALUE_SEPARATOR(true); + + private final boolean defaultState; private final long mask; diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java index 5ec3a1759..d2c696a49 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java @@ -79,6 +79,23 @@ void commandArgumentsGetsAddedAfterDoubleDash() { } ); } + + @Test + void commandArgumentsGetsAddedAfterDoubleDashOptionValueSeparatedByEqualSign() { + register(ROOT3); + ParseResult result = parse("root3", "--arg1=value1", "--", "arg1", "arg2"); + + assertThat(result.argumentResults()).satisfiesExactly( + ar -> { + assertThat(ar.value()).isEqualTo("arg1"); + assertThat(ar.position()).isEqualTo(0); + }, + ar -> { + assertThat(ar.value()).isEqualTo("arg2"); + assertThat(ar.position()).isEqualTo(1); + } + ); + } } @Nested @@ -95,6 +112,17 @@ void optionValueShouldBeInteger() { assertThat(result.messageResults()).isEmpty(); } + @Test + void optionValueShouldBeIntegerOptionValueSeparatedByEqualSign() { + register(ROOT6_OPTION_INT); + ParseResult result = parse("root6", "--arg1=1"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults().get(0).value()).isEqualTo(1); + assertThat(result.messageResults()).isEmpty(); + } + @Test void optionValueShouldBeIntegerArray() { register(ROOT6_OPTION_INTARRAY); @@ -106,6 +134,18 @@ void optionValueShouldBeIntegerArray() { assertThat(result.messageResults()).isEmpty(); } + @Test + void optionValueShouldBeIntegerArrayOptionValueSeparatedByEqualSign() { + register(ROOT6_OPTION_INTARRAY); + //TODO: is this how we want to indicate arrays after the equal sign + ParseResult result = parse("root6", "--arg1=1", "2"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults().get(0).value()).isEqualTo(new int[] { 1, 2 }); + assertThat(result.messageResults()).isEmpty(); + } + @Test void optionValueFailsFromStringToInteger() { register(ROOT6_OPTION_INT); @@ -121,6 +161,21 @@ void optionValueFailsFromStringToInteger() { }); } + @Test + void optionValueFailsFromStringToIntegerOptionValueSeparatedByEqualSign() { + register(ROOT6_OPTION_INT); + ParseResult result = parse("root6", "--arg1=x"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults().get(0).value()).isEqualTo("x"); + assertThat(result.messageResults()).isNotEmpty(); + assertThat(result.messageResults()).satisfiesExactly(ms -> { + // "2002E:(pos 0): Illegal option value 'x', reason 'Failed to convert from type [java.lang.String] to type [int] for value 'x''" + assertThat(ms.getMessage()).contains("Failed to convert"); + }); + } + @Test void optionValueShouldBeNegativeInteger() { register(ROOT6_OPTION_INT); @@ -132,6 +187,17 @@ void optionValueShouldBeNegativeInteger() { assertThat(result.messageResults()).isEmpty(); } + @Test + void optionValueShouldBeNegativeIntegerOptionValueSeparatedByEqualSign() { + register(ROOT6_OPTION_INT); + ParseResult result = parse("root6", "--arg1=-1"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults().get(0).value()).isEqualTo(-1); + assertThat(result.messageResults()).isEmpty(); + } + } @Nested @@ -312,6 +378,26 @@ void shouldFindTwoLongOptionArgument() { ); assertThat(result.messageResults()).isEmpty(); } + + @Test + void shouldFindTwoLongOptionArgumentOptionValueSeparatedByEqualSign() { + register(ROOT3_OPTION_ARG1_ARG2); + ParseResult result = parse("root3", "--arg1=value1", "--arg2=value2"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults()).satisfiesExactly( + r -> { + assertThat(r.option().getLongNames()).isEqualTo(new String[] { "arg1" }); + assertThat(r.value()).isEqualTo("value1"); + }, + r -> { + assertThat(r.option().getLongNames()).isEqualTo(new String[] { "arg2" }); + assertThat(r.value()).isEqualTo("value2"); + } + ); + assertThat(result.messageResults()).isEmpty(); + } } @Nested @@ -349,6 +435,22 @@ void shouldFindShortOptionWithArg() { assertThat(result.messageResults()).isEmpty(); } + @Test + void shouldFindShortOptionWithOptionValueSeparatedByEqualSign() { + register(ROOT3_SHORT_OPTION_A); + ParseResult result = parse("root3", "-a=aaa"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults()).satisfiesExactly( + r -> { + assertThat(r.option().getShortNames()).isEqualTo(new Character[] { 'a' }); + assertThat(r.value()).isEqualTo("aaa"); + } + ); + assertThat(result.messageResults()).isEmpty(); + } + @Test void shouldFindShortOptions() { register(ROOT3_SHORT_OPTION_A_B); @@ -369,6 +471,26 @@ void shouldFindShortOptions() { assertThat(result.messageResults()).isEmpty(); } + @Test + void shouldFindShortOptionsWithOptionValueSeparatedByEqualSign() { + register(ROOT3_SHORT_OPTION_A_B); + ParseResult result = parse("root3", "-a=aaa", "-b=bbb"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults()).satisfiesExactly( + r -> { + assertThat(r.option().getShortNames()).isEqualTo(new Character[] { 'a' }); + assertThat(r.value()).isEqualTo("aaa"); + }, + r -> { + assertThat(r.option().getShortNames()).isEqualTo(new Character[] { 'b' }); + assertThat(r.value()).isEqualTo("bbb"); + } + ); + assertThat(result.messageResults()).isEmpty(); + } + @Test void shouldFindShortOptionsRequired() { register(ROOT3_SHORT_OPTION_A_B_REQUIRED); @@ -379,6 +501,16 @@ void shouldFindShortOptionsRequired() { assertThat(result.messageResults()).isEmpty(); } + @Test + void shouldFindShortOptionsRequiredWithOptionValueSeparatedByEqualSign() { + register(ROOT3_SHORT_OPTION_A_B_REQUIRED); + ParseResult result = parse("root3", "-a=aaa", "-b=bbb"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.messageResults()).isEmpty(); + } + } @Nested @@ -439,6 +571,25 @@ void shouldFindLongOptionRegLowerCommandUpper() { assertThat(result.messageResults()).isEmpty(); } + @Test + void shouldFindLongOptionRegLowerCommandUpperWithOptionValueSeparatedByEqualSign() { + register(ROOT3); + ParserConfig config = new ParserConfig() + .disable(Feature.CASE_SENSITIVE_COMMANDS) + .disable(Feature.CASE_SENSITIVE_OPTIONS) + ; + ParseResult result = parse(config, "root3", "--Arg1=value1"); + assertThat(result).isNotNull(); + assertThat(result.commandRegistration()).isNotNull(); + assertThat(result.optionResults()).isNotEmpty(); + assertThat(result.optionResults()).satisfiesExactly( + r -> { + assertThat(r.value()).isEqualTo("value1"); + } + ); + assertThat(result.messageResults()).isEmpty(); + } + } @Nested @@ -611,6 +762,31 @@ void positionOverridesDefaultsKeepsDefaultWhenOption() { ); } + @Test + void positionOverridesDefaultsKeepsDefaultWhenOptionWithOptionValueSeparatedByEqualSign() { + register(ROOT7_POSITIONAL_TWO_ARG_STRING_DEFAULT); + ParseResult result = parse("root7", "--arg1=a", "b"); + assertThat(result.messageResults()).isEmpty(); + assertThat(result.argumentResults()).satisfiesExactly( + r -> { + assertThat(r.value()).isEqualTo("b"); + assertThat(r.position()).isEqualTo(0); + } + ); + assertThat(result.optionResults()).isNotNull().satisfiesExactly( + r -> { + assertThat(r.option().getLongNames()).isEqualTo(new String[] { "arg1" }); + assertThat(r.value()).isEqualTo("a"); + }, + r -> { + assertThat(r.option().getLongNames()).isEqualTo(new String[] { "arg2" }); + assertThat(r.value()).isEqualTo("b"); + } + ); + } + + + @Test void positionWithLastHavingNoDefault() { register(ROOT7_POSITIONAL_TWO_ARG_STRING_DEFAULT_ONE_NODEFAULT);