/*
* 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.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.logging.Logger;
import org.springframework.shell.core.annotation.CliAvailabilityIndicator;
import org.springframework.shell.core.annotation.CliCommand;
import org.springframework.shell.core.annotation.CliOption;
import org.springframework.shell.event.ParseResult;
import org.springframework.shell.support.logging.HandlerUtils;
import org.springframework.shell.support.util.ExceptionUtils;
import org.springframework.shell.support.util.NaturalOrderComparator;
import org.springframework.shell.support.util.OsUtils;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Default implementation of {@link Parser}.
*
* @author Ben Alex
* @since 1.0
*/
public class SimpleParser implements Parser {
// Constants
private static final Logger LOGGER = HandlerUtils.getLogger(SimpleParser.class);
private static final Comparator<Object> COMPARATOR = new NaturalOrderComparator<Object>();
// Fields
private final Object mutex = new Object();
private final Set<Converter<?>> converters = new HashSet<Converter<?>>();
private final Set<CommandMarker> commands = new HashSet<CommandMarker>();
private final Map<String, MethodTarget> availabilityIndicators = new HashMap<String, MethodTarget>();
/**
* The last buffer when completion was requested.
*/
private String previousCompletionBuffer;
/**
* The number of times completion has been requested with the same buffer.
*/
private int successiveCompletionRequests = 1;
private MethodTarget getAvailabilityIndicator(final String command) {
return availabilityIndicators.get(command);
}
/**
* 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
*/
private List<List<String>> getMandatoryOptionsKeys(Collection<CliOption> cliOptions) {
return getOptionsKeys(cliOptions, false);
}
/**
* get all options key.
*
* @param cliOptions
* @param includeOptionalOptions
* @return options keys
*/
private List<List<String>> getOptionsKeys(Collection<CliOption> cliOptions, boolean includeOptionalOptions) {
List<List<String>> optionsKeys = new ArrayList<List<String>>();
for (CliOption option : cliOptions) {
if (includeOptionalOptions) {
List<String> keys = new ArrayList<String>();
keys.addAll(Arrays.asList(option.key()));
optionsKeys.add(keys);
}
else if (option.mandatory()) {
List<String> keys = new ArrayList<String>();
keys.addAll(Arrays.asList(option.key()));
optionsKeys.add(keys);
}
}
return optionsKeys;
}
@Override
public ParseResult parse(final String rawInput) {
synchronized (mutex) {
Assert.notNull(rawInput, "Raw input required");
final String input = normalise(rawInput);
resetCompletionInvocations();
// Locate the applicable targets which match this buffer
final Collection<MethodTarget> matchingTargets = locateTargets(input, true, true);
if (matchingTargets.isEmpty()) {
// Before we just give up, let's see if we can offer a more informative message to the user
// by seeing the command is simply unavailable at this point in time
matchingTargets.addAll(locateTargets(input, true, false));
if (matchingTargets.isEmpty()) {
commandNotFound(LOGGER, input);
}
else {
LOGGER.warning("Command '"
+ input
+ "' was found but is not currently available (type 'help' then ENTER to learn about this command)");
}
return null;
}
MethodTarget methodTarget = null;
if (matchingTargets.size() > 1) {
// Any prefix of a valid command will do. Don't fail if the user used
// the exact key of a command though.
for (MethodTarget candidate : matchingTargets) {
if (candidate.getKey().equals(input) || input.startsWith(candidate.getKey() + " ")) {
methodTarget = candidate;
break;
}
}
if (methodTarget == null) {
LOGGER.warning("Ambigious command '" + input + "' (for assistance press "
+ AbstractShell.completionKeys + " or type \"hint\" then hit ENTER)");
return null;
}
}
else {
methodTarget = matchingTargets.iterator().next();
}
// Argument conversion time
Annotation[][] parameterAnnotations = methodTarget.getMethod().getParameterAnnotations();
if (parameterAnnotations.length == 0) {
// No args
return new ParseResult(methodTarget.getMethod(), methodTarget.getTarget(), null);
}
// Oh well, we need to convert some arguments
final List<Object> arguments = new ArrayList<Object>(methodTarget.getMethod().getParameterTypes().length);
// Attempt to parse
Map<String, String> options = null;
try {
options = new Tokenizer(methodTarget.getRemainingBuffer()).getTokens();
}
catch (TokenizingException te) {
String commandKey = methodTarget.getKey();
reportTokenizingException(commandKey, te);
return null;
}
catch (IllegalArgumentException e) {
LOGGER.warning(ExceptionUtils.extractRootCause(e).getMessage());
return null;
}
final Set<CliOption> cliOptions = getCliOptions(parameterAnnotations);
for (CliOption cliOption : cliOptions) {
Class<?> requiredType = methodTarget.getMethod().getParameterTypes()[arguments.size()];
if (cliOption.systemProvided()) {
Object result;
if (SimpleParser.class.isAssignableFrom(requiredType)) {
result = this;
}
else {
LOGGER.warning("Parameter type '" + requiredType + "' is not system provided");
return null;
}
arguments.add(result);
continue;
}
// Obtain the value the user specified, taking care to ensure they only specified it via a single alias
String value = null;
String sourcedFrom = null;
for (String possibleKey : cliOption.key()) {
if (options.containsKey(possibleKey)) {
if (sourcedFrom != null) {
LOGGER.warning("You cannot specify option '" + possibleKey
+ "' when you have also specified '" + sourcedFrom + "' in the same command");
return null;
}
sourcedFrom = possibleKey;
value = options.get(possibleKey);
}
}
// Ensure the user specified a value if the value is mandatory or
// key and value must appear in pair
boolean mandatory = !StringUtils.hasText(value) && cliOption.mandatory();
boolean specifiedKey = !StringUtils.hasText(value) && options.containsKey(sourcedFrom);
boolean specifiedKeyWithoutValue = false;
if (specifiedKey) {
value = cliOption.specifiedDefaultValue();
if ("__NULL__".equals(value)) {
specifiedKeyWithoutValue = true;
}
}
if (mandatory || specifiedKeyWithoutValue) {
if ("".equals(cliOption.key()[0])) {
StringBuilder message = new StringBuilder("You should specify a default option ");
if (cliOption.key().length > 1) {
message.append("(otherwise known as option '").append(cliOption.key()[1]).append("') ");
}
message.append("for this command");
LOGGER.warning(message.toString());
}
else {
printHintMessage(cliOptions, options);
}
return null;
}
// Accept a default if the user specified the option, but didn't provide a value
if ("".equals(value)) {
value = cliOption.specifiedDefaultValue();
}
// Accept a default if the user didn't specify the option at all
if (value == null) {
value = cliOption.unspecifiedDefaultValue();
}
// Special token that denotes a null value is sought (useful for default values)
if ("__NULL__".equals(value)) {
if (requiredType.isPrimitive()) {
LOGGER.warning("Nulls cannot be presented to primitive type " + requiredType.getSimpleName()
+ " for option '" + StringUtils.arrayToCommaDelimitedString(cliOption.key()) + "'");
return null;
}
arguments.add(null);
continue;
}
// Now we're ready to perform a conversion
try {
CliOptionContext.setOptionContext(cliOption.optionContext());
CliSimpleParserContext.setSimpleParserContext(this);
Object result;
Converter<?> c = null;
for (Converter<?> candidate : converters) {
if (candidate.supports(requiredType, cliOption.optionContext())) {
// Found a usable converter
c = candidate;
break;
}
}
if (c == null) {
throw new IllegalStateException("TODO: Add basic type conversion");
// TODO Fall back to a normal SimpleTypeConverter and attempt conversion
// SimpleTypeConverter simpleTypeConverter = new SimpleTypeConverter();
// result = simpleTypeConverter.convertIfNecessary(value, requiredType, mp);
}
// Use the converter
result = c.convertFromText(value, requiredType, cliOption.optionContext());
// If the option has been specified to be mandatory then the result should never be null
if (result == null && cliOption.mandatory()) {
throw new IllegalStateException();
}
arguments.add(result);
}
catch (RuntimeException e) {
LOGGER.warning(e.getClass().getName() + ": Failed to convert '" + value + "' to type "
+ requiredType.getSimpleName() + " for option '"
+ StringUtils.arrayToCommaDelimitedString(cliOption.key()) + "'");
if (StringUtils.hasText(e.getMessage())) {
LOGGER.warning(e.getMessage());
}
return null;
}
finally {
CliOptionContext.resetOptionContext();
CliSimpleParserContext.resetSimpleParserContext();
}
}
// Check for options specified by the user but are unavailable for the command
Set<String> unavailableOptions = getSpecifiedUnavailableOptions(cliOptions, options);
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. ");
}
else {
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());
return null;
}
return new ParseResult(methodTarget.getMethod(), methodTarget.getTarget(), arguments.toArray());
}
}
/**
* We're being asked to execute a command, so the next completion invocation will definitely refer to a different
* buffer.
*/
private void resetCompletionInvocations() {
previousCompletionBuffer = null;
successiveCompletionRequests = 1;
}
private void reportTokenizingException(String commandKey, TokenizingException te) {
StringBuilder caret = new StringBuilder();
for (int i = 0; i < te.getOffendingOffset() + commandKey.length() + 1; i++) {
caret.append(" ");
}
LOGGER.warning(commandKey + " " + te.getBuffer());
LOGGER.warning(caret + "^");
LOGGER.warning(te.getReason());
}
/**
* @param cliOptions
* @param options
*/
private void printHintMessage(final Set<CliOption> cliOptions, Map<String, String> options) {
boolean hintForOptions = true;
StringBuilder optionBuilder = new StringBuilder();
optionBuilder.append("You should specify option (");
StringBuilder valueBuilder = new StringBuilder();
valueBuilder.append("You should specify value for option '");
List<List<String>> optionsKeys = getOptionsKeys(cliOptions, true);
for (List<String> keys : optionsKeys) {
boolean found = false;
for (String key : keys) {
if (options.containsKey(key)) {
if (!StringUtils.hasText(options.get(key))) {
valueBuilder.append(key);
valueBuilder.append("' for this command");
hintForOptions = false;
}
found = true;
break;
}
}
if (!found) {
optionBuilder.append("--");
optionBuilder.append(keys.get(0));
optionBuilder.append(", ");
}
}
// remove the ", " in the end.
String hintForOption = optionBuilder.toString();
hintForOption = hintForOption.substring(0, hintForOption.length() - 2);
if (hintForOptions) {
LOGGER.warning(hintForOption + ") for this command");
}
else {
LOGGER.warning(valueBuilder.toString());
}
}
/**
* Normalises the given raw user input string ready for parsing
*
* @param rawInput the string to normalise; can't be <code>null</code>
* @return a non-<code>null</code> string
*/
String normalise(final String rawInput) {
// Replace all multiple spaces with a single space and then trim
return rawInput.replaceAll(" +", " ").trim();
}
private Set<String> getSpecifiedUnavailableOptions(final Set<CliOption> cliOptions,
final Map<String, String> options) {
Set<String> cliOptionKeySet = new LinkedHashSet<String>();
for (CliOption cliOption : cliOptions) {
for (String key : cliOption.key()) {
cliOptionKeySet.add(key.toLowerCase());
}
}
Set<String> unavailableOptions = new LinkedHashSet<String>();
for (String suppliedOption : options.keySet()) {
if (!cliOptionKeySet.contains(suppliedOption.toLowerCase())) {
unavailableOptions.add(suppliedOption);
}
}
return unavailableOptions;
}
private Set<CliOption> getCliOptions(final Annotation[][] parameterAnnotations) {
Set<CliOption> cliOptions = new LinkedHashSet<CliOption>();
for (Annotation[] annotations : parameterAnnotations) {
for (Annotation annotation : annotations) {
if (annotation instanceof CliOption) {
CliOption cliOption = (CliOption) annotation;
cliOptions.add(cliOption);
}
}
}
return cliOptions;
}
protected void commandNotFound(final Logger logger, final String buffer) {
logger.warning("Command '" + buffer + "' not found (for assistance press " + AbstractShell.completionKeys + ")");
}
private Collection<MethodTarget> locateTargets(final String buffer, final boolean strictMatching,
final boolean checkAvailabilityIndicators) {
Assert.notNull(buffer, "Buffer required");
final Collection<MethodTarget> result = new HashSet<MethodTarget>();
// The reflection could certainly be optimised, but it's good enough for now (and cached reflection
// is unlikely to be noticeable to a human being using the CLI)
for (final CommandMarker command : commands) {
for (final Method method : command.getClass().getMethods()) {
CliCommand cmd = method.getAnnotation(CliCommand.class);
if (cmd != null) {
// We have a @CliCommand.
if (checkAvailabilityIndicators) {
// Decide if this @CliCommand is available at this moment
Boolean available = null;
for (String value : cmd.value()) {
MethodTarget mt = getAvailabilityIndicator(value);
if (mt != null) {
Assert.isNull(available, "More than one availability indicator is defined for '"
+ 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) {
available = false;
}
}
}
// Skip this @CliCommand if it's not available
if (available != null && !available) {
continue;
}
}
for (String value : cmd.value()) {
String remainingBuffer = isMatch(buffer, value, strictMatching);
if (remainingBuffer != null) {
result.add(new MethodTarget(method, command, remainingBuffer, value));
}
}
}
}
}
return result;
}
/**
* See whether 'buffer' could be an invocation of 'command', and if so, return the remaining part of the buffer.
* @param strictMatching true if ALL words of 'command' need to be matched
*/
static String isMatch(final String buffer, final String command, final boolean strictMatching) {
if ("".equals(buffer.trim())) {
return "";
}
String[] commandWords = StringUtils.delimitedListToStringArray(command, " ");
int lastCommandWordUsed = 0;
Assert.notEmpty(commandWords, "Command required");
String bufferToReturn = null;
String lastWord = null;
next_buffer_loop: for (int bufferIndex = 0; bufferIndex < buffer.length(); bufferIndex++) {
String bufferSoFarIncludingThis = buffer.substring(0, bufferIndex + 1);
String bufferRemaining = buffer.substring(bufferIndex + 1);
int bufferLastIndexOfWord = bufferSoFarIncludingThis.lastIndexOf(" ");
String wordSoFarIncludingThis = bufferSoFarIncludingThis;
if (bufferLastIndexOfWord != -1) {
wordSoFarIncludingThis = bufferSoFarIncludingThis.substring(bufferLastIndexOfWord);
}
if (wordSoFarIncludingThis.equals(" ") || bufferIndex == buffer.length() - 1) {
if (bufferIndex == buffer.length() - 1 && !"".equals(wordSoFarIncludingThis.trim())) {
lastWord = wordSoFarIncludingThis.trim();
}
// At end of word or buffer. Let's see if a word matched or not
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
if (lastCommandWordUsed == 0 && candidate > 0) {
// This is not a valid match
break next_buffer_loop;
}
}
if (bufferToReturn != null) {
// We already matched something earlier, so ensure we didn't skip any word
if (candidate != lastCommandWordUsed + 1) {
// User has skipped a word
bufferToReturn = null;
break next_buffer_loop;
}
}
bufferToReturn = bufferRemaining;
lastCommandWordUsed = candidate;
if (candidate + 1 == commandWords.length) {
// This was a match for the final word in the command, so abort
break next_buffer_loop;
}
// There are more words left to potentially match, so continue
continue next_buffer_loop;
}
}
// This word is unrecognised as part of a command, so abort
bufferToReturn = null;
break next_buffer_loop;
}
lastWord = wordSoFarIncludingThis.trim();
}
// We only consider it a match if ALL words were actually used
if (bufferToReturn != null) {
if (!strictMatching || lastCommandWordUsed + 1 == commandWords.length) {
return bufferToReturn;
}
}
return null; // Not a match
}
@Override
public int complete(String buffer, int cursor, final List<String> candidates) {
final List<Completion> completions = new ArrayList<Completion>();
int result = completeAdvanced(buffer, cursor, completions);
for (final Completion completion : completions) {
candidates.add(completion.getValue());
}
return result;
}
@Override
public int completeAdvanced(String buffer, int cursor, final List<Completion> candidates) {
synchronized (mutex) {
Assert.notNull(buffer, "Buffer required");
Assert.notNull(candidates, "Candidates list required");
// Remove all spaces from beginning of command
while (buffer.startsWith(" ")) {
buffer = buffer.replaceFirst("^ ", "");
cursor--;
}
// Begin by only including the portion of the buffer represented to the present cursor position
String translated = buffer.substring(0, cursor);
String successiveInvocationContext = trackSuccessiveCompletionRequests(translated);
// Start by locating a method that matches
final Collection<MethodTarget> targets = locateTargets(translated, false, true);
SortedSet<Completion> results = new TreeSet<Completion>(COMPARATOR);
if (targets.isEmpty()) {
// Nothing matches the buffer they've presented
return -1;
}
if (targets.size() > 1) {
// Assist them locate a particular target
for (MethodTarget target : targets) {
// Calculate the correct starting position
int startAt = translated.length();
// Only add the first word of each target
int stopAt = target.getKey().indexOf(" ", startAt);
if (stopAt == -1) {
stopAt = target.getKey().length();
}
results.add(new Completion(target.getKey().substring(0, stopAt) + " "));
}
candidates.addAll(results);
return 0;
}
// There is a single target of this method, so provide completion services for it
MethodTarget methodTarget = targets.iterator().next();
// Identify the command we're working with
CliCommand cmd = methodTarget.getMethod().getAnnotation(CliCommand.class);
Assert.notNull(cmd, "CliCommand unavailable for '" + methodTarget.getMethod().toGenericString() + "'");
// Make a reasonable attempt at parsing the remainingBuffer
Tokenizer tokenizer = null;
try {
tokenizer = new Tokenizer(methodTarget.getRemainingBuffer(), true);
}
catch (TokenizingException e) {
// Make sure we don't crash the main shell loop just
// because the user specified some option twice
return -1;
}
catch (IllegalArgumentException e) {
// Make sure we don't crash the main shell loop just
// because the user specified some option twice
return -1;
}
Map<String, String> options = tokenizer.getTokens();
// Lookup arguments for this target
Annotation[][] parameterAnnotations = methodTarget.getMethod().getParameterAnnotations();
// If there aren't any parameters for the method, at least ensure they have typed the command properly
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
}
}
candidates.addAll(results);
return 0;
}
// If they haven't specified any parameters yet, at least verify the command name is fully completed
if (options.isEmpty()) {
for (String value : cmd.value()) {
if (value.startsWith(buffer)) {
// They are potentially trying to type this command
// We only need provide completion, though, if they failed to specify it fully
if (!buffer.startsWith(value)) {
// They failed to specify the command fully
results.add(new Completion(value + " "));
}
}
}
// Only quit right now if they have to finish specifying the command name
if (results.size() > 0) {
candidates.addAll(results);
return 0;
}
}
// 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<CliOption> cliOptions = new ArrayList<CliOption>();
for (Annotation[] annotations : parameterAnnotations) {
CliOption cliOption = null;
for (Annotation a : annotations) {
if (a instanceof CliOption) {
cliOption = (CliOption) a;
}
}
Assert.notNull(cliOption, "CliOption not found for parameter '" + Arrays.toString(annotations) + "'");
cliOptions.add(cliOption);
}
// Make a list of all CliOptions they've already included or are system-provided
List<CliOption> alreadySpecified = new ArrayList<CliOption>();
for (CliOption option : cliOptions) {
for (String value : option.key()) {
if (options.containsKey(value)) {
alreadySpecified.add(option);
break;
}
}
if (option.systemProvided()) {
alreadySpecified.add(option);
}
}
// Make a list of all CliOptions they have not provided
List<CliOption> unspecified = new ArrayList<CliOption>(cliOptions);
unspecified.removeAll(alreadySpecified);
// Determine whether they're presently editing an option key or an option value
// (and if possible, the full or partial name of the said option key being edited)
String lastOptionKey = null;
String lastOptionValue = null;
// The last item in the options map is *always* the option key they're editing (will never be null)
if (options.size() > 0) {
lastOptionKey = new ArrayList<String>(options.keySet()).get(options.keySet().size() - 1);
lastOptionValue = options.get(lastOptionKey);
}
// 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("--") && !tokenizer.openingQuotesHaveNotBeenClosed()) {
boolean showAllRemaining = true;
for (CliOption include : unspecified) {
if (include.mandatory()) {
showAllRemaining = false;
break;
}
}
for (CliOption include : unspecified) {
for (String value : include.key()) {
if (!"".equals(value)) {
results.add(new Completion(translated + value + " "));
}
}
if (!showAllRemaining) {
break;
}
}
candidates.addAll(results);
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)
if (lastOptionKey == null
|| (!"".equals(lastOptionKey) && !"".equals(lastOptionValue) && translated.endsWith(" ") && !tokenizer
.openingQuotesHaveNotBeenClosed())) {
// 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)
for (CliOption include : unspecified) {
for (String value : include.key()) {
// 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) {
// Find the target parameter
Class<?> paramType = null;
int index = -1;
for (Annotation[] a : methodTarget.getMethod().getParameterAnnotations()) {
index++;
for (Annotation an : a) {
if (an instanceof CliOption) {
if (an.equals(include)) {
// Found the parameter, so store it
paramType = methodTarget.getMethod().getParameterTypes()[index];
break;
}
}
}
}
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
break;
}
}
}
catch (RuntimeException notYetReady) {
if (translated.endsWith(" ")) {
results.add(new Completion(translated + "--" + value + " "));
}
else {
results.add(new Completion(translated + " --" + value + " "));
}
continue;
}
}
// Handle normal mandatory options
if (!"".equals(value) && include.mandatory()) {
handleMandatoryCompletion(translated, unspecified, value, results);
}
}
}
// 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;
}
}
// Handle completing the option key they're presently typing
if (lastOptionKey != null && "".equals(lastOptionValue)
&& !translated.endsWith("" + tokenizer.getLastValueDelimiter())) {
// Given we haven't got an option value of any form, we must
// still be typing an option key.
for (CliOption option : unspecified) {
for (String value : option.key()) {
if (value != null && value.regionMatches(true, 0, lastOptionKey, 0, lastOptionKey.length())) {
String completionValue = translated.substring(0,
(translated.length() - lastOptionKey.length()))
+ value + " ";
results.add(new Completion(completionValue));
}
}
}
candidates.addAll(results);
return 0;
}
// To be here, we are NOT typing an option key (or we might be, and there are no further option keys left)
if (lastOptionKey != null && !"".equals(lastOptionKey)) {
// Lookup the relevant CliOption that applies to this lastOptionKey
// We do this via the parameter type
Class<?>[] parameterTypes = methodTarget.getMethod().getParameterTypes();
for (int i = 0; i < parameterTypes.length; i++) {
CliOption option = cliOptions.get(i);
Class<?> parameterType = parameterTypes[i];
for (String key : option.key()) {
if (key.equals(lastOptionKey)) {
List<Completion> allValues = new ArrayList<Completion>();
// We'll append the closing delimiter to proposals
String suffix = "" + tokenizer.getLastValueDelimiter();
if (!suffix.endsWith(" ")) {
suffix += " ";
}
// Let's use a Converter if one is available
for (Converter<?> candidate : converters) {
String optionContext = successiveInvocationContext + " " + option.optionContext();
if (candidate.supports(parameterType, optionContext)) {
// Found a usable converter
boolean allComplete = candidate.getAllPossibleValues(allValues, parameterType,
lastOptionValue, optionContext, methodTarget);
if (!allComplete) {
suffix = "";
}
break;
}
}
if (allValues.isEmpty()) {
// Doesn't appear to be a custom Converter, so let's go and provide defaults
// for simple types
completeForSimpleTypes(parameterType, allValues);
}
// Only include in the candidates those results which are compatible with the present buffer
for (Completion currentValue : allValues) {
// Only add the result **if** what they've typed is compatible *AND* they haven't
// already typed it in full
String proposal = currentValue.getValue();
if (proposal.toLowerCase().startsWith(lastOptionValue.toLowerCase())
&& lastOptionValue.length() < proposal.length()
&& (!tokenizer.lastValueIsComplete())) {
results.add(new Completion(tokenizer.escape(proposal) + suffix, currentValue
.getFormattedValue(), currentValue.getHeading(), currentValue.getOrder()));
}
}
// ROO-389: give inline options given there's multiple choices available and we want to help
// the user
displayHelp(lastOptionKey, option);
if (results.size() == 1) {
String suggestion = results.iterator().next().getValue().trim();
if (suggestion.equals(tokenizer.escape(lastOptionValue))) {
// They have pressed TAB in the default value, and the default value has already
// been provided as an explicit option
return -1;
}
}
if (results.size() > 0) {
candidates.addAll(results);
return methodTarget.getKey().length() + " ".length()
+ tokenizer.getLastValueStartOffset();
}
return 0;
}
}
}
}
return -1;
}
}
/**
* Track the number of times completion has been requested for the same buffer, resetting everytime the buffer
* changes.
* @return the portion of "option context" that indicates the number of invocation
*/
private String trackSuccessiveCompletionRequests(String translated) {
if (translated.equals(previousCompletionBuffer)) {
successiveCompletionRequests++;
}
else {
previousCompletionBuffer = translated;
successiveCompletionRequests = 1;
}
return Converter.TAB_COMPLETION_COUNT_PREFIX + successiveCompletionRequests;
}
private void displayHelp(String lastOptionKey, CliOption option) {
StringBuilder help = new StringBuilder();
help.append(OsUtils.LINE_SEPARATOR);
help.append(option.mandatory() ? "required --" : "optional --");
if ("".equals(option.help())) {
help.append(lastOptionKey).append(": ").append("No help available");
}
else {
help.append(lastOptionKey).append(": ").append(option.help());
}
if (option.specifiedDefaultValue().equals(option.unspecifiedDefaultValue())) {
if (option.specifiedDefaultValue().equals("__NULL__")) {
help.append("; no default value");
}
else {
help.append("; default: '").append(option.specifiedDefaultValue()).append("'");
}
}
else {
if (!"".equals(option.specifiedDefaultValue()) && !"__NULL__".equals(option.specifiedDefaultValue())) {
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("'");
}
}
LOGGER.info(help.toString());
}
private void completeForSimpleTypes(Class<?> parameterType, List<Completion> allValues) {
// Provide some simple options for common types
if (Boolean.class.isAssignableFrom(parameterType) || Boolean.TYPE.isAssignableFrom(parameterType)) {
allValues.add(new Completion("true"));
allValues.add(new Completion("false"));
}
if (Number.class.isAssignableFrom(parameterType)) {
allValues.add(new Completion("0"));
allValues.add(new Completion("1"));
allValues.add(new Completion("2"));
allValues.add(new Completion("3"));
allValues.add(new Completion("4"));
allValues.add(new Completion("5"));
allValues.add(new Completion("6"));
allValues.add(new Completion("7"));
allValues.add(new Completion("8"));
allValues.add(new Completion("9"));
}
}
/**
* 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<CliOption> unspecified, String value,
SortedSet<Completion> results) {
StringBuilder strBuilder = new StringBuilder(translated);
if (!translated.endsWith(" ")) {
strBuilder.append(" ");
}
// Plan change for SHL-20. But usability is bad.
/*
* List<List<String>> mandatoryOptions = getMandatoryOptions(unspecified); for (List<String> 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) {
synchronized (mutex) {
if (buffer == null) {
buffer = "";
}
StringBuilder sb = new StringBuilder();
// Figure out if there's a single command we can offer help for
final Collection<MethodTarget> matchingTargets = locateTargets(buffer, false, false);
if (matchingTargets.size() == 1) {
// Single command help
MethodTarget methodTarget = matchingTargets.iterator().next();
// Argument conversion time
Annotation[][] parameterAnnotations = methodTarget.getMethod().getParameterAnnotations();
if (parameterAnnotations.length > 0) {
// Offer specified help
CliCommand cmd = methodTarget.getMethod().getAnnotation(CliCommand.class);
Assert.notNull(cmd, "CliCommand not found");
for (String value : cmd.value()) {
sb.append("Keyword: ").append(value).append(OsUtils.LINE_SEPARATOR);
}
sb.append("Description: ").append(cmd.help()).append(OsUtils.LINE_SEPARATOR);
for (Annotation[] annotations : parameterAnnotations) {
CliOption cliOption = null;
for (Annotation a : annotations) {
if (a instanceof CliOption) {
cliOption = (CliOption) a;
for (String key : cliOption.key()) {
if ("".equals(key)) {
key = "** default **";
}
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(OsUtils.LINE_SEPARATOR);
}
}
Assert.notNull(cliOption, "CliOption not found for parameter '" + Arrays.toString(annotations)
+ "'");
}
}
// Only a single argument, so default to the normal help operation
}
SortedSet<String> result = new TreeSet<String>(COMPARATOR);
for (MethodTarget mt : matchingTargets) {
CliCommand cmd = mt.getMethod().getAnnotation(CliCommand.class);
if (cmd != null) {
for (String value : cmd.value()) {
if ("".equals(cmd.help())) {
result.add("* " + value);
}
else {
result.add("* " + value + " - " + cmd.help());
}
}
}
}
for (String s : result) {
sb.append(s).append(OsUtils.LINE_SEPARATOR);
}
LOGGER.info(sb.toString());
// LOGGER.warning("** Type 'hint' (without the quotes) and hit ENTER for step-by-step guidance **"
// + StringUtils.LINE_SEPARATOR);
}
}
public Set<String> getEveryCommand() {
synchronized (mutex) {
SortedSet<String> result = new TreeSet<String>(COMPARATOR);
for (Object o : commands) {
Method[] methods = o.getClass().getMethods();
for (Method m : methods) {
CliCommand cmd = m.getAnnotation(CliCommand.class);
if (cmd != null) {
result.addAll(Arrays.asList(cmd.value()));
}
}
}
return result;
}
}
public final void add(final CommandMarker command) {
synchronized (mutex) {
commands.add(command);
for (final Method method : command.getClass().getMethods()) {
CliAvailabilityIndicator availability = method.getAnnotation(CliAvailabilityIndicator.class);
if (availability != null) {
Assert.isTrue(
method.getParameterTypes().length == 0,
"CliAvailabilityIndicator is only legal for 0 parameter methods ("
+ method.toGenericString() + ")");
Assert.isTrue(
method.getReturnType().equals(Boolean.TYPE),
"CliAvailabilityIndicator is only legal for primitive boolean return types ("
+ method.toGenericString() + ")");
for (String cmd : availability.value()) {
Assert.isTrue(!availabilityIndicators.containsKey(cmd),
"Cannot specify an availability indicator for '" + cmd + "' more than once");
availabilityIndicators.put(cmd, new MethodTarget(method, command));
}
}
}
}
}
public final Set<CommandMarker> getCommandMarkers() {
synchronized (mutex) {
return Collections.unmodifiableSet(commands);
}
}
public final void remove(final CommandMarker command) {
synchronized (mutex) {
commands.remove(command);
for (Method m : command.getClass().getMethods()) {
CliAvailabilityIndicator availability = m.getAnnotation(CliAvailabilityIndicator.class);
if (availability != null) {
for (String cmd : availability.value()) {
availabilityIndicators.remove(cmd);
}
}
}
}
}
public final void add(final Converter<?> converter) {
synchronized (mutex) {
converters.add(converter);
}
}
public final void remove(final Converter<?> converter) {
synchronized (mutex) {
converters.remove(converter);
}
}
public final Set<Converter<?>> getConverters() {
synchronized (mutex) {
return Collections.unmodifiableSet(converters);
}
}
}