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 @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.springframework.shell.jline;

import java.io.File;
import java.io.IOException;

import org.jline.reader.LineReader;
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -226,7 +231,7 @@ public boolean isDelimiterChar(CharSequence buffer, int pos) {
*
* @author <a href="mailto:mwp1@cornell.edu">Marc Prud'hommeaux</a>
*/
public class ExtendedArgumentList implements ParsedLine, CompletingParsedLine {
public class ExtendedArgumentList implements CompletingParsedLine {

private final String line;

Expand Down Expand Up @@ -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<Integer> 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;
Expand All @@ -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;
Expand All @@ -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<String> 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);

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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");
}

}