Introduce -standard and -standard-commands artifacts

Fixes #43
This commit is contained in:
Eric Bottard
2017-05-27 14:48:54 +02:00
parent 5a2658b0e4
commit ec83869c9f
26 changed files with 74 additions and 12 deletions

View File

@@ -25,11 +25,8 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell2.standard.EnumValueProvider;
import org.springframework.shell2.standard.StandardParameterResolver;
/**
* Main entry point for the application.

View File

@@ -1,41 +0,0 @@
/*
* Copyright 2015 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.shell2.commands;
import org.jline.terminal.Terminal;
import org.jline.utils.InfoCmp;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell2.standard.ShellComponent;
import org.springframework.shell2.standard.ShellMethod;
/**
* ANSI console related commands.
*
* @author Eric Bottard
*/
@ShellComponent
public class Console {
@Autowired
private Terminal terminal;
@ShellMethod(help = "Clear the shell screen.")
public void clear() {
terminal.puts(InfoCmp.Capability.clear_screen);
}
}

View File

@@ -1,255 +0,0 @@
/*
* Copyright 2015 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.shell2.commands;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toCollection;
import java.io.IOException;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.MethodTarget;
import org.springframework.shell2.ParameterDescription;
import org.springframework.shell2.ParameterResolver;
import org.springframework.shell2.Shell;
import org.springframework.shell2.standard.CommandValueProvider;
import org.springframework.shell2.standard.ShellComponent;
import org.springframework.shell2.standard.ShellMethod;
import org.springframework.shell2.standard.ShellOption;
import org.springframework.shell2.Utils;
/**
* A command to display help about all available commands.
*
* @author Eric Bottard
*/
@ShellComponent
public class Help {
private final List<ParameterResolver> parameterResolvers;
private Shell shell;
@Autowired
public Help(List<ParameterResolver> parameterResolvers) {
this.parameterResolvers = parameterResolvers;
}
@Autowired // ctor injection impossible b/c of circular dependency
public void setShell(Shell shell) {
this.shell = shell;
}
@ShellMethod(help = "Display help about available commands.", prefix = "-")
public CharSequence help(
@ShellOption(defaultValue = ShellOption.NULL,
valueProvider = CommandValueProvider.class,
value = {"-C", "--command"},
help = "The command to obtain help for.") String command) throws IOException {
if (command == null) {
return listCommands();
}
else {
return documentCommand(command);
}
}
/**
* Return a description of a specific command. Uses a layout inspired by *nix man pages.
*/
private CharSequence documentCommand(String command) {
MethodTarget methodTarget = shell.listCommands().get(command);
if (methodTarget == null) {
throw new IllegalArgumentException("Unknown command '" + command + "'");
}
// NAME
AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n");
result.append("NAME", AttributedStyle.BOLD).append("\n\t");
result.append(command).append(" - ").append(methodTarget.getHelp()).append("\n\n");
// SYNOPSYS
result.append("SYNOPSYS", AttributedStyle.BOLD).append("\n\t");
result.append(command, AttributedStyle.BOLD);
result.append(" ");
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
for (ParameterDescription description : parameterDescriptions) {
if (description.defaultValue().isPresent()) {
result.append("["); // Whole parameter is optional, as there is a default value (1)
}
List<String> keys = description.keys();
if(!keys.isEmpty()) {
if (!description.mandatoryKey()) {
result.append("["); // Specifying a key is optional (ie positional params). (2)
}
result.append(first(keys), AttributedStyle.BOLD);
if (!description.mandatoryKey()) {
result.append("]"); // (close 2)
}
if (!description.formal().isEmpty()) {
result.append(" ");
}
}
if (description.defaultValueWhenFlag().isPresent()) {
result.append("["); // Parameter can be used as a toggle flag (3)
}
appendUnderlinedFormal(result, description);
if (description.defaultValueWhenFlag().isPresent()) {
result.append("]"); // (close 3)
}
if (description.defaultValue().isPresent()) {
result.append("]"); // (close 1)
}
result.append(" "); // two spaces between each param for better legibility
}
result.append("\n\n");
// OPTIONS
if (!parameterDescriptions.isEmpty()) {
result.append("OPTIONS", AttributedStyle.BOLD).append("\n");
}
for (ParameterDescription description : parameterDescriptions) {
result.append("\t").append(description.keys().stream().collect(Collectors.joining(" or ")), AttributedStyle.BOLD);
if (description.formal().length() > 0) {
if (!description.keys().isEmpty()) {
result.append(" ");
}
description.defaultValueWhenFlag().ifPresent(f -> result.append('['));
appendUnderlinedFormal(result, description);
description.defaultValueWhenFlag().ifPresent(f -> result.append(']'));
result.append("\n\t");
}
else if (description.keys().size() > 1) {
result.append("\n\t");
}
result.append("\t");
result.append(description.help());
// Optional parameter
if (description.defaultValue().isPresent()) {
result
.append(" [Optional, default = ", AttributedStyle.BOLD)
.append(description.defaultValue().get(), AttributedStyle.BOLD.italic());
description.defaultValueWhenFlag().ifPresent(
s -> result.append(", or ", AttributedStyle.BOLD)
.append(s, AttributedStyle.BOLD.italic())
.append(" if used as a flag", AttributedStyle.BOLD)
);
result.append("]", AttributedStyle.BOLD);
} // Mandatory parameter, but with a default when used as a flag
else if (description.defaultValueWhenFlag().isPresent()) {
result
.append(" [Mandatory, default = ", AttributedStyle.BOLD)
.append(description.defaultValueWhenFlag().get(), AttributedStyle.BOLD.italic())
.append(" when used as a flag]", AttributedStyle.BOLD)
;
} // true mandatory parameter
else {
result.append(" [Mandatory]", AttributedStyle.BOLD);
}
result.append("\n\n");
}
// ALSO KNOWN AS
Set<String> aliases = shell.listCommands().entrySet().stream()
.filter(e -> e.getValue().equals(methodTarget))
.map(Map.Entry::getKey)
.filter(c -> !command.equals(c))
.collect(toCollection(TreeSet::new));
if (!aliases.isEmpty()) {
result.append("ALSO KNOWN AS", AttributedStyle.BOLD).append("\n");
for (String alias : aliases) {
result.append('\t').append(alias).append('\n');
}
}
result.append("\n");
return result;
}
private String first(List<String> keys) {
return keys.iterator().next();
}
private CharSequence listCommands() {
Map<String, Set<String>> groupedByMethodTarget = shell.listCommands().entrySet().stream()
.collect(Collectors.groupingBy(e -> e.getValue().getHelp(), // Use help() as the grouping key
mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into a sorted set
// Then display commands, sorted alphabetically by their first alias
AttributedStringBuilder result = new AttributedStringBuilder();
result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD);
groupedByMethodTarget.entrySet().stream()
.sorted(sortByFirstElement())
.forEach(e -> result.append("\t")
.append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD)
.append(": ")
.append(e.getKey())
.append('\n')
);
return result.append("\n");
}
private Comparator<Map.Entry<String, Set<String>>> sortByFirstElement() {
return Comparator.comparing(e -> e.getValue().iterator().next());
}
private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDescription description) {
for (char c : description.formal().toCharArray()) {
if (c != ' ') {
result.append("" + c, AttributedStyle.DEFAULT.underline());
}
else {
result.append(c);
}
}
}
private List<ParameterDescription> getParameterDescriptions(MethodTarget methodTarget) {
Parameter[] parameters = methodTarget.getMethod().getParameters();
List<ParameterDescription> parameterDescriptions = new ArrayList<>();
for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) {
for (ParameterResolver parameterResolver : parameterResolvers) {
MethodParameter methodParameter = Utils.createMethodParameter(methodTarget.getMethod(), i);
if (parameterResolver.supports(methodParameter)) {
parameterDescriptions.add(parameterResolver.describe(methodParameter));
break;
}
}
}
return parameterDescriptions;
}
}

