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..446c4072f 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 @@ -16,6 +16,7 @@ package org.springframework.shell.core.autoconfigure; +import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; @@ -211,6 +212,12 @@ public Parser parser() { ExtendedDefaultParser parser = new ExtendedDefaultParser(); parser.setEofOnUnclosedQuote(true); parser.setEofOnEscapedNewLine(true); + // Backslash is both the DefaultParser escape character and the Windows path + // separator. Disable escapes on Windows so path tab-completion works (see + // gh-1169 / gh-240). + if (File.separatorChar == '\\') { + parser.setEscapeChars(null); + } return parser; } 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..5e3cb8bb1 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 @@ -1,5 +1,6 @@ package org.springframework.shell.jline; +import java.io.File; import java.io.IOException; import org.jline.reader.LineReader; @@ -53,6 +54,12 @@ public Parser parser() { ExtendedDefaultParser parser = new ExtendedDefaultParser(); parser.setEofOnUnclosedQuote(true); parser.setEofOnEscapedNewLine(true); + // Backslash is both the DefaultParser escape character and the Windows path + // separator. Disable escapes on Windows so path tab-completion works (see + // gh-1169 / gh-240). + if (File.separatorChar == '\\') { + parser.setEscapeChars(null); + } return parser; } diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/ExtendedDefaultParser.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/ExtendedDefaultParser.java index 046672bce..ed91f352d 100644 --- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/ExtendedDefaultParser.java +++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/ExtendedDefaultParser.java @@ -22,6 +22,7 @@ import java.util.Objects; import java.util.function.Predicate; +import org.jline.reader.CompletingParsedLine; import org.jline.reader.EOFError; import org.jline.reader.ParsedLine; import org.jline.reader.Parser; @@ -135,16 +136,20 @@ else if (!isEscapeChar(line, i)) { wordCursor = words.get(words.size() - 1).length(); } - if (eofOnEscapedNewLine && (line != null && isEscapeChar(line, line.length() - 1))) { - throw new EOFError(-1, -1, "Escaped new line", "newline"); - } - if (eofOnUnclosedQuote && quoteStart >= 0 && context != ParseContext.COMPLETE) { - throw new EOFError(-1, -1, "Missing closing quote", - (line != null && line.charAt(quoteStart) == '\'') ? "quote" : "dquote"); + // Match JLine DefaultParser: never treat incomplete lines as EOF during + // completion or split-line (e.g. Windows paths ending with '\'). + if (context != ParseContext.COMPLETE && context != ParseContext.SPLIT_LINE) { + if (eofOnEscapedNewLine && line != null && !line.isEmpty() && isEscapeChar(line, line.length() - 1)) { + throw new EOFError(-1, -1, "Escaped new line", "newline"); + } + if (eofOnUnclosedQuote && quoteStart >= 0) { + throw new EOFError(-1, -1, "Missing closing quote", + (line != null && line.charAt(quoteStart) == '\'') ? "quote" : "dquote"); + } } String openingQuote = (quoteStart >= 0 && line != null) ? line.substring(quoteStart, quoteStart + 1) : null; - return wrap(new ExtendedArgumentList(line, words, wordIndex, wordCursor, cursor, openingQuote)); + return new ExtendedArgumentList(line, words, wordIndex, wordCursor, cursor, openingQuote); } /** @@ -226,7 +231,7 @@ public boolean isDelimiterChar(CharSequence buffer, int pos) { * * @author Marc Prud'hommeaux */ - public class ExtendedArgumentList implements ParsedLine, CompletingParsedLine { + public class ExtendedArgumentList implements CompletingParsedLine { private final String line; @@ -279,37 +284,65 @@ public String line() { } @Override - public CharSequence emit(CharSequence candidate) { + public CharSequence escape(CharSequence candidate, boolean complete) { StringBuilder sb = new StringBuilder(candidate); Predicate needToBeEscaped; + String quote = openingQuote; // Completion is protected by an opening quote: // Delimiters (spaces) don't need to be escaped, nor do other quotes, but // everything else does. // Also, close the quote at the end - if (openingQuote != null) { - needToBeEscaped = i -> isRawEscapeChar(sb.charAt(i)) - || String.valueOf(sb.charAt(i)).equals(openingQuote); - } // No quote protection, need to escape everything: delimiter chars (spaces), - // quote chars - // and escapes themselves - else { - needToBeEscaped = i -> isDelimiterChar(sb, i) || isRawEscapeChar(sb.charAt(i)) - || isRawQuoteChar(sb.charAt(i)); + if (escapeChars != null && escapeChars.length > 0) { + if (openingQuote != null) { + needToBeEscaped = i -> isRawEscapeChar(sb.charAt(i)) + || String.valueOf(sb.charAt(i)).equals(openingQuote); + } + // No quote protection, need to escape everything: delimiter chars + // (spaces), quote chars and escapes themselves + else { + needToBeEscaped = i -> isDelimiterChar(sb, i) || isRawEscapeChar(sb.charAt(i)) + || isRawQuoteChar(sb.charAt(i)); + } + for (int i = 0; i < sb.length(); i++) { + if (needToBeEscaped.test(i)) { + sb.insert(i++, escapeChars[0]); + } + } } - for (int i = 0; i < sb.length(); i++) { - if (needToBeEscaped.test(i)) { - sb.insert(i++, escapeChars[0]); + else if (openingQuote == null) { + // Without escape characters, quote candidates that contain delimiters + for (int i = 0; i < sb.length(); i++) { + if (isDelimiterChar(sb, i)) { + quote = "'"; + break; + } } } - if (openingQuote != null) { - sb.append(openingQuote); + if (quote != null) { + sb.insert(0, quote); + if (complete) { + sb.append(quote); + } } return sb; } + @Override + public int rawWordCursor() { + return wordCursor(); + } + + @Override + public int rawWordLength() { + return word().length(); + } + } private boolean isRawEscapeChar(char key) { + if (escapeChars == null) { + return false; + } for (char e : escapeChars) { if (e == key) { return true; @@ -319,6 +352,9 @@ private boolean isRawEscapeChar(char key) { } private boolean isRawQuoteChar(char key) { + if (quoteChars == null) { + return false; + } for (char e : quoteChars) { if (e == key) { return true; @@ -327,70 +363,4 @@ private boolean isRawQuoteChar(char key) { return false; } - /** - * Another copy from JLine's {@link org.jline.reader.impl.LineReaderImpl} - * - * Used to wrap {@link org.jline.reader.ParsedLine} into - * {@link org.jline.reader.CompletingParsedLine} - */ - private static org.jline.reader.CompletingParsedLine wrap(ParsedLine line) { - if (line instanceof org.jline.reader.CompletingParsedLine) { - return (org.jline.reader.CompletingParsedLine) line; - } - else { - return new org.jline.reader.CompletingParsedLine() { - public String word() { - return line.word(); - } - - public int wordCursor() { - return line.wordCursor(); - } - - public int wordIndex() { - return line.wordIndex(); - } - - public List words() { - return line.words(); - } - - public String line() { - return line.line(); - } - - public int cursor() { - return line.cursor(); - } - - public CharSequence escape(CharSequence candidate, boolean complete) { - return candidate; - } - - public int rawWordCursor() { - return wordCursor(); - } - - public int rawWordLength() { - return word().length(); - } - }; - } - } - - /** - * An extension of {@link ParsedLine} that, being aware of the quoting and escaping - * rules of the {@link Parser} that produced it, knows if and how a completion - * candidate should be escaped/quoted. - * - * @author Eric Bottard - * @author Piotr Olaszewski - */ - @FunctionalInterface - interface CompletingParsedLine { - - CharSequence emit(CharSequence candidate); - - } - } diff --git a/spring-shell-jline/src/test/java/org/springframework/shell/jline/ExtendedDefaultParserTests.java b/spring-shell-jline/src/test/java/org/springframework/shell/jline/ExtendedDefaultParserTests.java index 154b70f05..c776958ea 100644 --- a/spring-shell-jline/src/test/java/org/springframework/shell/jline/ExtendedDefaultParserTests.java +++ b/spring-shell-jline/src/test/java/org/springframework/shell/jline/ExtendedDefaultParserTests.java @@ -17,12 +17,18 @@ import java.util.stream.Stream; +import org.jline.reader.CompletingParsedLine; +import org.jline.reader.EOFError; import org.jline.reader.ParsedLine; +import org.jline.reader.Parser; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class ExtendedDefaultParserTests { @@ -76,4 +82,45 @@ void testSpringExtendedDefaultParser(int cursor, int words, int wordIndex, int w assertThat(parse.wordCursor()).as("wordCursor").isEqualTo(wordCursor); } + @Test + void trailingBackslashDoesNotThrowOnCompleteWhenEofOnEscapedNewLine() { + // gh-1169 / gh-240: Windows path completion often ends with '\' + ExtendedDefaultParser parser = new ExtendedDefaultParser(); + parser.setEofOnEscapedNewLine(true); + String line = "pselect --path target\\"; + assertThatCode(() -> parser.parse(line, line.length(), Parser.ParseContext.COMPLETE)) + .doesNotThrowAnyException(); + } + + @Test + void trailingBackslashStillThrowsEofWhenAcceptingLine() { + ExtendedDefaultParser parser = new ExtendedDefaultParser(); + parser.setEofOnEscapedNewLine(true); + String line = "pselect --path target\\"; + assertThatThrownBy(() -> parser.parse(line, line.length(), Parser.ParseContext.ACCEPT_LINE)) + .isInstanceOf(EOFError.class) + .hasMessageContaining("Escaped new line"); + } + + @Test + void windowsStylePathsParseWithEscapesDisabled() { + // When escape chars are disabled (Windows configuration), backslashes are kept + // in words so multi-level path completion can continue. + ExtendedDefaultParser parser = new ExtendedDefaultParser(); + parser.setEofOnEscapedNewLine(true); + parser.setEscapeChars(null); + String line = "pselect --path target\\subdir\\"; + ParsedLine parsed = parser.parse(line, line.length(), Parser.ParseContext.COMPLETE); + assertThat(parsed.words()).containsExactly("pselect", "--path", "target\\subdir\\"); + assertThat(parsed.word()).isEqualTo("target\\subdir\\"); + } + + @Test + void parseResultImplementsCompletingParsedLine() { + ParsedLine parsed = springParser.parse("one two", 3, Parser.ParseContext.COMPLETE); + assertThat(parsed).isInstanceOf(CompletingParsedLine.class); + CompletingParsedLine completing = (CompletingParsedLine) parsed; + assertThat(completing.escape("a b", true).toString()).isEqualTo("a\\ b"); + } + }