Rework command subsystem

- Focus of these changes are to introduce a new command system based on
  real registrations (new way) instead of continuously (old way) resolve
  methods and its parameters via reflection.
- There's a lot of changes as this resolution via reflection had its
  hooks almost everywhere and thus most changes are just refactorings.
- Order to understand real changes I'd start to look classes under
  `org.springframework.shell.command` package as it defines new registration,
  catalog and parser classes. Also samples contain new classes to demonstrate
  new functionality.
- Fixes #380
This commit is contained in:
Janne Valkealahti
2022-05-06 08:32:53 +01:00
parent 81e5bf8c81
commit 8a23518b84
91 changed files with 7026 additions and 2291 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2021 the original author or authors.
* Copyright 2021-2022 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.
@@ -26,9 +26,9 @@ import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.io.ResourceLoader;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.ParameterResolver;
import org.springframework.shell.Shell;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.completion.CompletionResolver;
import org.springframework.shell.style.TemplateExecutor;
import org.springframework.shell.style.ThemeResolver;
@@ -47,9 +47,9 @@ public abstract class AbstractShellComponent implements ApplicationContextAware,
private ObjectProvider<Terminal> terminalProvider;
private ObjectProvider<CommandRegistry> commandRegistryProvider;
private ObjectProvider<CommandCatalog> commandCatalogProvider;
private ObjectProvider<ParameterResolver> parameterResolverProvider;
private ObjectProvider<CompletionResolver> completionResolverProvider;
private ObjectProvider<TemplateExecutor> templateExecutorProvider;
@@ -69,8 +69,8 @@ public abstract class AbstractShellComponent implements ApplicationContextAware,
public void afterPropertiesSet() throws Exception {
shellProvider = applicationContext.getBeanProvider(Shell.class);
terminalProvider = applicationContext.getBeanProvider(Terminal.class);
commandRegistryProvider = applicationContext.getBeanProvider(CommandRegistry.class);
parameterResolverProvider = applicationContext.getBeanProvider(ParameterResolver.class);
commandCatalogProvider = applicationContext.getBeanProvider(CommandCatalog.class);
completionResolverProvider = applicationContext.getBeanProvider(CompletionResolver.class);
templateExecutorProvider = applicationContext.getBeanProvider(TemplateExecutor.class);
themeResolverProvider = applicationContext.getBeanProvider(ThemeResolver.class);
}
@@ -91,12 +91,12 @@ public abstract class AbstractShellComponent implements ApplicationContextAware,
return terminalProvider.getObject();
}
protected CommandRegistry getCommandRegistry() {
return commandRegistryProvider.getObject();
protected CommandCatalog getCommandCatalog() {
return commandCatalogProvider.getObject();
}
protected Stream<ParameterResolver> getParameterResolver() {
return parameterResolverProvider.orderedStream();
protected Stream<CompletionResolver> getCompletionResolver() {
return completionResolverProvider.orderedStream();
}
protected TemplateExecutor getTemplateExecutor() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2017 the original author or authors.
* Copyright 2017-2022 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.
@@ -20,9 +20,9 @@ import java.util.List;
import java.util.stream.Collectors;
import org.springframework.core.MethodParameter;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.command.CommandCatalog;
/**
* A {@link ValueProvider} that can be used to auto-complete names of shell commands.
@@ -31,15 +31,15 @@ import org.springframework.shell.CompletionProposal;
*/
public class CommandValueProvider extends ValueProviderSupport {
private final CommandRegistry commandRegistry;
private final CommandCatalog commandRegistry;
public CommandValueProvider(CommandRegistry commandRegistry) {
public CommandValueProvider(CommandCatalog commandRegistry) {
this.commandRegistry = commandRegistry;
}
@Override
public List<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) {
return commandRegistry.listCommands().keySet().stream()
return commandRegistry.getRegistrations().keySet().stream()
.map(CompletionProposal::new)
.collect(Collectors.toList());
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2022 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
*
* https://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.standard;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.core.MethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHandlingException;
import org.springframework.shell.support.AbstractArgumentMethodArgumentResolver;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Resolver for {@link ShellOption @ShellOption} arguments.
*
* @author Janne Valkealahti
*/
public class ShellOptionMethodArgumentResolver extends AbstractArgumentMethodArgumentResolver {
public ShellOptionMethodArgumentResolver(ConversionService conversionService,
@Nullable ConfigurableBeanFactory beanFactory) {
super(conversionService, beanFactory);
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(ShellOption.class);
}
@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
ShellOption annot = parameter.getParameterAnnotation(ShellOption.class);
Assert.state(annot != null, "No ShellOption annotation");
List<String> names = Arrays.stream(annot.value()).map(v -> StringUtils.trimLeadingCharacter(v, '-')).collect(Collectors.toList());
return new HeaderNamedValueInfo(annot, names);
}
@Override
@Nullable
protected Object resolveArgumentInternal(MethodParameter parameter, Message<?> message, List<String> names)
throws Exception {
for (String name : names) {
if (message.getHeaders().containsKey(ARGUMENT_PREFIX + name)) {
return message.getHeaders().get(ARGUMENT_PREFIX + name);
}
}
return null;
}
@Override
protected void handleMissingValue(List<String> headerName, MethodParameter parameter, Message<?> message) {
throw new MessageHandlingException(message,
"Missing headers '" + StringUtils.collectionToCommaDelimitedString(headerName)
+ "' for method parameter type [" + parameter.getParameterType() + "]");
}
private static final class HeaderNamedValueInfo extends NamedValueInfo {
private HeaderNamedValueInfo(ShellOption annotation, List<String> names) {
super(names, false, null);
}
}
}

View File

@@ -26,21 +26,27 @@ import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
import org.springframework.shell.Availability;
import org.springframework.shell.Command;
import org.springframework.shell.ConfigurableCommandRegistry;
import org.springframework.shell.MethodTarget;
import org.springframework.shell.MethodTargetRegistrar;
import org.springframework.shell.Utils;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.CommandRegistration.Builder;
import org.springframework.shell.command.CommandRegistration.OptionSpec;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import static org.springframework.util.StringUtils.collectionToDelimitedString;
/**
* The standard implementation of {@link MethodTargetRegistrar} for new shell
* applications, resolves methods annotated with {@link ShellMethod} on
@@ -49,20 +55,20 @@ import static org.springframework.util.StringUtils.collectionToDelimitedString;
* @author Eric Bottard
* @author Florent Biville
* @author Camilo Gonzalez
* @author Janne Valkealahti
*/
public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, ApplicationContextAware {
private final Logger log = LoggerFactory.getLogger(StandardMethodTargetRegistrar.class);
private ApplicationContext applicationContext;
private Map<String, MethodTarget> commands = new HashMap<>();
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void register(ConfigurableCommandRegistry registry) {
public void register(CommandCatalog registry) {
Map<String, Object> commandBeans = applicationContext.getBeansWithAnnotation(ShellComponent.class);
for (Object bean : commandBeans.values()) {
Class<?> clazz = bean.getClass();
@@ -74,11 +80,78 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, App
}
String group = getOrInferGroup(method);
for (String key : keys) {
log.debug("Registering with keys='{}' key='{}'", keys, key);
Supplier<Availability> availabilityIndicator = findAvailabilityIndicator(keys, bean, method);
MethodTarget target = new MethodTarget(method, bean, new Command.Help(shellMapping.value(), group),
availabilityIndicator, shellMapping.interactionMode());
registry.register(key, target);
commands.put(key, target);
Builder builder = CommandRegistration.builder()
.command(key)
.group(group)
.help(shellMapping.value())
.interactionMode(shellMapping.interactionMode())
.availability(availabilityIndicator);
InvocableHandlerMethod ihm = new InvocableHandlerMethod(bean, method);
for (MethodParameter mp : ihm.getMethodParameters()) {
ShellOption so = mp.getParameterAnnotation(ShellOption.class);
log.debug("Registering with mp='{}' so='{}'", mp, so);
if (so != null) {
List<String> longNames = new ArrayList<>();
List<Character> shortNames = new ArrayList<>();
if (!ObjectUtils.isEmpty(so.value())) {
Arrays.asList(so.value()).stream().forEach(o -> {
String stripped = StringUtils.trimLeadingCharacter(o, '-');
log.debug("Registering o='{}' stripped='{}'", o, stripped);
if (o.length() == stripped.length() + 2) {
longNames.add(stripped);
}
else if (o.length() == stripped.length() + 1 && stripped.length() == 1) {
shortNames.add(stripped.charAt(0));
}
});
}
else {
// ShellOption value not defined
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
longNames.add(longName);
}
}
if (!longNames.isEmpty() || !shortNames.isEmpty()) {
log.debug("Registering longNames='{}' shortNames='{}'", longNames, shortNames);
OptionSpec optionSpec = builder.withOption()
.type(mp.getParameterType())
.longNames(longNames.toArray(new String[0]))
.shortNames(shortNames.toArray(new Character[0]))
.position(mp.getParameterIndex())
.description(so.help());
if (so.arity() > -1) {
optionSpec.arity(0, so.arity());
}
}
}
else {
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
builder.withOption()
.longNames(longName)
.type(parameterType)
.required()
.position(mp.getParameterIndex());
}
}
}
builder.withTarget().method(bean, method);
CommandRegistration registration = builder.build();
registry.register(registration);
}
}, method -> method.getAnnotation(ShellMethod.class) != null);
}
@@ -190,10 +263,4 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, App
return null;
}
}
@Override
public String toString() {
return getClass().getSimpleName() + " contributing "
+ collectionToDelimitedString(commands.keySet(), ", ", "[", "]");
}
}

