From 27399c3381dbb73c11c2380f3b0eabee8cba5e9d Mon Sep 17 00:00:00 2001 From: Eric Bottard Date: Thu, 12 Dec 2013 15:19:28 +0100 Subject: [PATCH] SHL-116: Rewrote tokenizer to allow quotes escaping --- .../shell/core/ParserUtils.java | 196 ------------------ .../shell/core/SimpleParser.java | 166 ++++++++------- .../springframework/shell/core/Tokenizer.java | 134 ++++++++++++ .../shell/core/TokenizerTests.java | 118 +++++++++++ 4 files changed, 345 insertions(+), 269 deletions(-) delete mode 100644 src/main/java/org/springframework/shell/core/ParserUtils.java create mode 100644 src/main/java/org/springframework/shell/core/Tokenizer.java create mode 100644 src/test/java/org/springframework/shell/core/TokenizerTests.java diff --git a/src/main/java/org/springframework/shell/core/ParserUtils.java b/src/main/java/org/springframework/shell/core/ParserUtils.java deleted file mode 100644 index 434f7939..00000000 --- a/src/main/java/org/springframework/shell/core/ParserUtils.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2011-2012 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.shell.core; - -import java.util.LinkedHashMap; -import java.util.Map; - -import org.springframework.util.Assert; - -/** - * Utilities for parsing. - * - * @author Ben Alex - * @since 1.0 - */ -public class ParserUtils { - - private ParserUtils() {} - - /** - * Converts a particular buffer into a tokenized structure. - * - *

- * Properly treats double quotes (") as option delimiters. - * - *

- * Expects option names to be preceded by a single or double dash. We call this an "option marker". - * - *

- * Treats spaces as the default option tokenizer. - * - *

- * 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. - * - * @param remainingBuffer to tokenize - * @return a Map where keys are the option names (minus any dashes) and values are the option values (any double-quotes are removed) - */ - public static Map tokenize(final String remainingBuffer) { - Assert.notNull(remainingBuffer, "Remaining buffer cannot be null, although it can be empty"); - Map result = new LinkedHashMap(); - StringBuilder currentOption = new StringBuilder(); - StringBuilder currentValue = new StringBuilder(); - boolean inQuotes = false; - - // Verify correct number of double quotes are present - int count = 0; - for (char c : remainingBuffer.toCharArray()) { - if ('"' == c) { - count++; - } - } - Assert.isTrue(count % 2 == 0, "Cannot have an unbalanced number of quotation marks"); - - if ("".equals(remainingBuffer.trim())) { - // They've not specified anything, so exit now - return result; - } - - String[] split = remainingBuffer.split(" "); - for (int i = 0; i < split.length; i++) { - String currentToken = split[i]; - - if (currentToken.startsWith("\"") && currentToken.endsWith("\"") && currentToken.length() > 1) { - String tokenLessDelimiters = currentToken.substring(1, currentToken.length() - 1); - currentValue.append(tokenLessDelimiters); - - // Store this token - store(result, currentOption, currentValue); - currentOption = new StringBuilder(); - currentValue = new StringBuilder(); - continue; - } - - if (inQuotes) { - // We're only interested in this token series ending - if (currentToken.endsWith("\"")) { - String tokenLessDelimiters = currentToken.substring(0, currentToken.length() - 1); - currentValue.append(" ").append(tokenLessDelimiters); - inQuotes = false; - - // Store this now-ended token series - store(result, currentOption, currentValue); - currentOption = new StringBuilder(); - currentValue = new StringBuilder(); - } else { - // The current token series has not ended - currentValue.append(" ").append(currentToken); - } - continue; - } - - if (currentToken.startsWith("\"")) { - // We're about to start a new delimited token - String tokenLessDelimiters = currentToken.substring(1); - currentValue.append(tokenLessDelimiters); - inQuotes = true; - continue; - } - - if (currentToken.trim().equals("")) { - // It's simply empty, so ignore it (ROO-23) - continue; - } - - if (currentToken.startsWith("--")) { - // We're about to start a new option marker - // First strip all of the - or -- or however many there are - int lastIndex = currentToken.lastIndexOf("-"); - String tokenLessDelimiters = currentToken.substring(lastIndex + 1); - currentOption.append(tokenLessDelimiters); - - // Store this token if it's the last one, or the next token starts with a "-" - if (i + 1 == split.length) { - // We're at the end of the tokens, so store this one and stop processing - store(result, currentOption, currentValue); - break; - } - - if (split[i + 1].startsWith("-")) { - // A new token is being started next iteration, so store this one now - store(result, currentOption, currentValue); - currentOption = new StringBuilder(); - currentValue = new StringBuilder(); - } - - continue; - } - - // We must be in a standard token - - // If the standard token has no option name, we allow it to contain unquoted spaces - if (currentOption.length() == 0) { - if (currentValue.length() > 0) { - // Existing content, so add a space first - currentValue.append(" "); - } - currentValue.append(currentToken); - - // Store this token if it's the last one, or the next token starts with a "-" - if (i + 1 == split.length) { - // We're at the end of the tokens, so store this one and stop processing - store(result, currentOption, currentValue); - break; - } - - if (split[i + 1].startsWith("--")) { - // A new token is being started next iteration, so store this one now - store(result, currentOption, currentValue); - currentOption = new StringBuilder(); - currentValue = new StringBuilder(); - } - - continue; - } - - // This is an ordinary token, so store it now - currentValue.append(currentToken); - store(result, currentOption, currentValue); - currentOption = new StringBuilder(); - currentValue = new StringBuilder(); - } - - // Strip out an empty default option, if it was returned (ROO-379) - if (result.containsKey("") && result.get("").trim().equals("")) { - result.remove(""); - } - - return result; - } - - private static void store(final Map results, final StringBuilder currentOption, final StringBuilder currentValue) { - if (currentOption.length() > 0) { - // There is an option marker - String option = currentOption.toString(); - Assert.isTrue(!results.containsKey(option), "You cannot specify option '" + option + "' more than once in a single command"); - results.put(option, currentValue.toString()); - } else { - // There was no option marker, so verify this isn't the first - Assert.isTrue(!results.containsKey(""), "You cannot add more than one default option ('" + currentValue.toString() + "') in a single command"); - results.put("", currentValue.toString()); - } - } -} diff --git a/src/main/java/org/springframework/shell/core/SimpleParser.java b/src/main/java/org/springframework/shell/core/SimpleParser.java index cfd22910..313022df 100644 --- a/src/main/java/org/springframework/shell/core/SimpleParser.java +++ b/src/main/java/org/springframework/shell/core/SimpleParser.java @@ -45,7 +45,7 @@ import org.springframework.util.StringUtils; /** * Default implementation of {@link Parser}. - * + * * @author Ben Alex * @since 1.0 */ @@ -53,12 +53,16 @@ public class SimpleParser implements Parser { // Constants private static final Logger LOGGER = HandlerUtils.getLogger(SimpleParser.class); + private static final Comparator COMPARATOR = new NaturalOrderComparator(); // Fields private final Object mutex = new Object(); + private final Set> converters = new HashSet>(); + private final Set commands = new HashSet(); + private final Map availabilityIndicators = new HashMap(); private MethodTarget getAvailabilityIndicator(final String command) { @@ -66,9 +70,8 @@ public class SimpleParser implements Parser { } /** - * get all mandatory options keys. For the options with multiple keys, the - * keys will be in one row. - * + * get all mandatory options keys. For the options with multiple keys, the keys will be in one row. + * * @param cliOptions options * @return mandatory options keys */ @@ -78,7 +81,7 @@ public class SimpleParser implements Parser { /** * get all options key. - * + * * @param cliOptions * @param includeOptionalOptions * @return options keys @@ -100,7 +103,6 @@ public class SimpleParser implements Parser { return optionsKeys; } - public ParseResult parse(final String rawInput) { synchronized (mutex) { Assert.notNull(rawInput, "Raw input required"); @@ -142,8 +144,9 @@ public class SimpleParser implements Parser { // Attempt to parse Map options = null; try { - options = ParserUtils.tokenize(methodTarget.getRemainingBuffer()); - } catch (IllegalArgumentException e) { + options = new Tokenizer(methodTarget.getRemainingBuffer()).getTokens(); + } + catch (IllegalArgumentException e) { LOGGER.warning(ExceptionUtils.extractRootCause(e).getMessage()); return null; } @@ -185,9 +188,9 @@ public class SimpleParser implements Parser { boolean mandatory = !StringUtils.hasText(value) && cliOption.mandatory(); boolean specifiedKey = !StringUtils.hasText(value) && options.containsKey(sourcedFrom); boolean specifiedKeyWithoutValue = false; - if(specifiedKey){ + if (specifiedKey) { value = cliOption.specifiedDefaultValue(); - if("__NULL__".equals(value)){ + if ("__NULL__".equals(value)) { specifiedKeyWithoutValue = true; } } @@ -255,7 +258,8 @@ public class SimpleParser implements Parser { throw new IllegalStateException(); } arguments.add(result); - } catch (RuntimeException e) { + } + catch (RuntimeException e) { LOGGER.warning(e.getClass().getName() + ": Failed to convert '" + value + "' to type " + requiredType.getSimpleName() + " for option '" + StringUtils.arrayToCommaDelimitedString(cliOption.key()) + "'"); @@ -263,7 +267,8 @@ public class SimpleParser implements Parser { LOGGER.warning(e.getMessage()); } return null; - } finally { + } + finally { CliOptionContext.resetOptionContext(); CliSimpleParserContext.resetSimpleParserContext(); } @@ -274,13 +279,13 @@ public class SimpleParser implements Parser { if (!unavailableOptions.isEmpty()) { StringBuilder message = new StringBuilder(); if (unavailableOptions.size() == 1) { - message.append("Option '").append(unavailableOptions.iterator().next()).append( - "' is not available for this command. "); + message.append("Option '").append(unavailableOptions.iterator().next()) + .append("' is not available for this command. "); } else { - message.append("Options ").append( - StringUtils.collectionToDelimitedString(unavailableOptions, ", ", "'", "'")).append( - " are not available for this command. "); + message.append("Options ") + .append(StringUtils.collectionToDelimitedString(unavailableOptions, ", ", "'", "'")) + .append(" are not available for this command. "); } message.append("Use tab assist or the \"help\" command to see the legal options"); LOGGER.warning(message.toString()); @@ -304,7 +309,7 @@ public class SimpleParser implements Parser { StringBuilder valueBuilder = new StringBuilder(); valueBuilder.append("You should specify value for option '"); - List> optionsKeys = getOptionsKeys(cliOptions,true); + List> optionsKeys = getOptionsKeys(cliOptions, true); for (List keys : optionsKeys) { boolean found = false; for (String key : keys) { @@ -324,7 +329,7 @@ public class SimpleParser implements Parser { optionBuilder.append(", "); } } - //remove the ", " in the end. + // remove the ", " in the end. String hintForOption = optionBuilder.toString(); hintForOption = hintForOption.substring(0, hintForOption.length() - 2); if (hintForOptions) { @@ -338,7 +343,7 @@ public class SimpleParser implements Parser { /** * Normalises the given raw user input string ready for parsing - * + * * @param rawInput the string to normalise; can't be null * @return a non-null string */ @@ -347,7 +352,8 @@ public class SimpleParser implements Parser { return rawInput.replaceAll(" +", " ").trim(); } - private Set getSpecifiedUnavailableOptions(final Set cliOptions, final Map options) { + private Set getSpecifiedUnavailableOptions(final Set cliOptions, + final Map options) { Set cliOptionKeySet = new LinkedHashSet(); for (CliOption cliOption : cliOptions) { for (String key : cliOption.key()) { @@ -380,7 +386,8 @@ public class SimpleParser implements Parser { logger.warning("Command '" + buffer + "' not found (for assistance press " + AbstractShell.completionKeys + ")"); } - private Collection locateTargets(final String buffer, final boolean strictMatching, final boolean checkAvailabilityIndicators) { + private Collection locateTargets(final String buffer, final boolean strictMatching, + final boolean checkAvailabilityIndicators) { Assert.notNull(buffer, "Buffer required"); final Collection result = new HashSet(); @@ -401,8 +408,10 @@ public class SimpleParser implements Parser { + method.toGenericString() + "'"); try { available = (Boolean) mt.getMethod().invoke(mt.getTarget()); - // We should "break" here, but we loop over all to ensure no conflicting availability indicators are defined - } catch (Exception e) { + // We should "break" here, but we loop over all to ensure no conflicting + // availability indicators are defined + } + catch (Exception e) { available = false; } } @@ -455,7 +464,8 @@ public class SimpleParser implements Parser { for (int candidate = lastCommandWordUsed; candidate < commandWords.length; candidate++) { if (lastWord != null && lastWord.length() > 0 && commandWords[candidate].startsWith(lastWord)) { if (bufferToReturn == null) { - // This is the first match, so ensure the intended match really represents the start of a command and not a later word within it + // This is the first match, so ensure the intended match really represents the start of a + // command and not a later word within it if (lastCommandWordUsed == 0 && candidate > 0) { // This is not a valid match break next_buffer_loop; @@ -565,8 +575,9 @@ public class SimpleParser implements Parser { // Make a reasonable attempt at parsing the remainingBuffer Map options; try { - options = ParserUtils.tokenize(methodTarget.getRemainingBuffer()); - } catch (IllegalArgumentException ex) { + 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; @@ -579,7 +590,8 @@ public class SimpleParser implements Parser { if (parameterAnnotations.length == 0) { for (String value : cmd.value()) { if (buffer.startsWith(value) || value.startsWith(buffer)) { - results.add(new Completion(value)); // no space at the end, as there's no need to continue the command further + results.add(new Completion(value)); // no space at the end, as there's no need to continue the + // command further } } candidates.addAll(results); @@ -606,7 +618,8 @@ public class SimpleParser implements Parser { } } - // To get this far, we know there are arguments required for this CliCommand, and they specified a valid command name + // To get this far, we know there are arguments required for this CliCommand, and they specified a valid + // command name // Record all the CliOptions applicable to this command List cliOptions = new ArrayList(); @@ -675,16 +688,19 @@ public class SimpleParser implements Parser { return 0; } - // Handle suggesting an option key if they haven't got one presently specified (or they've completed a full option key/value pair) + // 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(" "))) { // We have either NEVER specified an option key/value pair // OR we have specified a full option key/value pair - // Let's list some other options the user might want to try (naturally skip the "" option, as that's the default) + // Let's list some other options the user might want to try (naturally skip the "" option, as that's the + // default) for (CliOption include : unspecified) { for (String value : include.key()) { - // Manually determine if this non-mandatory but unspecifiedDefaultValue=* requiring option is able to be bound + // Manually determine if this non-mandatory but unspecifiedDefaultValue=* requiring option is + // able to be bound if (!include.mandatory() && "*".equals(include.unspecifiedDefaultValue()) && !"".equals(value)) { try { for (Converter candidate : converters) { @@ -706,11 +722,13 @@ public class SimpleParser implements Parser { if (paramType != null && candidate.supports(paramType, include.optionContext())) { // Try to invoke this usable converter candidate.convertFromText("*", paramType, include.optionContext()); - // If we got this far, the converter is happy with "*" so we need not bother the user with entering the data in themselves + // If we got this far, the converter is happy with "*" so we need not bother the + // user with entering the data in themselves break; } } - } catch (RuntimeException notYetReady) { + } + catch (RuntimeException notYetReady) { if (translated.endsWith(" ")) { results.add(new Completion(translated + "--" + value + " ")); } @@ -728,7 +746,8 @@ public class SimpleParser implements Parser { } } - // Only abort at this point if we have some suggestions; otherwise we might want to try to complete the "" option + // Only abort at this point if we have some suggestions; otherwise we might want to try to complete the + // "" option if (results.size() > 0) { candidates.addAll(results); return 0; @@ -737,8 +756,9 @@ public class SimpleParser implements Parser { // Handle completing the option key they're presently typing if ((lastOptionValue == null || "".equals(lastOptionValue)) && !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"); + // 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"); for (CliOption option : cliOptions) { for (String value : option.key()) { if (value != null && lastOptionKey != null @@ -782,7 +802,8 @@ public class SimpleParser implements Parser { } if (allValues.isEmpty()) { - // Doesn't appear to be a custom Converter, so let's go and provide defaults for simple types + // Doesn't appear to be a custom Converter, so let's go and provide defaults for simple + // types // Provide some simple options for common types if (Boolean.class.isAssignableFrom(parameterType) @@ -815,12 +836,12 @@ public class SimpleParser implements Parser { // We only provide a suggestion if the lastOptionValue == "" if (!StringUtils.hasText(lastOptionValue)) { // We should add the result, as they haven't typed anything yet - results.add(new Completion(prefix + currentValue.getValue() + suffix, - currentValue.getFormattedValue(), currentValue.getHeading(), - currentValue.getOrder())); + results.add(new Completion(prefix + currentValue.getValue() + suffix, currentValue + .getFormattedValue(), currentValue.getHeading(), currentValue.getOrder())); } else { - // Only add the result **if** what they've typed is compatible *AND* they haven't already typed it in full + // Only add the result **if** what they've typed is compatible *AND* they haven't + // already typed it in full if (currentValue.getValue().toLowerCase().startsWith(lastOptionValue.toLowerCase()) && !lastOptionValue.equalsIgnoreCase(currentValue.getValue()) && lastOptionValue.length() < currentValue.getValue().length()) { @@ -831,7 +852,8 @@ public class SimpleParser implements Parser { } } - // ROO-389: give inline options given there's multiple choices available and we want to help the user + // ROO-389: give inline options given there's multiple choices available and we want to help + // the user StringBuilder help = new StringBuilder(); help.append(OsUtils.LINE_SEPARATOR); help.append(option.mandatory() ? "required --" : "optional --"); @@ -852,13 +874,13 @@ public class SimpleParser implements Parser { else { if (!"".equals(option.specifiedDefaultValue()) && !"__NULL__".equals(option.specifiedDefaultValue())) { - help.append("; default if option present: '").append(option.specifiedDefaultValue()).append( - "'"); + help.append("; default if option present: '") + .append(option.specifiedDefaultValue()).append("'"); } if (!"".equals(option.unspecifiedDefaultValue()) && !"__NULL__".equals(option.unspecifiedDefaultValue())) { - help.append("; default if option not present: '").append( - option.unspecifiedDefaultValue()).append("'"); + help.append("; default if option not present: '") + .append(option.unspecifiedDefaultValue()).append("'"); } } LOGGER.info(help.toString()); @@ -866,7 +888,8 @@ public class SimpleParser implements Parser { if (results.size() == 1) { String suggestion = results.iterator().next().getValue().trim(); if (suggestion.equals(lastOptionValue)) { - // They have pressed TAB in the default value, and the default value has already been provided as an explicit option + // They have pressed TAB in the default value, and the default value has already + // been provided as an explicit option return 0; } } @@ -891,34 +914,32 @@ public class SimpleParser implements Parser { /** * populate completion for mandatory options - * + * * @param translated user's input * @param unspecified unspecified options * @param value the option key * @param results completion list */ - private void handleMandatoryCompletion(String translated, List unspecified, String value, SortedSet results) { + private void handleMandatoryCompletion(String translated, List unspecified, String value, + SortedSet results) { StringBuilder strBuilder = new StringBuilder(translated); if (!translated.endsWith(" ")) { strBuilder.append(" "); } // Plan change for SHL-20. But usability is bad. /* - List> mandatoryOptions = getMandatoryOptions(unspecified); - for (List option : mandatoryOptions) { - strBuilder.append("--"); - strBuilder.append(option.get(0)); - strBuilder.append(" "); - } - */ + * List> mandatoryOptions = getMandatoryOptions(unspecified); for (List option : + * mandatoryOptions) { strBuilder.append("--"); strBuilder.append(option.get(0)); strBuilder.append(" "); } + */ strBuilder.append("--"); strBuilder.append(value); strBuilder.append(" "); results.add(new Completion(strBuilder.toString())); } - - public void obtainHelp(@CliOption(key = { "", "command" }, optionContext = "availableCommands", help = "Command name to provide help for") String buffer) { + public void obtainHelp( + @CliOption(key = { "", "command" }, optionContext = "availableCommands", help = "Command name to provide help for") + String buffer) { synchronized (mutex) { if (buffer == null) { buffer = ""; @@ -955,18 +976,17 @@ public class SimpleParser implements Parser { if ("".equals(key)) { key = "** default **"; } - sb.append(" Keyword: ").append(key).append( - OsUtils.LINE_SEPARATOR); + sb.append(" Keyword: ").append(key).append(OsUtils.LINE_SEPARATOR); } - sb.append(" Help: ").append(cliOption.help()).append( - OsUtils.LINE_SEPARATOR); - sb.append(" Mandatory: ").append(cliOption.mandatory()).append( - OsUtils.LINE_SEPARATOR); - sb.append(" Default if specified: '").append(cliOption.specifiedDefaultValue()).append( - "'").append(OsUtils.LINE_SEPARATOR); - sb.append(" Default if unspecified: '").append(cliOption.unspecifiedDefaultValue()).append( - "'").append(OsUtils.LINE_SEPARATOR); + sb.append(" Help: ").append(cliOption.help()) + .append(OsUtils.LINE_SEPARATOR); + sb.append(" Mandatory: ").append(cliOption.mandatory()) + .append(OsUtils.LINE_SEPARATOR); + sb.append(" Default if specified: '").append(cliOption.specifiedDefaultValue()) + .append("'").append(OsUtils.LINE_SEPARATOR); + sb.append(" Default if unspecified: '").append(cliOption.unspecifiedDefaultValue()) + .append("'").append(OsUtils.LINE_SEPARATOR); sb.append(OsUtils.LINE_SEPARATOR); } @@ -998,8 +1018,8 @@ public class SimpleParser implements Parser { } LOGGER.info(sb.toString()); -// LOGGER.warning("** Type 'hint' (without the quotes) and hit ENTER for step-by-step guidance **" -// + StringUtils.LINE_SEPARATOR); + // LOGGER.warning("** Type 'hint' (without the quotes) and hit ENTER for step-by-step guidance **" + // + StringUtils.LINE_SEPARATOR); } } @@ -1042,7 +1062,7 @@ public class SimpleParser implements Parser { } } } - + public final Set getCommandMarkers() { synchronized (mutex) { return Collections.unmodifiableSet(commands); @@ -1074,7 +1094,7 @@ public class SimpleParser implements Parser { converters.remove(converter); } } - + public final Set> getConverters() { synchronized (mutex) { return Collections.unmodifiableSet(converters); diff --git a/src/main/java/org/springframework/shell/core/Tokenizer.java b/src/main/java/org/springframework/shell/core/Tokenizer.java new file mode 100644 index 00000000..a7e0ffd8 --- /dev/null +++ b/src/main/java/org/springframework/shell/core/Tokenizer.java @@ -0,0 +1,134 @@ +/* + * Copyright 2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.core; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Converts a particular buffer into a tokenized structure. + * + *

+ * Properly treats double quotes (") as option delimiters. + * + *

+ * Expects option names to be preceded by a double dash. We call this an "option marker". + * + *

+ * Treats spaces as the default option tokenizer. + * + *

+ * 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. + * + */ +public class Tokenizer { + + private static final char ESCAPE_CHAR = '\\'; + + private final char[] buffer; + + private int pos = 0; + + private final Map result = new LinkedHashMap(); + + public Tokenizer(String text) { + this.buffer = text.toCharArray(); + tokenize(); + } + + private void eatWhiteSpace() { + while (pos < buffer.length && buffer[pos] == ' ') { + pos++; + } + } + + public void tokenize() { + while (pos < buffer.length) { + eatWhiteSpace(); + if (pos < buffer.length) { + eatKeyValuePair(); + } + } + } + + public Map getTokens() { + return result; + } + + /** + * Consume either {@code --key=value} or just {@code value}, eating extra spaces. + */ + private void eatKeyValuePair() { + if (buffer[pos] == '-' && pos + 1 < buffer.length && buffer[pos + 1] == '-') { + pos += 2; + eatKeyEqualsValue(); + } + else { + String value = eatValue(); + store("", value); + } + + } + + private void store(String key, String value) { + if (result.put(key, value) != null) { + throw new IllegalArgumentException("You cannot specify option '" + key + + "' more than once in a single command"); + } + } + + private String eatValue() { + StringBuilder sb = new StringBuilder(); + char endDelimiter = ' '; + if (buffer[pos] == '"') { + endDelimiter = '"'; + pos++; + } + while (pos < buffer.length && buffer[pos] != endDelimiter) { + if (buffer[pos] == ESCAPE_CHAR && pos + 1 < buffer.length && buffer[pos + 1] == endDelimiter) { + sb.append(endDelimiter); + pos += 2; + continue; + } + sb.append(buffer[pos]); + 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"); + } + // Eat our delim + pos++; + return sb.toString(); + } + + private void eatKeyEqualsValue() { + String key = eatKey(); + eatWhiteSpace(); + String value = eatValue(); + store(key, value); + } + + private String eatKey() { + int start = pos; + while (pos < buffer.length && buffer[pos] != ' ') { + pos++; + } + return new String(buffer, start, pos - start); + } +} diff --git a/src/test/java/org/springframework/shell/core/TokenizerTests.java b/src/test/java/org/springframework/shell/core/TokenizerTests.java new file mode 100644 index 00000000..2878d429 --- /dev/null +++ b/src/test/java/org/springframework/shell/core/TokenizerTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.core; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static org.junit.Assert.assertEquals; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +/** + * Tests for Tokenizer. + * + * @author Eric Bottard + */ +public class TokenizerTests { + + @Test + public void testEmpty() { + Map result = tokenize(""); + assertEquals(emptyMap(), result); + } + + @Test + public void testBlank() { + Map result = tokenize(" "); + assertEquals(emptyMap(), result); + } + + @Test + public void testDefaultKey() { + Map result = tokenize("foo"); + assertEquals(singletonMap("", "foo"), result); + } + + @Test + public void testOneOption() { + Map result = tokenize("--foo bar"); + assertEquals(singletonMap("foo", "bar"), result); + } + + @Test + public void testTwoOptions() { + Map result = tokenize("--foo bar --fizz buzz"); + Map expected = new HashMap(); + expected.put("foo", "bar"); + expected.put("fizz", "buzz"); + assertEquals(expected, result); + } + + @Test + public void testTwoOptionsOneWithDefault() { + Map result = tokenize("bar --fizz buzz"); + Map expected = new HashMap(); + expected.put("", "bar"); + expected.put("fizz", "buzz"); + assertEquals(expected, result); + } + + @Test(expected = IllegalArgumentException.class) + public void testTwoOptionsSameKey() { + tokenize("--foo bar --foo buzz"); + } + + @Test(expected = IllegalArgumentException.class) + public void testTwoOptionsSameEmptyKey() { + tokenize("bar buzz"); + } + + @Test + public void testValueQuotation() { + Map result = tokenize("--foo \"bar fizz\""); + assertEquals(singletonMap("foo", "bar fizz"), result); + } + + @Test + public void testExtraSpaces() { + Map result = tokenize(" --foo \"bar fizz\" --bozz bizzz \"the default\""); + Map expected = new HashMap(); + expected.put("", "the default"); + expected.put("foo", "bar fizz"); + expected.put("bozz", "bizzz"); + assertEquals(expected, result); + } + + @Test(expected = IllegalArgumentException.class) + public void testValueQuotationUnbalanced() { + Map result = tokenize("--foo \"bar fizz"); + assertEquals(singletonMap("foo", "bar fizz"), result); + } + + @Test + public void testValueQuotationEscaped() { + Map result = tokenize("--foo \"bar \\\"fizz\""); + assertEquals(singletonMap("foo", "bar \"fizz"), result); + } + + private Map tokenize(String what) { + return new Tokenizer(what).getTokens(); + } +}