View File

@@ -1,35 +0,0 @@
/*
* Copyright 2015 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.shell2.commands;
import org.springframework.shell2.ExitRequest;
import org.springframework.shell2.standard.ShellComponent;
import org.springframework.shell2.standard.ShellMethod;
/**
* A command that terminates the running shell.
*
* @author Eric Bottard
*/
@ShellComponent
public class Quit {
@ShellMethod(help = "Exit the shell.", value = {"quit", "exit"})
public void quit() {
throw new ExitRequest();
}
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright 2017 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.
*/
/**
* Contains default commands that ought to apply to each shell app.
*
* @author Eric Bottard
*/
package org.springframework.shell2.commands;

View File

@@ -1,55 +0,0 @@
/*
* Copyright 2017 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.shell2.standard;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
import org.springframework.shell2.Shell;
import org.springframework.stereotype.Component;
/**
* A {@link ValueProvider} that can be used to auto-complete names of shell commands.
*
* @author Eric Bottard
*/
@Component
public class CommandValueProvider extends ValueProviderSupport {
private final Shell shell;
@Lazy
@Autowired
public CommandValueProvider(Shell shell) {
this.shell = shell;
}
@Override
public List<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) {
return shell.listCommands().keySet().stream()
.map(CompletionProposal::new)
.collect(Collectors.toList());
}
}

View File

@@ -1,54 +0,0 @@
/*
* Copyright 2016 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.shell2.standard;
import java.util.ArrayList;
import java.util.List;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
import org.springframework.stereotype.Component;
/**
* A {@link ValueProvider} that knows how to complete values for {@link Enum} typed parameters.
* @author Eric Bottard
*/
@Component
public class EnumValueProvider implements ValueProvider {
@Override
public boolean supports(MethodParameter parameter, CompletionContext completionContext) {
return Enum.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public List<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) {
List<CompletionProposal> result = new ArrayList<>();
for (Object v : parameter.getParameterType().getEnumConstants()) {
Enum e = (Enum) v;
String prefix = completionContext.currentWordUpToCursor();
if (prefix == null) {
prefix = "";
}
if (e.name().startsWith(prefix)) {
result.add(new CompletionProposal(e.name()));
}
}
return result;
}
}

View File

@@ -1,47 +0,0 @@
/*
* Copyright 2015 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.shell2.standard;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.stereotype.Component;
/**
* Indicates that an annotated class may contain shell methods (themselves annotated with {@link ShellMethod}) that
* is,
* methods that may be invoked reflectively by the shell.
*
* <p>This annotation is a specialization of {@link Component}.</p>
*
* @author Eric Bottard
* @see Component
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Component
public @interface ShellComponent {
/**
* Used to indicate a suggestion for a logical name for the component.
*/
String value() default "";
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright 2015 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.shell2.standard;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Used to mark a method as invokable via Spring Shell.
*
* @author Eric Bottard
* @author Florent Biville
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface ShellMethod {
/**
* The name(s) by which this method can be invoked via Spring Shell. If not specified, the actual method name
* will be used (turning camelCase humps into "-").
*/
String[] value() default {};
/**
* A description for the command. Should not contain any formatting (e.g. html) characters and would typically
* start with a capital letter and end with a dot.
*/
String help() default "";
/**
* The prefix to use for assigning parameters by name.
*/
String prefix() default "--";
}

