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