View File

@@ -1,573 +0,0 @@
/*
* Copyright 2015-2021 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
*
* https://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.standard;
import java.lang.reflect.Array;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
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 javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.validation.metadata.MethodDescriptor;
import javax.validation.metadata.ParameterDescriptor;
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.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.ParameterDescription;
import org.springframework.shell.ParameterMissingResolutionException;
import org.springframework.shell.ParameterResolver;
import org.springframework.shell.UnfinishedParameterResolutionException;
import org.springframework.shell.Utils;
import org.springframework.shell.ValueResult;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.ObjectUtils;
import static org.springframework.shell.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
* @author Camilo Gonzalez
*/
@Component
public class StandardParameterResolver implements ParameterResolver {
private final ConversionService conversionService;
private Collection<ValueProvider> valueProviders = new HashSet<>();
private Validator validator = Utils.defaultValidator();
/**
* 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<>();
public StandardParameterResolver(ConversionService conversionService, Set<ValueProvider> valueProviders) {
this.conversionService = conversionService;
this.valueProviders = valueProviders;
}
@Autowired(required = false)
public void setValidatorFactory(ValidatorFactory validatorFactory) {
this.validator = validatorFactory.getValidator();
}
@Override
public boolean supports(MethodParameter parameter) {
boolean optOut = parameter.hasParameterAnnotation(ShellOption.class)
&& parameter.getParameterAnnotation(ShellOption.class).optOut();
return !optOut && parameter.getMethodAnnotation(ShellMethod.class) != null;
}
@Override
public ValueResult resolve(MethodParameter methodParameter, List<String> wordsBuffer) {
List<String> words = wordsBuffer.stream().filter(w -> !w.isEmpty()).collect(Collectors.toList());
CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), wordsBuffer);
parameterCache.clear();
Map<Parameter, ParameterRawValue> resolved = parameterCache.computeIfAbsent(cacheKey, (k) -> {
Map<Parameter, ParameterRawValue> result = new HashMap<>();
Map<String, String> namedParameters = new HashMap<>();
// index of words that haven't yet been used to resolve parameter values
List<Integer> unusedWords = new ArrayList<>();
Set<String> possibleKeys = gatherAllPossibleKeys(methodParameter.getMethod());
// First, resolve all parameters passed by-name
for (int i = 0; i < words.size(); i++) {
int from = i;
String word = words.get(i);
if (possibleKeys.contains(word)) {
String key = word;
Parameter parameter = lookupParameterForKey(methodParameter.getMethod(), key);
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)).findFirst().get(), 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);
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, from, from));
}
else {
i += arity;
result.put(parameter, ParameterRawValue.explicit(raw, key, from, i));
}
} // store for later processing of positional params
else {
unusedWords.add(i);
}
}
// 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) <= unusedWords.size()) {
String raw = unusedWords.subList(offset, offset + arity).stream()
.map(index -> words.get(index))
.collect(Collectors.joining(","));
int from = unusedWords.get(offset);
int to = from + arity - 1;
result.put(parameter, ParameterRawValue.explicit(raw, null, from, to));
offset += arity;
} // No more input. Try defaultValues
else {
Optional<String> defaultValue = defaultValueFor(parameter);
defaultValue.ifPresent(
value -> result.put(parameter, ParameterRawValue.implicit(value, null, null, null)));
}
}
else if (copy.size() > 1) {
throw new IllegalArgumentException(
"Named parameter has been specified multiple times via " + quote(copy));
}
}
Assert.isTrue(offset == unusedWords.size(),
"Too many arguments: the following could not be mapped to parameters: "
+ unusedWords.subList(offset, unusedWords.size()).stream()
.map(index -> words.get(index)).collect(Collectors.joining(" ", "'", "'")));
return result;
});
Parameter param = methodParameter.getMethod().getParameters()[methodParameter.getParameterIndex()];
if (!resolved.containsKey(param)) {
throw new ParameterMissingResolutionException(describe(methodParameter).findFirst().get());
}
ParameterRawValue parameterRawValue = resolved.get(param);
Object value = convertRawValue(parameterRawValue, methodParameter);
BitSet wordsUsed = getWordsUsed(parameterRawValue);
BitSet wordsUsedForValue = getWordsUsedForValue(parameterRawValue);
return new ValueResult(methodParameter, value, wordsUsed, wordsUsedForValue);
}
private BitSet getWordsUsed(ParameterRawValue parameterRawValue) {
if (parameterRawValue.from != null) {
BitSet wordsUsed = new BitSet();
wordsUsed.set(parameterRawValue.from, parameterRawValue.to + 1);
return wordsUsed;
}
return null;
}
private BitSet getWordsUsedForValue(ParameterRawValue parameterRawValue) {
if (parameterRawValue.from != null) {
BitSet wordsUsedForValue = new BitSet();
wordsUsedForValue.set(parameterRawValue.from, parameterRawValue.to + 1);
if (parameterRawValue.key != null) {
wordsUsedForValue.clear(parameterRawValue.from);
}
return wordsUsedForValue;
}
return null;
}
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) {
return Arrays.stream(method.getParameters())
.flatMap(this::getKeysForParameter)
.collect(Collectors.toSet());
}
private String prefixForMethod(Executable method) {
return method.getAnnotation(ShellMethod.class).prefix();
}
private Optional<String> defaultValueFor(Parameter parameter) {
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && !ShellOption.NONE.equals(option.defaultValue())) {
return Optional.of(option.defaultValue());
}
else if (getArity(parameter) == 0) {
return Optional.of("false");
}
return Optional.empty();
}
private boolean booleanDefaultValue(Parameter parameter) {
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && !ShellOption.NONE.equals(option.defaultValue())) {
return Boolean.parseBoolean(option.defaultValue());
}
return false;
}
@Override
public Stream<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);
MethodDescriptor constraintsForMethod = validator.getConstraintsForClass(parameter.getDeclaringClass())
.getConstraintsForMethod(parameter.getMethod().getName(), parameter.getMethod().getParameterTypes());
if (constraintsForMethod != null) {
ParameterDescriptor constraintsDescriptor = constraintsForMethod
.getParameterDescriptors().get(parameter.getParameterIndex());
result.elementDescriptor(constraintsDescriptor);
}
return Stream.of(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 (UnfinishedParameterResolutionException e) {
if (e.getParameterDescription().parameter().equals(methodParameter)) {
unfinished = e;
set = false;
}
else {
return Collections.emptyList();
}
}
catch (Exception e) {
// Most likely what is already typed would fail resolution (eg type conversion failure)
return argumentKeysThatStartWithContextPrefix(methodParameter, context);
}
// 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 argumentKeysThatStartWithContextPrefix(methodParameter, context);
} // case 2
else {
return valueCompletions(methodParameter, context);
}
}
else {
List<CompletionProposal> result = new ArrayList<>();
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;
}
if (!context.currentWord().equals("")) {
// 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(argumentKeysThatStartWithContextPrefix(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> argumentKeysThatStartWithContextPrefix(MethodParameter methodParameter,
CompletionContext context) {
String prefix = context.currentWordUpToCursor() != null ? context.currentWordUpToCursor() : "";
return describe(methodParameter).flatMap(pd -> pd.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() != ShellOption.ARITY_USE_HEURISTICS ? option.arity() : inferred;
}
/**
* Return the key(s) for 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) {
Parameter p = method.getParameters()[index];
return getKeysForParameter(p);
}
private Stream<String> getKeysForParameter(Parameter p) {
Executable method = p.getDeclaringExecutable();
String prefix = prefixForMethod(method);
ShellOption option = p.getAnnotation(ShellOption.class);
if (option != null && option.value().length > 0) {
return Arrays.stream(option.value());
}
else {
return Stream.of(prefix + Utils.unCamelify(Utils.createMethodParameter(p).getParameterName()));
}
}
/**
* Return the method parameter that should be bound to the given key.
*/
private Parameter lookupParameterForKey(Method method, String key) {
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' in %s", 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 Integer from;
private Integer to;
/**
* 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, Integer from, Integer to) {
this.value = value;
this.explicit = explicit;
this.key = key;
this.from = from;
this.to = to;
}
public static ParameterRawValue explicit(String value, String key, Integer from, Integer to) {
return new ParameterRawValue(value, true, key, from, to);
}
public static ParameterRawValue implicit(String value, String key, Integer from, Integer to) {
return new ParameterRawValue(value, false, key, from, to);
}
public boolean positional() {
return key == null;
}
@Override
public String toString() {
return "ParameterRawValue{" +
"value='" + value + '\'' +
", explicit=" + explicit +
", key='" + key + '\'' +
", from=" + from +
", to=" + to +
'}';
}
}
}

View File

@@ -22,10 +22,10 @@ import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -36,11 +36,8 @@ import org.stringtemplate.v4.STGroupString;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.MethodTarget;
import org.springframework.shell.ParameterDescription;
import org.springframework.shell.ParameterResolver;
import org.springframework.shell.Utils;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@@ -54,14 +51,11 @@ import org.springframework.util.MultiValueMap;
public abstract class AbstractCompletions {
private final ResourceLoader resourceLoader;
private final CommandRegistry commandRegistry;
private final List<ParameterResolver> parameterResolvers;
private final CommandCatalog commandCatalog;
public AbstractCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
public AbstractCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) {
this.resourceLoader = resourceLoader;
this.commandRegistry = commandRegistry;
this.parameterResolvers = parameterResolvers;
this.commandCatalog = commandCatalog;
}
protected Builder builder() {
@@ -74,12 +68,12 @@ public abstract class AbstractCompletions {
* all needed to build completions structure.
*/
protected CommandModel generateCommandModel() {
Map<String, MethodTarget> commandsByName = commandRegistry.listCommands();
Collection<CommandRegistration> commandsByName = commandCatalog.getRegistrations().values();
HashMap<String, DefaultCommandModelCommand> commands = new HashMap<>();
HashSet<CommandModelCommand> topCommands = new HashSet<>();
commandsByName.entrySet().stream()
.forEach(entry -> {
String key = entry.getKey();
commandsByName.stream()
.forEach(registration -> {
String key = registration.getCommand();
String[] splitKeys = key.split(" ");
String commandKey = "";
for (int i = 0; i < splitKeys.length; i++) {
@@ -94,12 +88,13 @@ public abstract class AbstractCompletions {
}
DefaultCommandModelCommand command = commands.computeIfAbsent(commandKey,
(fullCommand) -> new DefaultCommandModelCommand(fullCommand, main));
MethodTarget methodTarget = entry.getValue();
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
List<DefaultCommandModelOption> options = parameterDescriptions.stream()
.flatMap(pd -> pd.keys().stream())
.map(k -> new DefaultCommandModelOption(k))
.collect(Collectors.toList());
// TODO long vs short
List<CommandModelOption> options = registration.getOptions().stream()
.flatMap(co -> Arrays.stream(co.getLongNames()))
.map(lo -> CommandModelOption.of("--", lo))
.collect(Collectors.toList());
if (i == splitKeys.length - 1) {
command.addOptions(options);
}
@@ -114,13 +109,6 @@ public abstract class AbstractCompletions {
return new DefaultCommandModel(new ArrayList<>(topCommands));
}
private List<ParameterDescription> getParameterDescriptions(MethodTarget methodTarget) {
return Utils.createMethodParameters(methodTarget.getMethod())
.flatMap(mp -> parameterResolvers.stream().filter(pr -> pr.supports(mp)).limit(1L)
.flatMap(pr -> pr.describe(mp)))
.collect(Collectors.toList());
}
/**
* Interface for a command model structure. Is also used as entry model
* for ST4 templates which is a reason it has utility methods for easier usage
@@ -208,6 +196,10 @@ public abstract class AbstractCompletions {
interface CommandModelOption {
String option();
static CommandModelOption of(String prefix, String name) {
return new DefaultCommandModelOption(String.format("%s%s", prefix, name));
}
}
class DefaultCommandModel implements CommandModel {
@@ -294,7 +286,7 @@ public abstract class AbstractCompletions {
return options;
}
void addOptions(List<DefaultCommandModelOption> options) {
void addOptions(List<CommandModelOption> options) {
this.options.addAll(options);
}
@@ -341,7 +333,7 @@ public abstract class AbstractCompletions {
}
}
class DefaultCommandModelOption implements CommandModelOption {
static class DefaultCommandModelOption implements CommandModelOption {
private String option;

View File

@@ -15,11 +15,8 @@
*/
package org.springframework.shell.standard.completion;
import java.util.List;
import org.springframework.core.io.ResourceLoader;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.ParameterResolver;
import org.springframework.shell.command.CommandCatalog;
/**
* Completion script generator for a {@code bash}.
@@ -28,9 +25,8 @@ import org.springframework.shell.ParameterResolver;
*/
public class BashCompletions extends AbstractCompletions {
public BashCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
super(resourceLoader, commandRegistry, parameterResolvers);
public BashCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) {
super(resourceLoader, commandCatalog);
}
public String generate(String rootCommand) {