View File

@@ -1,66 +0,0 @@
/*
* Copyright 2015 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.shell2.standard;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Used to customize handling of a {@link ShellMethod} parameter.
*
* @author Eric Bottard
* @author Florent Biville
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface ShellOption {
String NULL = "__NULL__";
String NONE = "__NONE__";
/**
* The key(s) (without the {@link ShellMethod#prefix()}) by which this parameter can be referenced
* when using named parameters. If none is specified, the actual method parameter name will be used.
*/
String[] value() default {};
/**
* Return the number of input "words" this parameter consumes.
*/
int arity() default 1;
/**
* The textual (pre-conversion) value to assign to this parameter if no value is provided by the user.
*/
String defaultValue() default NONE;
/**
* Return a short description of the parameter.
*/
String help() default "";
Class<? extends ValueProvider> valueProvider() default NoValueProvider.class;
interface NoValueProvider extends ValueProvider {
}
}

View File

@@ -1,73 +0,0 @@
/*
* Copyright 2015-2017 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.shell2.standard;
import static org.springframework.util.StringUtils.collectionToCommaDelimitedString;
import static org.springframework.util.StringUtils.collectionToDelimitedString;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.shell2.MethodTarget;
import org.springframework.shell2.MethodTargetResolver;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/**
* The standard implementation of {@link MethodTargetResolver} for new shell applications,
* resolves methods annotated with {@link ShellMethod} on {@link ShellComponent} beans.
*
* @author Eric Bottard
* @author Florent Biville
* @author Camilo Gonzalez
*/
@Component
public class StandardMethodTargetResolver implements MethodTargetResolver {
@Autowired
private ApplicationContext applicationContext;
@Override
public Map<String, MethodTarget> resolve() {
Map<String, MethodTarget> methodTargets = new HashMap<>();
Map<String, Object> commandBeans = applicationContext.getBeansWithAnnotation(ShellComponent.class);
for (Object bean : commandBeans.values()) {
Class<?> clazz = bean.getClass();
ReflectionUtils.doWithMethods(clazz, method -> {
ShellMethod shellMapping = method.getAnnotation(ShellMethod.class);
String[] keys = shellMapping.value();
if (keys.length == 0) {
keys = new String[] {method.getName()};
}
for (String key : keys) {
methodTargets.put(key, new MethodTarget(method, bean, shellMapping.help()));
}
}, method -> method.getAnnotation(ShellMethod.class) != null);
}
return methodTargets;
}
@Override
public String toString() {
return getClass().getSimpleName() + " contributing "
+ collectionToDelimitedString(resolve().keySet(), ", ", "[", "]");
}
}

View File

