Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. <pre>
* 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}. <pre>
* CommandSyntax ::= CommandName [SubCommandName]* [Option | Argument]*
* CommandName ::= String
* SubCommandName ::= String
Expand Down Expand Up @@ -55,7 +59,10 @@ public DefaultCommandParser(CommandRegistry commandRegistry) {
@Override
public ParsedInput parse(String input) {
log.debug("Parsing input: " + input);
List<String> words = List.of(input.split("\\s+(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"));
List<String> words = tokenize(input);
if (words.isEmpty()) {
words = List.of("");
}

// the first word is the (root) command name
String commandName = words.get(0);
Expand Down Expand Up @@ -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<String> tokenize(String input) {
List<String> 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("--");
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,108 @@ static Stream<Arguments> 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<Arguments> 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<Arguments> 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,
Expand Down
41 changes: 41 additions & 0 deletions spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading