diff --git a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java index a4039fb72..ccd743c00 100644 --- a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java +++ b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java @@ -132,7 +132,10 @@ public LineReader lineReader(Terminal terminal, Parser parser, CommandCompleter .completer(commandCompleter) .history(jLineHistory) .highlighter(commandHighlighter) - .parser(parser); + .parser(parser) + // keep backslashes in the returned line, escapes are resolved by the + // command parser + .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true); LineReader lineReader = lineReaderBuilder.build(); if (this.springShellProperties.getHistory().isEnabled()) { 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 dd1a63ce7..a837aa1e8 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 @@ -18,6 +18,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -26,7 +27,10 @@ * Default implementation of {@link CommandParser}. Supports options in the long form of * --key=value or --key value as well in the short form of -k=value or -k value. Options * and arguments can be specified in any order. Arguments are 0-based indexed among other - * arguments.
+ * arguments. Option values and arguments can be quoted with double or single quotes to
+ * include whitespace, and quote characters can be escaped with a backslash inside quoted
+ * values (e.g. greet "she said \" and left"). Unbalanced quotes are rejected with an
+ * {@link IllegalArgumentException}.
* CommandSyntax ::= CommandName [SubCommandName]* [Option | Argument]*
* CommandName ::= String
* SubCommandName ::= String
@@ -55,7 +59,10 @@ public DefaultCommandParser(CommandRegistry commandRegistry) {
@Override
public ParsedInput parse(String input) {
log.debug("Parsing input: " + input);
- List words = List.of(input.split("\\s+(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"));
+ List words = tokenize(input);
+ if (words.isEmpty()) {
+ words = List.of("");
+ }
// the first word is the (root) command name
String commandName = words.get(0);
@@ -143,6 +150,56 @@ else if (isBooleanOption(fullCommandName, currentWord) && !isBooleanValue(nextWo
return parsedInput;
}
+ /**
+ * Split the input into words on whitespace, keeping quoted sections together. This is
+ * a character-by-character state machine tracking the currently open quote (none,
+ * double or single). A backslash makes the following character part of the current
+ * word without any special meaning, so an escaped quote neither opens nor closes a
+ * quoted section. Words are kept verbatim (quotes and escape sequences included),
+ * quote removal and escape resolution happen later in
+ * {@link #unquoteAndUnescapeQuoted(String)}.
+ * @param input the raw input line
+ * @return the list of words
+ * @throws IllegalArgumentException if the input contains an unbalanced quote
+ */
+ private List tokenize(String input) {
+ List words = new ArrayList<>();
+ StringBuilder currentWord = new StringBuilder();
+ char openingQuote = 0;
+ for (int i = 0; i < input.length(); i++) {
+ char currentChar = input.charAt(i);
+ if (currentChar == '\\' && i + 1 < input.length()) {
+ currentWord.append(currentChar).append(input.charAt(++i));
+ }
+ else if (openingQuote != 0) {
+ currentWord.append(currentChar);
+ if (currentChar == openingQuote) {
+ openingQuote = 0;
+ }
+ }
+ else if (currentChar == '"' || currentChar == '\'') {
+ openingQuote = currentChar;
+ currentWord.append(currentChar);
+ }
+ else if (Character.isWhitespace(currentChar)) {
+ if (!currentWord.isEmpty()) {
+ words.add(currentWord.toString());
+ currentWord.setLength(0);
+ }
+ }
+ else {
+ currentWord.append(currentChar);
+ }
+ }
+ if (openingQuote != 0) {
+ throw new IllegalArgumentException("Unbalanced quote (" + openingQuote + ") in input: " + input);
+ }
+ if (!currentWord.isEmpty()) {
+ words.add(currentWord.toString());
+ }
+ return words;
+ }
+
// Check if the word is the argument separator, ie empty "--" (POSIX style)
private boolean isArgumentSeparator(String word) {
return word.equals("--");
@@ -185,11 +242,14 @@ private CommandArgument parseArgument(int index, String word) {
private String unquoteAndUnescapeQuoted(String s) {
// only process quoted strings
- if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) {
- s = s.substring(1, s.length() - 1);
+ if (s.length() >= 2) {
+ char quote = s.charAt(0);
+ if ((quote == '"' || quote == '\'') && s.charAt(s.length() - 1) == quote) {
+ s = s.substring(1, s.length() - 1);
- // unescape only inside quoted strings
- s = s.replace("\\\"", "\"").replace("\\\\", "\\");
+ // unescape only inside quoted strings
+ s = s.replace("\\" + quote, String.valueOf(quote)).replace("\\\\", "\\");
+ }
}
return s;
}
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 4a64eba36..dcd74b535 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
@@ -270,6 +270,108 @@ static Stream parseWithQuotedArgumentData() {
Arguments.of("mycommand -- value", "value"), Arguments.of("mycommand -- \"value\"", "value"));
}
+ @ParameterizedTest
+ @MethodSource("parseWithEscapedQuotedArgumentData")
+ void testParseWithEscapedQuotedArgument(String input, String expectedValue) {
+ // when
+ ParsedInput parsedInput = parser.parse(input);
+
+ // then
+ assertEquals("mycommand", parsedInput.commandName());
+ assertEquals(1, parsedInput.arguments().size());
+ assertEquals(expectedValue, parsedInput.arguments().get(0).value());
+ }
+
+ static Stream parseWithEscapedQuotedArgumentData() {
+ return Stream.of(
+ // escaped quote inside a double quoted argument (odd number of escaped
+ // quotes)
+ Arguments.of("mycommand \"she said \\\" and left\"", "she said \" and left"),
+ Arguments.of("mycommand \"a \\\" b \\\" c \\\" d\"", "a \" b \" c \" d"),
+ Arguments.of("mycommand \"it's here\"", "it's here"),
+ // single quoted arguments group words
+ Arguments.of("mycommand 'value1 value2'", "value1 value2"),
+ Arguments.of("mycommand 'she said \" and left'", "she said \" and left"),
+ Arguments.of("mycommand 'don\\'t stop'", "don't stop"),
+ // escaped backslash inside a quoted argument
+ Arguments.of("mycommand \"a\\\\b\"", "a\\b"),
+ // empty quoted argument
+ Arguments.of("mycommand \"\"", ""), Arguments.of("mycommand ''", ""),
+ // quoted words are not treated as options
+ Arguments.of("mycommand \"--option=value\"", "--option=value"),
+ // trailing backslash is kept as-is
+ Arguments.of("mycommand arg\\", "arg\\"),
+ // any whitespace separates words
+ Arguments.of("mycommand\t\"value1 value2\"", "value1 value2"));
+ }
+
+ @Test
+ void testParseWithMultipleQuotedArguments() {
+ // when
+ ParsedInput parsedInput = parser.parse("mycommand \"value1 value2\" 'value3 value4'");
+
+ // then
+ assertEquals("mycommand", parsedInput.commandName());
+ assertEquals(2, parsedInput.arguments().size());
+ assertEquals("value1 value2", parsedInput.arguments().get(0).value());
+ assertEquals("value3 value4", parsedInput.arguments().get(1).value());
+ }
+
+ @Test
+ void testParseWithQuotedOptionValueFollowedByArgument() {
+ // when
+ ParsedInput parsedInput = parser.parse("mycommand --option \"value1 value2\" arg1");
+
+ // then
+ assertEquals("mycommand", parsedInput.commandName());
+ assertEquals(1, parsedInput.options().size());
+ assertEquals("value1 value2", parsedInput.options().get(0).value());
+ assertEquals(1, parsedInput.arguments().size());
+ assertEquals("arg1", parsedInput.arguments().get(0).value());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "", " " })
+ void testParseEmptyInput(String input) {
+ // when
+ ParsedInput parsedInput = parser.parse(input);
+
+ // then
+ assertEquals("", parsedInput.commandName());
+ assertEquals(0, parsedInput.options().size());
+ assertEquals(0, parsedInput.arguments().size());
+ }
+
+ @ParameterizedTest
+ @MethodSource("parseWithEscapedQuotedOptionData")
+ void testParseWithEscapedQuotedOption(String input, String expectedValue) {
+ // when
+ ParsedInput parsedInput = parser.parse(input);
+
+ // then
+ assertEquals("mycommand", parsedInput.commandName());
+ assertEquals(1, parsedInput.options().size());
+ assertEquals("option", parsedInput.options().get(0).longName());
+ assertEquals(expectedValue, parsedInput.options().get(0).value());
+ }
+
+ static Stream parseWithEscapedQuotedOptionData() {
+ return Stream.of(Arguments.of("mycommand --option=\"she said \\\" and left\"", "she said \" and left"),
+ Arguments.of("mycommand --option \"she said \\\" and left\"", "she said \" and left"),
+ Arguments.of("mycommand --option='value1 value2'", "value1 value2"),
+ Arguments.of("mycommand --option 'value1 value2'", "value1 value2"),
+ Arguments.of("mycommand --option=\"\"", ""), Arguments.of("mycommand --option=''", ""));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "mycommand \"unbalanced value", "mycommand 'unbalanced value",
+ "mycommand --option=\"unbalanced value", "mycommand don't" })
+ void testParseWithUnbalancedQuotes(String input) {
+ IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> parser.parse(input));
+ Assertions.assertTrue(exception.getMessage().contains("quote"));
+ }
+
@ParameterizedTest
@MethodSource("parseWithBooleanOptionData")
void testParseWithBooleanOption(String input, String commandName, String longName, char shortName, Class> type,
diff --git a/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc b/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
index da8156dc5..de30163bd 100644
--- a/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
+++ b/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
@@ -61,6 +61,47 @@ IMPORTANT: When an option is specified, it is always expected to have a value fo
TIP: To avoid ambiguity, named options should be preferred over positional arguments whenever possible, especially when subcommands are involved (see https://clig.dev/#arguments-and-flags[Command Line Interface Guidelines]).
+== Quoting and escaping
+
+The input is split into words on whitespace. To provide an option value or an argument that contains whitespace,
+the value needs to be quoted. Both single (`'`) and double (`"`) quotes are supported, and the enclosing quotes
+will not be part of the value:
+
+[source,shell]
+----
+$>mycommand --message='Hello World' <1>
+$>mycommand --message="Hello World" <2>
+$>mycommand "arg1 with spaces" <3>
+----
+<1> option value is `Hello World`
+<2> option value is `Hello World`
+<3> argument value is `arg1 with spaces`
+
+Supporting both types of quotes allows one type of quote to be embedded in a value quoted with the other type:
+
+[source,shell]
+----
+$>mycommand --message="I'm here!" <1>
+$>mycommand --message='He said "Hi!"' <2>
+----
+<1> option value is `I'm here!`
+<2> option value is `He said "Hi!"`
+
+To embed the same kind of quote that was used to quote the whole value, escape it with the backslash (`\`)
+character. A literal backslash inside a quoted value can be escaped as `\\`:
+
+[source,shell]
+----
+$>mycommand --message="He said \"Hi!\"" <1>
+$>mycommand --message='I\'m here!' <2>
+----
+<1> option value is `He said "Hi!"`
+<2> option value is `I'm here!`
+
+Quotes are only removed when the whole value is enclosed in them. Quote characters in the middle of a value
+are kept as-is (e.g., `value1"inside"value2` stays unchanged), and escape sequences are only resolved inside
+quoted values. An input with an unbalanced quote is rejected with an error.
+
== Customizing parsing rules
Spring Shell 4 provides a new API called `CommandParser` that allows you to customize the command parsing rules.
diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java
index 8159a471d..e6107016f 100644
--- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java
+++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java
@@ -30,7 +30,14 @@ public JLineInputProvider inputProvider(LineReader lineReader) {
@Bean
public LineReader lineReader(Terminal terminal, Parser parser, CommandCompleter commandCompleter) {
- return LineReaderBuilder.builder().terminal(terminal).completer(commandCompleter).parser(parser).build();
+ return LineReaderBuilder.builder()
+ .terminal(terminal)
+ .completer(commandCompleter)
+ .parser(parser)
+ // keep backslashes in the returned line, escapes are resolved by the
+ // command parser
+ .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true)
+ .build();
}
@Bean