@@ -1,492 +0,0 @@
/*
* Copyright 2015 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.shell2.standard;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
import org.springframework.shell2.ParameterDescription;
import org.springframework.shell2.ParameterMissingResolutionException;
import org.springframework.shell2.ParameterResolver;
import org.springframework.shell2.UnfinishedParameterResolutionException;
import org.springframework.shell2.Utils;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.ObjectUtils;
import static org.springframework.shell2.Utils.unCamelify;
/**
* Default ParameterResolver implementation that supports the following features:<ul>
* <li>named parameters (recognized because they start with some {@link ShellMethod#prefix()})</li>
* <li>implicit named parameters (from the actual method parameter name)</li>
* <li>positional parameters (in order, for all parameter values that were not resolved <i>via</i> named
* parameters)</li>
* <li>default values (for all remaining parameters)</li>
* </ul>
*
* <p>Method arguments can consume several words of input at once (driven by {@link ShellOption#arity()}, default 1).
* If several words are consumed, they will be joined together as a comma separated value and passed to the {@link
* ConversionService}
* (which will typically return a List or array).</p>
*
* <p>Boolean parameters are by default expected to have an arity of 0, allowing invocations in the form {@code rm
* --force --dir /foo}:
* the presence of {@code --force} passes {@code true} as a parameter value, while its absence passes {@code false}.
* Both
* the default arity of 0 and the default value of {@code false} can be overridden <i>via</i> {@link ShellOption}
* if needed.</p>
* @author Eric Bottard
* @author Florent Biville
*/
@Component
public class StandardParameterResolver implements ParameterResolver {
private final ConversionService conversionService;
private Collection<ValueProvider> valueProviders = new HashSet<>();
/**
* A cache from method+input to String representation of actual parameter values.
* Note that the converted result is not cached, to allow dynamic computation to happen at every invocation
* if needed (e.g. if a remote service is involved).
*/
private final Map<CacheKey, Map<Parameter, ParameterRawValue>> parameterCache = new ConcurrentReferenceHashMap<>();
@Autowired
public StandardParameterResolver(ConversionService conversionService) {
this.conversionService = conversionService;
}
@Autowired(required = false)
public void setValueProviders(Collection<ValueProvider> valueProviders) {
this.valueProviders = valueProviders;
}
@Override
public boolean supports(MethodParameter parameter) {
return parameter.getMethodAnnotation(ShellMethod.class) != null;
}
@Override
public Object resolve(MethodParameter methodParameter, List<String> words) {
String prefix = prefixForMethod(methodParameter);
CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), words);
Map<Parameter, ParameterRawValue> resolved = parameterCache.computeIfAbsent(cacheKey, (k) -> {
Map<Parameter, ParameterRawValue> result = new HashMap<>();
Map<String, String> namedParameters = new HashMap<>();
List<String> positionalValues = new ArrayList<>();
Set<String> possibleKeys = gatherAllPossibleKeys(methodParameter.getMethod());
// First, resolve all parameters passed by-name
for (int i = 0; i < words.size(); i++) {
String word = words.get(i);
if (possibleKeys.contains(word)) {
String key = word;
Parameter parameter = lookupParameterForKey(methodParameter.getMethod(), key, prefix);
int arity = getArity(parameter);
if (i + 1 + arity > words.size()) {
String input = words.subList(i, words.size()).stream().collect(Collectors.joining(" "));
throw new UnfinishedParameterResolutionException(describe(Utils.createMethodParameter(parameter)), input);
}
Assert.isTrue(i + 1 + arity <= words.size(), String.format("Not enough input for parameter '%s'", word));
String raw = words.subList(i + 1, i + 1 + arity).stream().collect(Collectors.joining(","));
Assert.isTrue(!namedParameters.containsKey(key), String.format("Parameter for '%s' has already been specified", word));
namedParameters.put(key, raw);
result.put(parameter, ParameterRawValue.explicit(raw, key));
i += arity;
if (arity == 0) {
boolean defaultValue = booleanDefaultValue(parameter);
// Boolean parameter has been specified. Use the opposite of the default value
result.put(parameter, ParameterRawValue.explicit(String.valueOf(!defaultValue), key));
}
} // store for later processing of positional params
else {
positionalValues.add(word);
}
}
// Now have a second pass over params and treat them as positional
int offset = 0;
Parameter[] parameters = methodParameter.getMethod().getParameters();
for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) {
Parameter parameter = parameters[i];
// Compute the intersection between possible keys for the param and what we've already seen for named params
Collection<String> keys = getKeysForParameter(methodParameter.getMethod(), i).collect(Collectors.toSet());
Collection<String> copy = new HashSet<>(keys);
copy.retainAll(namedParameters.keySet());
if (copy.isEmpty()) { // Was not set via a key (including aliases), must be positional
int arity = getArity(parameter);
if (arity > 0 && (offset + arity) <= positionalValues.size()) {
String raw = positionalValues.subList(offset, offset + arity).stream().collect(Collectors.joining(","));
result.put(parameter, ParameterRawValue.explicit(raw, null));
offset += arity;
} // No more input. Try defaultValues
else {
Optional<String> defaultValue = defaultValueFor(parameter);
defaultValue.ifPresent(value -> result.put(parameter, ParameterRawValue.implicit(value, null)));
}
}
else if (copy.size() > 1) {
throw new IllegalArgumentException("Named parameter has been specified multiple times via " + quote(copy));
}
}
Assert.isTrue(offset == positionalValues.size(), "Too many arguments: the following could not be mapped to parameters: "
+ positionalValues.subList(offset, positionalValues.size()).stream().collect(Collectors.joining(" ", "'", "'")));
return result;
});
Parameter param = methodParameter.getMethod().getParameters()[methodParameter.getParameterIndex()];
if (!resolved.containsKey(param)) {
throw new ParameterMissingResolutionException(describe(methodParameter));
}
ParameterRawValue parameterRawValue = resolved.get(param);
return convertRawValue(parameterRawValue, methodParameter);
}
private Object convertRawValue(ParameterRawValue parameterRawValue, MethodParameter methodParameter) {
String s = parameterRawValue.value;
if (ShellOption.NULL.equals(s)) {
return null;
}
else {
return conversionService.convert(s, TypeDescriptor.valueOf(String.class), new TypeDescriptor(methodParameter));
}
}
private Set<String> gatherAllPossibleKeys(Method method) {
final String prefix = "--";
return Arrays.stream(method.getParameters())
.flatMap(p -> {
ShellOption option = p.getAnnotation(ShellOption.class);
if (option != null && option.value().length > 0) {
return Arrays.stream(option.value());
}
else {
return Stream.of(prefix + Utils.createMethodParameter(p).getParameterName());
}
}).collect(Collectors.toSet());
}
private String prefixForMethod(MethodParameter methodParameter) {
return methodParameter.getMethod().getAnnotation(ShellMethod.class).prefix();
}
private Optional<String> defaultValueFor(Parameter parameter) {
Optional<String> defaultValue = Optional.empty();
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && !ShellOption.NONE.equals(option.defaultValue())) {
defaultValue = Optional.of(option.defaultValue());
}
else if (option == null && getArity(parameter) == 0) {
return Optional.of("false");
}
return defaultValue;
}
private boolean booleanDefaultValue(Parameter parameter) {
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && !ShellOption.NULL.equals(option.defaultValue())) {
return Boolean.parseBoolean(option.defaultValue());
}
return false;
}
@Override
public ParameterDescription describe(MethodParameter parameter) {
Parameter jlrParameter = parameter.getMethod().getParameters()[parameter.getParameterIndex()];
int arity = getArity(jlrParameter);
Class<?> type = parameter.getParameterType();
ShellOption option = jlrParameter.getAnnotation(ShellOption.class);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < arity; i++) {
if (i > 0) {
sb.append(" ");
}
sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName()) : unCamelify(type.getSimpleName()));
}
ParameterDescription result = ParameterDescription.outOf(parameter);
result.formal(sb.toString());
if (option != null) {
result.help(option.help());
Optional<String> defaultValue = defaultValueFor(jlrParameter);
if (defaultValue.isPresent()) {
result.defaultValue(defaultValue.map(dv -> dv.equals(ShellOption.NULL) ? "<none>" : dv).get());
}
}
result
.keys(getKeysForParameter(parameter.getMethod(), parameter.getParameterIndex())
.collect(Collectors.toList()))
.mandatoryKey(false);
return result;
}
@Override
public List<CompletionProposal> complete(MethodParameter methodParameter, CompletionContext context) {
boolean set;
Exception unfinished = null;
// First try to see if this parameter has been set, even to some unfinished value
ParameterRawValue parameterRawValue = null;
int arity = 1;
try {
resolve(methodParameter, context.getWords());
CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), context.getWords());
Parameter parameter = methodParameter.getMethod().getParameters()[methodParameter.getParameterIndex()];
arity = getArity(parameter);
parameterRawValue = parameterCache.get(cacheKey).get(parameter);
set = parameterRawValue.explicit;
}
catch (ParameterMissingResolutionException e) {
set = false;
}
catch (Exception e) {
unfinished = e;
set = false;
// Most likely what is already typed would fail resolution (eg type conversion failure)
// Exit early and let other parameters have a chance at being proposed
//return Collections.emptyList();
}
// There are 4 possible cases:
// 1) parameter not set at all
// 2) parameter set via its key, not enough input to consume a value
// 3) parameter set with multiple values, enough to cover arity. We're done
// 4) parameter set, and some value bound. But maybe that value is just a prefix to what the user actually wants
// 4.1) or maybe that value was resolved by position, but is a prefix of an actual valid key
if (!set) {
if (unfinished == null) { // case 1 above
return commandsThatStartWithContextPrefix(methodParameter, context);
} // case 2
else {
return valueCompletions(methodParameter, context);
}
}
else {
List<CompletionProposal> result = new ArrayList<>();
String prefix = context.currentWordUpToCursor() != null ? context.currentWordUpToCursor() : "";
// TODO: should not look at last word only, but everything after what was used for key
Object value = convertRawValue(parameterRawValue, methodParameter);
if (value instanceof Collection && ((Collection) value).size() == arity
|| (ObjectUtils.isArray(value) && Array.getLength(value) == arity)) {
// We're done already
return result;
}
// Case 4
result.addAll(valueCompletions(methodParameter, context));
if (parameterRawValue.positional()) {
// Case 4.1: There exists "--command foo" and user has typed "--comm" which (wrongly) got resolved as a positional param
result.addAll(commandsThatStartWithContextPrefix(methodParameter, context));
}
return result;
}
}
private List<CompletionProposal> valueCompletions(MethodParameter methodParameter, CompletionContext completionContext) {
return valueProviders.stream()
.filter(vp -> vp.supports(methodParameter, completionContext))
.map(vp -> vp.complete(methodParameter, completionContext, null))
.findFirst().orElseGet(() -> Collections.emptyList());
}
private List<CompletionProposal> commandsThatStartWithContextPrefix(MethodParameter methodParameter, CompletionContext context) {
String prefix = context.currentWordUpToCursor() != null ? context.currentWordUpToCursor() : "";
return describe(methodParameter).keys().stream()
.filter(k -> k.startsWith(prefix))
.map(CompletionProposal::new)
.collect(Collectors.toList());
}
/**
* In case of {@code foo[] or Collection<Foo>} and arity > 1, return the element type.
*/
private Class<?> removeMultiplicityFromType(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
if (parameterType.isArray()) {
return parameterType.getComponentType();
}
else if (Collection.class.isAssignableFrom(parameterType)) {
return parameter.getNestedParameterType();
}
else {
throw new RuntimeException("For " + parameter + " (with arity > 1) expected an array/collection type");
}
}
/**
* Surrounds the parameter keys with quotes.
*/
private String quote(Collection<String> keys) {
return keys.stream().collect(Collectors.joining(", ", "'", "'"));
}
/**
* Return the arity of a given parameter. The default arity is 1, except for
* booleans where arity is 0 (can be overridden back to 1 via an annotation)
*/
private int getArity(Parameter parameter) {
ShellOption option = parameter.getAnnotation(ShellOption.class);
int inferred = (parameter.getType() == boolean.class || parameter.getType() == Boolean.class) ? 0 : 1;
return option != null ? option.arity() : inferred;
}
/**
* Return the key(s) the i-th parameter of the command method, resolved either from the {@link ShellOption}
* annotation,
* or from the actual parameter name.
*/
private Stream<String> getKeysForParameter(Method method, int index) {
String prefix = "--";
Parameter p = method.getParameters()[index];
ShellOption option = p.getAnnotation(ShellOption.class);
if (option != null && option.value().length > 0) {
return Arrays.stream(option.value());
}
else {
return Stream.of(prefix + Utils.createMethodParameter(p).getParameterName());
}
}
/**
* Return the method parameter that should be bound to the given key.
*/
private Parameter lookupParameterForKey(Method method, String key, String prefix) {
Parameter[] parameters = method.getParameters();
for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) {
Parameter p = parameters[i];
if (getKeysForParameter(method, i).anyMatch(k -> k.equals(key))) {
return p;
}
}
throw new IllegalArgumentException(String.format("Could not look up parameter for '%s%s' in %s", prefix, key, method));
}
private static class CacheKey {
private final Method method;
private final List<String> words;
private CacheKey(Method method, List<String> words) {
this.method = method;
this.words = words;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CacheKey cacheKey = (CacheKey) o;
return Objects.equals(method, cacheKey.method) &&
Objects.equals(words, cacheKey.words);
}
@Override
public int hashCode() {
return Objects.hash(method, words);
}
@Override
public String toString() {
return method.getName() + " " + words;
}
}
private static class ParameterRawValue {
private CompletionContext context;
private int from;
private int to;
private Integer keyIndex;
/**
* The raw String value that got bound to a parameter.
*/
private final String value;
/**
* If false, the value resolved is the result of applying defaults.
*/
private final boolean explicit;
/**
* The key that was used to set the parameter, or null if resolution happened by position.
*/
private final String key;
private ParameterRawValue(String value, boolean explicit, String key) {
this.value = value;
this.explicit = explicit;
this.key = key;
}
public static ParameterRawValue explicit(String value, String key) {
return new ParameterRawValue(value, true, key);
}
public static ParameterRawValue implicit(String value, String key) {
return new ParameterRawValue(value, false, key);
}
public boolean positional() {
return key == null;
}
@Override
public String toString() {
return "ParameterRawValue{" +
"value='" + value + '\'' +
", explicit=" + explicit +
", key='" + key + '\'' +
'}';
}
}
}

