SHL-116: Rewrote tokenizer to allow quotes escaping
* Allow unmatched quotes if at the end 30e34da977289ed52221ae0b4e9f5eabdf392a60 * Allow trailing key without an option b6814870a28f5a4bc9509556a847acec5db10523 * Allow completions in unmatched quotes f93bbc7d44461595eb299797a36bd10bc23b83f9 * Fix case where parser would think it is at end but in quotes is intended for it (as a option marker) a2a710463761668c67810448bd6803ce67bba35e * Fix corner case of empty quoted string 4fdcfbf2e2cbbee9a7a624735be81ed98fc67ed6 * Handle dangling dash dash at the end 40dd0bbc867b45c8cb8c37a82d5afe93ccbcb960 * Add author tag 9e3a6e0c0964d39e51f6496c97bfdf74c153bd9d
This commit is contained in:
@@ -26,20 +26,23 @@ import org.springframework.util.StringUtils;
|
||||
* A method that can be executed via a shell command.
|
||||
* <p>
|
||||
* Immutable since 1.2.0.
|
||||
*
|
||||
*
|
||||
* @author Ben Alex
|
||||
*/
|
||||
public class MethodTarget {
|
||||
|
||||
// Fields
|
||||
private final Method method;
|
||||
|
||||
private final Object target;
|
||||
|
||||
private final String remainingBuffer;
|
||||
|
||||
private final String key;
|
||||
|
||||
/**
|
||||
* Constructor for a <code>null remainingBuffer</code> and <code>key</code>
|
||||
*
|
||||
*
|
||||
* @param method the method to invoke (required)
|
||||
* @param target the object on which the method is to be invoked (required)
|
||||
* @since 1.2.0
|
||||
@@ -50,7 +53,7 @@ public class MethodTarget {
|
||||
|
||||
/**
|
||||
* Constructor that allows all fields to be set
|
||||
*
|
||||
*
|
||||
* @param method the method to invoke (required)
|
||||
* @param target the object on which the method is to be invoked (required)
|
||||
* @param remainingBuffer can be blank
|
||||
@@ -62,7 +65,7 @@ public class MethodTarget {
|
||||
Assert.notNull(target, "Target is required");
|
||||
this.key = StringUtils.trimWhitespace(key);
|
||||
this.method = method;
|
||||
this.remainingBuffer = StringUtils.trimWhitespace(remainingBuffer);
|
||||
this.remainingBuffer = remainingBuffer;
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
|
||||
@@ -573,15 +573,8 @@ public class SimpleParser implements Parser {
|
||||
Assert.notNull(cmd, "CliCommand unavailable for '" + methodTarget.getMethod().toGenericString() + "'");
|
||||
|
||||
// Make a reasonable attempt at parsing the remainingBuffer
|
||||
Map<String, String> options;
|
||||
try {
|
||||
options = new Tokenizer(methodTarget.getRemainingBuffer()).getTokens();
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Assume any IllegalArgumentException is due to a quotation mark mismatch
|
||||
candidates.add(new Completion(translated + "\""));
|
||||
return 0;
|
||||
}
|
||||
Tokenizer tokenizer = new Tokenizer(methodTarget.getRemainingBuffer(), true);
|
||||
Map<String, String> options = tokenizer.getTokens();
|
||||
|
||||
// Lookup arguments for this target
|
||||
Annotation[][] parameterAnnotations = methodTarget.getMethod().getParameterAnnotations();
|
||||
@@ -665,7 +658,7 @@ public class SimpleParser implements Parser {
|
||||
|
||||
// Handle if they are trying to find out the available option keys; always present option keys in order
|
||||
// of their declaration on the method signature, thus we can stop when mandatory options are filled in
|
||||
if (methodTarget.getRemainingBuffer().endsWith("--")) {
|
||||
if (methodTarget.getRemainingBuffer().endsWith("--") && !tokenizer.lastValueHadQuote()) {
|
||||
boolean showAllRemaining = true;
|
||||
for (CliOption include : unspecified) {
|
||||
if (include.mandatory()) {
|
||||
@@ -691,7 +684,8 @@ public class SimpleParser implements Parser {
|
||||
// Handle suggesting an option key if they haven't got one presently specified (or they've completed a full
|
||||
// option key/value pair)
|
||||
if (lastOptionKey == null
|
||||
|| (!"".equals(lastOptionKey) && !"".equals(lastOptionValue) && translated.endsWith(" "))) {
|
||||
|| (!"".equals(lastOptionKey) && !"".equals(lastOptionValue) && translated.endsWith(" ") && !tokenizer
|
||||
.lastValueHadQuote())) {
|
||||
// We have either NEVER specified an option key/value pair
|
||||
// OR we have specified a full option key/value pair
|
||||
|
||||
@@ -755,7 +749,8 @@ public class SimpleParser implements Parser {
|
||||
}
|
||||
|
||||
// Handle completing the option key they're presently typing
|
||||
if ((lastOptionValue == null || "".equals(lastOptionValue)) && !translated.endsWith(" ")) {
|
||||
if ((lastOptionValue == null || "".equals(lastOptionValue))
|
||||
&& !(translated.endsWith(" ") || translated.endsWith(" \""))) {
|
||||
// Given we haven't got an option value of any form, and there's no space at the buffer end, we must
|
||||
// still be typing an option key
|
||||
// System.out.println("completing an option");
|
||||
@@ -827,9 +822,13 @@ public class SimpleParser implements Parser {
|
||||
}
|
||||
|
||||
String prefix = "";
|
||||
if (!translated.endsWith(" ")) {
|
||||
if (!tokenizer.lastValueHadQuote() && !translated.endsWith(" ")) {
|
||||
prefix = " ";
|
||||
}
|
||||
else if (tokenizer.lastValueHadQuote()) {
|
||||
// Re-install opening quote if there was one
|
||||
prefix = " \"";
|
||||
}
|
||||
|
||||
// Only include in the candidates those results which are compatible with the present buffer
|
||||
for (Completion currentValue : allValues) {
|
||||
@@ -896,11 +895,12 @@ public class SimpleParser implements Parser {
|
||||
|
||||
if (results.size() > 0) {
|
||||
candidates.addAll(results);
|
||||
// Values presented from the last space onwards
|
||||
if (translated.endsWith(" ")) {
|
||||
if (tokenizer.lastValueHadQuote()) {
|
||||
return translated.lastIndexOf(" \"");
|
||||
}
|
||||
else {
|
||||
return translated.lastIndexOf(" ") + 1;
|
||||
}
|
||||
return translated.trim().lastIndexOf(" ");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ import java.util.Map;
|
||||
* Any token without an option marker is considered the default. The default is returned in the Map as an element with
|
||||
* an empty string key (""). There can only be a single default.
|
||||
*
|
||||
* @author Eric Bottard
|
||||
* @since 1.1
|
||||
*/
|
||||
public class Tokenizer {
|
||||
|
||||
@@ -46,8 +48,21 @@ public class Tokenizer {
|
||||
|
||||
private final Map<String, String> result = new LinkedHashMap<String, String>();
|
||||
|
||||
/** Useful when trying to do auto complete. */
|
||||
private boolean allowUnbalancedLastQuotedValue;
|
||||
|
||||
/**
|
||||
* Used to indicate that the last value was indeed half enclosed in quotes. Useful so that parser can re-add it.
|
||||
*/
|
||||
private boolean lastValueHadQuote;
|
||||
|
||||
public Tokenizer(String text) {
|
||||
this(text, false);
|
||||
}
|
||||
|
||||
public Tokenizer(String text, boolean allowUnbalancedLastQuotedValue) {
|
||||
this.buffer = text.toCharArray();
|
||||
this.allowUnbalancedLastQuotedValue = allowUnbalancedLastQuotedValue;
|
||||
tokenize();
|
||||
}
|
||||
|
||||
@@ -109,19 +124,46 @@ public class Tokenizer {
|
||||
pos++;
|
||||
}
|
||||
// When here, we either ran out of input, or encountered our delim, or both
|
||||
if (endDelimiter == '"' && pos == buffer.length && buffer[pos - 1] != '"') {
|
||||
throw new IllegalArgumentException("Cannot have an unbalanced number of quotation marks");
|
||||
// Fail, unless we allow an unfinished quoted string to be reported
|
||||
if (endDelimiter == '"' && // we're using quotes
|
||||
pos == buffer.length && // we ran of input
|
||||
(buffer[pos - 1] != '"' || // quotes are not properly closed
|
||||
sb.length() == 0)) { // BUT it's ok if consumed nothing (pos-1 is *opening* quote then)
|
||||
if (allowUnbalancedLastQuotedValue) {
|
||||
lastValueHadQuote = true;
|
||||
return sb.toString();
|
||||
}
|
||||
else {
|
||||
throw new IllegalArgumentException("Cannot have an unbalanced number of quotation marks");
|
||||
}
|
||||
}
|
||||
// Eat our delim
|
||||
pos++;
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public boolean lastValueHadQuote() {
|
||||
return lastValueHadQuote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a full @code{--key value} pair *unless* we're at the very end, in which case allow for @code {--key},
|
||||
* using "" for the value.
|
||||
*/
|
||||
private void eatKeyEqualsValue() {
|
||||
String key = eatKey();
|
||||
eatWhiteSpace();
|
||||
String value = eatValue();
|
||||
store(key, value);
|
||||
String value;
|
||||
if (pos < buffer.length) {
|
||||
value = eatValue();
|
||||
}
|
||||
else {
|
||||
value = "";
|
||||
}
|
||||
if (!key.equals("") || !key.equals(value)) {
|
||||
// Don't store the ""="" that would result from having a pending " --" at the end
|
||||
store(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private String eatKey() {
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.springframework.shell.core;
|
||||
import static java.util.Collections.emptyMap;
|
||||
import static java.util.Collections.singletonMap;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -112,6 +113,45 @@ public class TokenizerTests {
|
||||
assertEquals(singletonMap("foo", "bar \"fizz"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAllowKeyOnlyIfAtTheEnd() {
|
||||
Map<String, String> result = tokenize("--foo bar fizz --bozz ");
|
||||
Map<String, String> expected = new HashMap<String, String>();
|
||||
expected.put("", "fizz");
|
||||
expected.put("foo", "bar");
|
||||
expected.put("bozz", "");
|
||||
assertEquals(expected, result);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValueQuotationAllowUnfinished() {
|
||||
Tokenizer tokenizer = new Tokenizer("--foo \"bar bazz\" --bizz \"unfinished bizness ", true);
|
||||
Map<String, String> result = tokenizer.getTokens();
|
||||
Map<String, String> expected = new HashMap<String, String>();
|
||||
expected.put("foo", "bar bazz");
|
||||
expected.put("bizz", "unfinished bizness ");
|
||||
assertEquals(expected, result);
|
||||
assertTrue(tokenizer.lastValueHadQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValueQuotationAllowUnfinishedEvenWithEmptyContent() {
|
||||
Tokenizer tokenizer = new Tokenizer("--foo \"bar bazz\" --bizz \"", true);
|
||||
Map<String, String> result = tokenizer.getTokens();
|
||||
Map<String, String> expected = new HashMap<String, String>();
|
||||
expected.put("foo", "bar bazz");
|
||||
expected.put("bizz", "");
|
||||
assertEquals(expected, result);
|
||||
assertTrue(tokenizer.lastValueHadQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPendingDashDash() {
|
||||
Map<String, String> result = tokenize("--foo bar --");
|
||||
assertEquals(singletonMap("foo", "bar"), result);
|
||||
}
|
||||
|
||||
private Map<String, String> tokenize(String what) {
|
||||
return new Tokenizer(what).getTokens();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user