View File

@@ -1,32 +0,0 @@
/*
* Copyright 2016 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.shell2.standard;
import java.util.List;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
/**
*/
public interface ValueProvider {
boolean supports(MethodParameter parameter, CompletionContext completionContext);
List<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints);
}

View File

@@ -1,38 +0,0 @@
/*
* Copyright 2016 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.shell2.standard;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.CompletionContext;
/**
* Base class for {@link ValueProvider} that match by type. Subclasses {@literal C} will be selected for parameters
* whose {@literal @}{@link ShellOption#valueProvider()} return the concrete class {@literal C}.
*
* @author Eric Bottard
*/
public abstract class ValueProviderSupport implements ValueProvider {
@Override
public boolean supports(MethodParameter parameter, CompletionContext completionContext) {
ShellOption annotation = parameter.getParameterAnnotation(ShellOption.class);
if (annotation == null) {
return false;
}
return annotation.valueProvider().isAssignableFrom(this.getClass());
}
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright 2017 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.
*/
/**
* Contains infrastructure for describing commands with the "new" preferred Spring Shell programming model.
*
* @author Eric Bottard
*/
package org.springframework.shell2.standard;

View File

@@ -1,157 +0,0 @@
/*
* Copyright 2015 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.shell2.commands;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.assertj.core.api.Assertions;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestName;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.io.ClassPathResource;
import org.springframework.shell2.standard.StandardParameterResolver;
import org.springframework.shell2.MethodTarget;
import org.springframework.shell2.ParameterResolver;
import org.springframework.shell2.Shell;
import org.springframework.shell2.standard.ShellComponent;
import org.springframework.shell2.standard.ShellMethod;
import org.springframework.shell2.standard.ShellOption;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.ReflectionUtils;
/**
* Tests for the {@link Help} command.
*
* @author Eric Bottard
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = HelpTest.Config.class)
public class HelpTest {
@Autowired
private Help help;
@Rule
public TestName testName = new TestName();
@Test
public void testCommandHelp() throws Exception {
CharSequence help = this.help.help("first-command").toString();
Assertions.assertThat(help).isEqualTo(sample());
}
@Test
public void testCommandList() throws Exception {
String list = this.help.help(null).toString();
Assertions.assertThat(list).isEqualTo(sample());
}
@Test(expected = IllegalArgumentException.class)
public void testUnknownCommand() throws Exception {
this.help.help("some unknown command");
}
private String sample() throws IOException {
InputStream is = new ClassPathResource(HelpTest.class.getSimpleName() + "-" + testName.getMethodName() + ".txt", HelpTest.class).getInputStream();
return FileCopyUtils.copyToString(new InputStreamReader(is, "UTF-8")).replace("&", "");
}
@Configuration
static class Config {
@Bean
public Help help() {
return new Help(Collections.singletonList(parameterResolver()));
}
@Bean
public Shell shell() {
return () -> {
Map<String, MethodTarget> result = new HashMap<>();
Method method = ReflectionUtils.findMethod(Commands.class, "firstCommand", boolean.class, boolean.class, int.class, float[].class);
MethodTarget methodTarget = new MethodTarget(method, commands(), "A rather extensive description of some command.");
result.put("first-command", methodTarget);
result.put("1st-command", methodTarget);
method = ReflectionUtils.findMethod(Commands.class, "secondCommand");
methodTarget = new MethodTarget(method, commands(), "The second command. This one is known under several aliases as well.");
result.put("second-command", methodTarget);
result.put("yet-another-command", methodTarget);
method = ReflectionUtils.findMethod(Commands.class, "thirdCommand");
methodTarget = new MethodTarget(method, commands(), "The last command.");
result.put("third-command", methodTarget);
return result;
};
}
@Bean
public ParameterResolver parameterResolver() {
return new StandardParameterResolver(new DefaultConversionService());
}
@Bean
public Object commands() {
return new Commands();
}
}
@ShellComponent
static class Commands {
@ShellMethod(prefix = "--")
public void firstCommand(
// Single key and arity = 0. Help displayed on same line
@ShellOption(help = "Whether to delete recursively", arity = 0, value = "-r") boolean r,
// Multiple keys and arity 0. Help displayed on next line
@ShellOption(help = "Do not ask for confirmation. YOLO", arity = 0, value = {"-f", "--force"}) boolean force,
// Single key, arity >= 1. Help displayed on next line. Optional
@ShellOption(help = "The answer to everything", defaultValue = "42", value = "-n") int n,
// Single key, arity > 1.
@ShellOption(help = "Some other parameters", arity = 3, value = "-o") float[] o
) {
}
@ShellMethod
public void secondCommand() {
}
@ShellMethod
public void thirdCommand() {
}
}
}

View File

@@ -1,92 +0,0 @@
/*
* Copyright 2017 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.shell2.standard;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.assertj.core.api.Assertions;
import org.hamcrest.CoreMatchers;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
import org.springframework.shell2.MethodTarget;
import org.springframework.shell2.Shell;
import org.springframework.shell2.Utils;
import org.springframework.util.ReflectionUtils;
/**
* Unit tests for {@link CommandValueProvider}.
*
* @author Eric Bottard
*/
public class CommandValueProviderTest {
@Mock
private Shell shell;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}
@Test
public void testValues() {
CommandValueProvider valueProvider = new CommandValueProvider(shell);
Method help = ReflectionUtils.findMethod(Command.class, "help", String.class);
MethodParameter methodParameter = Utils.createMethodParameter(help, 0);
CompletionContext completionContext = new CompletionContext(Arrays.asList("help", "m"), 0, 0);
boolean supports = valueProvider.supports(methodParameter, completionContext);
assertThat(supports).isEqualTo(true);
Map<String, MethodTarget> commands = new HashMap<>();
commands.put("me", null);
commands.put("meow", null);
commands.put("yourself", null);
when(shell.listCommands()).thenReturn(commands);
List<CompletionProposal> proposals = valueProvider.complete(methodParameter, completionContext, new String[0]);
assertThat(proposals).extracting("value", String.class)
.contains("me", "meow", "yourself");
}
public static class Command {
public void help(@ShellOption(valueProvider = CommandValueProvider.class) String command) {
}
}
}

View File

@@ -1,88 +0,0 @@
/*
* Copyright 2015 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.shell2.standard;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
/**
* An example commands class.
*
* @author Eric Bottard
* @author Florent Biville
*/
public class Remote {
/**
* A command method that showcases<ul>
* <li>default handling for booleans (force)</li>
* <li>default parameter name discovery (name)</li>
* <li>default value supplying (foo and bar)</li>
* </ul>
*/
@ShellMethod(help = "switch channels")
public void zap(boolean force,
String name,
@ShellOption(defaultValue="defoolt") String foo,
@ShellOption(value = {"--bar", "--baz"}, defaultValue = "last") String bar) {
}
@ShellMethod(help = "bye bye")
public void shutdown(@ShellOption Delay delay) {
}
@ShellMethod(help = "add 3 numbers together")
public void add(@ShellOption(arity = 3, valueProvider = NumberValueProvider.class) List<Integer> numbers) {
}
@ShellMethod(help = "add 3 numbers together (array)")
public void addAsArray(@ShellOption(arity = 3, valueProvider = NumberValueProvider.class) int[] numbers) {
}
public enum Delay {
small, medium, big;
}
public static class NumberValueProvider extends ValueProviderSupport {
private final String[] values;
public NumberValueProvider(String... values) {
this.values = values;
}
@Override
public List<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) {
String prefix = completionContext.currentWord() != null ? completionContext.currentWord() : "";
return Stream.of(values)
.filter(n -> n.startsWith(prefix))
.map(CompletionProposal::new)
.collect(Collectors.toList());
}
}
}

View File

@@ -1,247 +0,0 @@
/*
* Copyright 2015 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.shell2.standard;
import java.lang.reflect.Method;
import java.util.List;
import java.util.stream.Collectors;
import org.jline.reader.ParsedLine;
import org.jline.reader.impl.DefaultParser;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell2.CompletionContext;
import org.springframework.shell2.CompletionProposal;
import org.springframework.shell2.ParameterMissingResolutionException;
import org.springframework.shell2.UnfinishedParameterResolutionException;
import org.springframework.shell2.Utils;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.util.ReflectionUtils.findMethod;
/**
* Unit tests for DefaultParameterResolver.
* @author Eric Bottard
* @author Florent Biville
*/
public class StandardParameterResolverTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
private StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService());
// Tests for resolution
@Test
public void testParses() throws Exception {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
assertThat(resolver.resolve(
Utils.createMethodParameter(method, 0),
asList("--force --name --foo y".split(" "))
)).isEqualTo(true);
assertThat(resolver.resolve(
Utils.createMethodParameter(method, 1),
asList("--force --name --foo y".split(" "))
)).isEqualTo("--foo");
assertThat(resolver.resolve(
Utils.createMethodParameter(method, 2),
asList("--force --name --foo y".split(" "))
)).isEqualTo("y");
assertThat(resolver.resolve(
Utils.createMethodParameter(method, 3),
asList("--force --name --foo y".split(" "))
)).isEqualTo("last");
}
@Test
public void testParameterSpecifiedTwiceViaDifferentAliases() throws Exception {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Named parameter has been specified multiple times via '--bar, --baz'");
resolver.resolve(
Utils.createMethodParameter(method, 0),
asList("--force --name --foo y --bar x --baz z".split(" "))
);
}
@Test
public void testParameterSpecifiedTwiceViaSameKey() throws Exception {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Parameter for '--baz' has already been specified");
resolver.resolve(
Utils.createMethodParameter(method, 0),
asList("--force --name --foo y --baz x --baz z".split(" "))
);
}
@Test
public void testTooMuchInput() throws Exception {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("the following could not be mapped to parameters: 'leftover'");
resolver.resolve(
Utils.createMethodParameter(method, 0),
asList("--foo hello --name bar --force --bar well leftover".split(" "))
);
}
@Test
public void testIncompleteCommandResolution() throws Exception {
Method method = findMethod(Remote.class, "shutdown", Remote.Delay.class);
thrown.expect(UnfinishedParameterResolutionException.class);
thrown.expectMessage("Error trying to resolve '--delay delay' using [--delay]");
resolver.resolve(
Utils.createMethodParameter(method, 0),
asList("--delay".split(" "))
);
}
@Test
public void testIncompleteCommandResolutionBigArity() throws Exception {
Method method = findMethod(Remote.class, "add", List.class);
thrown.expect(UnfinishedParameterResolutionException.class);
thrown.expectMessage("Error trying to resolve '--numbers list list list' using [--numbers 1 2]");
resolver.resolve(
Utils.createMethodParameter(method, 0),
asList("--numbers 1 2".split(" "))
);
}
@Test
public void testUnresolvableArg() throws Exception {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
thrown.expect(ParameterMissingResolutionException.class);
thrown.expectMessage("Parameter '--name string' should be specified");
resolver.resolve(
Utils.createMethodParameter(method, 1),
asList("--foo hello --force --bar well".split(" "))
);
}
// Tests for completion
@Test
public void testParameterKeyNotYetSetAppearsInProposals() {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
List<String> completions = resolver.complete(
Utils.createMethodParameter(method, 1),
contextFor("")
).stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("--name");
completions = resolver.complete(
Utils.createMethodParameter(method, 1),
contextFor("--force ")
).stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("--name");
}
@Test
public void testParameterKeyNotFullySpecified() {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
List<String> completions = resolver.complete(
Utils.createMethodParameter(method, 1),
contextFor("--na")
).stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("--name");
completions = resolver.complete(
Utils.createMethodParameter(method, 1),
contextFor("--force --na")
).stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("--name");
}
@Test
public void testNoMoreAvailableParameters() {
Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class);
List<String> completions = resolver.complete(
Utils.createMethodParameter(method, 2), // trying to complete --foo
contextFor("--name ") // but input is currently focused on --name
).stream().map(CompletionProposal::value).collect(Collectors.toList());
System.out.println(completions);
// assertThat(completions).isEmpty();
}
@Test
public void testNotTheRightTimeToCompleteThatParameter() {
Method method = findMethod(Remote.class, "shutdown", Remote.Delay.class);
List<String> completions = resolver.complete(
Utils.createMethodParameter(method, 0),
contextFor("--delay 323")
).stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).isEmpty();
}
@Test
public void testValueCompletionWithNonDefaultArity() {
resolver.setValueProviders(singletonList(new Remote.NumberValueProvider("12", "42", "7")));
Method[] methods = {
findMethod(org.springframework.shell2.standard.Remote.class, "add", List.class),
findMethod(org.springframework.shell2.standard.Remote.class, "addAsArray", int[].class),
};
for (Method method : methods) {
List<String> completions = resolver
.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers ")).stream()
.map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("12", "42", "7");
completions = resolver.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers 42 "))
.stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("12", "7");
completions = resolver.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers 42 34 "))
.stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).contains("12", "7");
completions = resolver.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers 42 34 66 "))
.stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(completions).isEmpty(); // All 3 have already been set
}
}
private CompletionContext contextFor(String input) {
DefaultParser defaultParser = new DefaultParser();
ParsedLine parsed = defaultParser.parse(input, input.length());
List<String> words = parsed.words().stream().filter(w -> w.length() > 0).collect(Collectors.toList());
return new CompletionContext(words, parsed.wordIndex(), parsed.wordCursor());
}
}

View File

@@ -1,23 +0,0 @@
&
&
NAME&
first-command - A rather extensive description of some command.&
&
SYNOPSYS&
first-command [-r] [-f] [[-n] int] [-o] float float float &
&
OPTIONS&
-r Whether to delete recursively [Mandatory]&
&
-f or --force&
Do not ask for confirmation. YOLO [Mandatory]&
&
-n int&
The answer to everything [Optional, default = 42]&
&
-o float float float&
Some other parameters [Mandatory]&
&
ALSO KNOWN AS&
1st-command&
&

View File

@@ -1,6 +0,0 @@
AVAILABLE COMMANDS&
&
1st-command, first-command: A rather extensive description of some command.&
second-command, yet-another-command: The second command. This one is known under several aliases as well.&
third-command: The last command.&
&