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,48 +0,0 @@
/*
* Copyright 2015-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;
import java.util.Map;
/**
* Implementing this interface allows sub-systems (such as the {@literal help} command) to
* discover available commands.
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
public interface CommandRegistry {
/**
* Return the mapping from command trigger keywords to implementation.
*/
Map<String, MethodTarget> listCommands();
/**
* Register a new command.
*
* @param name the command name
* @param target the method target
*/
void addCommand(String name, MethodTarget target);
/**
* Deregister a command.
*
* @param name the command name
*/
void removeCommand(String name);
}

View File

@@ -1,79 +0,0 @@
/*
* 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.
* 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;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.shell.context.InteractionMode;
import org.springframework.shell.context.ShellContext;
/**
* A {@link CommandRegistry} that supports registration of new commands.
*
* <p>Makes sure that no two commands are registered with the same name.</p>
*
* @author Eric Bottard
*/
public class ConfigurableCommandRegistry implements CommandRegistry {
private final ShellContext shellContext;
private Map<String, MethodTarget> commands = new HashMap<>();
public ConfigurableCommandRegistry(ShellContext shellContext) {
this.shellContext = shellContext;
}
@Override
public Map<String, MethodTarget> listCommands() {
return commands.entrySet().stream()
.filter(e -> {
InteractionMode mim = e.getValue().getInteractionMode();
InteractionMode cim = shellContext.getInteractionMode();
if (mim == null || cim == null || mim == InteractionMode.ALL) {
return true;
}
else if (mim == InteractionMode.INTERACTIVE) {
return cim == InteractionMode.INTERACTIVE || cim == InteractionMode.ALL;
}
else if (mim == InteractionMode.NONINTERACTIVE) {
return cim == InteractionMode.NONINTERACTIVE || cim == InteractionMode.ALL;
}
return true;
})
.collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
}
@Override
public void addCommand(String name, MethodTarget target) {
commands.put(name, target);
}
@Override
public void removeCommand(String name) {
commands.remove(name);
}
public void register(String name, MethodTarget target) {
MethodTarget previous = commands.get(name);
if (previous != null) {
throw new IllegalArgumentException(
String.format("Illegal registration for command '%s': Attempt to register both '%s' and '%s'", name, target, previous));
}
commands.put(name, target);
}
}

View File

@@ -1,152 +0,0 @@
/*
* Copyright 2015-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;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Supplier;
import org.springframework.shell.context.InteractionMode;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
/**
* Represents a shell command behavior, <em>i.e.</em> code to be executed when a command is requested.
*
* @author Eric Bottard
*/
public class MethodTarget implements Command {
private final Method method;
private final Object bean;
private final Help help;
private final InteractionMode interactionMode;
/**
* If not null, returns whether or not the command is currently available. Implementations must be idempotent.
*/
private final Supplier<Availability> availabilityIndicator;
public MethodTarget(Method method, Object bean, String help) {
this(method, bean, new Help(help, null), null);
}
public MethodTarget(Method method, Object bean, String help, Supplier<Availability> availabilityIndicator) {
this(method, bean, new Help(help, null), availabilityIndicator);
}
public MethodTarget(Method method, Object bean, Help help, Supplier<Availability> availabilityIndicator) {
this(method, bean, help, availabilityIndicator, null);
}
public MethodTarget(Method method, Object bean, Help help, Supplier<Availability> availabilityIndicator, InteractionMode interactionMode) {
Assert.notNull(method, "Method cannot be null");
Assert.notNull(bean, "Bean cannot be null");
Assert.hasText(help.getDescription(), String.format("Help cannot be blank when trying to define command based on '%s'", method));
ReflectionUtils.makeAccessible(method);
this.method = method;
this.bean = bean;
this.help = help;
this.availabilityIndicator = availabilityIndicator != null ? availabilityIndicator : () -> Availability.available();
this.interactionMode = interactionMode;
}
/**
* Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception
* in case of overloaded method.
*/
public static MethodTarget of(String name, Object bean, String description, String group) {
return of(name, bean, new Help(description, group));
}
/**
* Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception
* in case of overloaded method.
*/
public static MethodTarget of(String name, Object bean, Help help) {
return of(name, bean, help, null);
}
/**
* Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception
* in case of overloaded method.
*/
public static MethodTarget of(String name, Object bean, Help help, Supplier<Availability> availabilityIndicator) {
Set<Method> found = new HashSet<>();
ReflectionUtils.doWithMethods(bean.getClass(), found::add, m -> m.getName().equals(name));
if (found.size() != 1) {
throw new IllegalArgumentException(String.format("Could not find unique method named '%s' on object of class %s. Found %s",
name, bean.getClass(), found));
}
return new MethodTarget(found.iterator().next(), bean, help, availabilityIndicator);
}
public Method getMethod() {
return method;
}
public Object getBean() {
return bean;
}
public String getHelp() {
return help.getDescription();
}
public String getGroup() {
return help.getGroup();
}
public Availability getAvailability() {
return availabilityIndicator.get();
}
public InteractionMode getInteractionMode() {
return interactionMode;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MethodTarget that = (MethodTarget) o;
if (!method.equals(that.method)) return false;
if (!bean.equals(that.bean)) return false;
if (!help.equals(that.help)) return false;
return help.equals(that.help);
}
@Override
public int hashCode() {
int result = method.hashCode();
result = 31 * result + bean.hashCode();
result = 31 * result + help.hashCode();
result = 31 * result + help.hashCode();
return result;
}
@Override
public String toString() {
return method.toString();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2015-2017 the original author or authors.
* Copyright 2015-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.
@@ -16,6 +16,8 @@
package org.springframework.shell;
import org.springframework.shell.command.CommandCatalog;
/**
* Strategy interface for registering commands.
*
@@ -27,6 +29,6 @@ public interface MethodTargetRegistrar {
/**
* Register mappings from {@literal <command keyword(s)>} to actual behavior.
*/
void register(ConfigurableCommandRegistry registry);
void register(CommandCatalog registry);
}

View File

@@ -34,15 +34,10 @@ import javax.validation.metadata.ElementDescriptor;
*/
public class ParameterDescription {
/**
* The original method parameter this is describing.
*/
private final MethodParameter parameter;
/**
* A string representation of the type of the parameter.
*/
private final String type;
private String type;
/**
* A string representation of the parameter, as it should appear in a parameter list.
@@ -85,15 +80,8 @@ public class ParameterDescription {
*/
private ElementDescriptor elementDescriptor;
public ParameterDescription(MethodParameter parameter, String type) {
this.parameter = parameter;
public void type(String type) {
this.type = type;
this.formal = type;
}
public static ParameterDescription outOf(MethodParameter parameter) {
Class<?> type = parameter.getParameterType();
return new ParameterDescription(parameter, Utils.unCamelify(type.getSimpleName()));
}
public ParameterDescription help(String help) {
@@ -171,17 +159,13 @@ public class ParameterDescription {
return String.format("%s %s", keys.isEmpty() ? "" : keys().iterator().next(), formal());
}
public MethodParameter parameter() {
return parameter;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ParameterDescription that = (ParameterDescription) o;
return mandatoryKey == that.mandatoryKey &&
Objects.equals(parameter, that.parameter) &&
// Objects.equals(parameter, that.parameter) &&
Objects.equals(type, that.type) &&
Objects.equals(formal, that.formal) &&
Objects.equals(defaultValue, that.defaultValue) &&
@@ -192,6 +176,6 @@ public class ParameterDescription {
@Override
public int hashCode() {
return Objects.hash(parameter, type, formal, defaultValue, defaultValueWhenFlag, keys, mandatoryKey, help);
return Objects.hash(type, formal, defaultValue, defaultValueWhenFlag, keys, mandatoryKey, help);
}
}

View File

@@ -1,63 +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
*
* 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;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
/**
* Implementations of this interface are responsible, once the command has been identified, of transforming the textual
* input to an actual parameter object.
*
* <p>
* An order can also be specified in case more than one {@link ParameterResolver} supports a {@link MethodParameter}.
* See {@link AnnotationAwareOrderComparator} for details..
* </p>
*
* @author Eric Bottard
* @author Camilo Gonzalez
*/
public interface ParameterResolver {
/**
* Should return true if this resolver recognizes the given method parameter (<em>e.g.</em> it
* has the correct annotation or the correct type).
*/
boolean supports(MethodParameter parameter);
/**
* Turn the given textual input into an actual object, maybe using some conversion or lookup mechanism.
*/
ValueResult resolve(MethodParameter methodParameter, List<String> words);
/**
* Describe a supported parameter, so that integrated help can be generated.
* <p>Typical implementations will return a one element stream result, but some may return several (for
* example if binding several words to a POJO).</p>
*/
Stream<ParameterDescription> describe(MethodParameter parameter);
/**
* Invoked during TAB completion. If the {@link CompletionContext} can be interpreted as the start
* of a supported {@link MethodParameter} value, one or several proposals should be returned.
*/
List<CompletionProposal> complete(MethodParameter parameter, CompletionContext context);
}

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.
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell;
import javax.validation.ConstraintViolation;
@@ -23,21 +22,17 @@ import java.util.Set;
* Thrown when one or more parameters fail bean validation constraints.
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
public class ParameterValidationException extends RuntimeException {
private final Set<ConstraintViolation<Object>> constraintViolations;
private final MethodTarget methodTarget;
public ParameterValidationException(Set<ConstraintViolation<Object>> constraintViolations, MethodTarget methodTarget) {
private final Set<ConstraintViolation<Object>> constraintViolations;
public ParameterValidationException(Set<ConstraintViolation<Object>> constraintViolations) {
this.constraintViolations = constraintViolations;
this.methodTarget = methodTarget;
}
public Set<ConstraintViolation<Object>> getConstraintViolations() {
return constraintViolations;
}
public MethodTarget getMethodTarget() {
return methodTarget;
}
}

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.
@@ -13,45 +13,36 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell;
import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.UndeclaredThrowableException;
import java.nio.channels.ClosedByInterruptException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import org.jline.terminal.Terminal;
import org.jline.utils.Signals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.util.ReflectionUtils;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandExecution;
import org.springframework.shell.command.CommandExecution.CommandExecutionException;
import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.completion.CompletionResolver;
/**
* Main class implementing a shell loop.
*
* <p>
* Given some textual input, locate the {@link MethodTarget} to invoke and
* {@link ResultHandler#handleResult(Object) handle} the result.
* </p>
*
* <p>
* Also provides hooks for code completion
* </p>
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
@@ -66,11 +57,10 @@ public class Shell {
*/
public static final Object NO_INPUT = new Object();
private final CommandRegistry commandRegistry;
private Validator validator = Utils.defaultValidator();
protected List<ParameterResolver> parameterResolvers;
private final Terminal terminal;
private final CommandCatalog commandRegistry;
protected List<CompletionResolver> completionResolvers = new ArrayList<>();
private CommandExecutionHandlerMethodArgumentResolvers argumentResolvers;
/**
* Marker object to distinguish unresolved arguments from {@code null}, which is a valid
@@ -78,9 +68,23 @@ public class Shell {
*/
protected static final Object UNRESOLVED = new Object();
public Shell(ResultHandlerService resultHandlerService, CommandRegistry commandRegistry) {
private Validator validator = Utils.defaultValidator();
public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal) {
this.resultHandlerService = resultHandlerService;
this.commandRegistry = commandRegistry;
this.terminal = terminal;
}
@Autowired
public void setCompletionResolvers(List<CompletionResolver> resolvers) {
this.completionResolvers = new ArrayList<>(resolvers);
AnnotationAwareOrderComparator.sort(completionResolvers);
}
@Autowired
public void setArgumentResolvers(CommandExecutionHandlerMethodArgumentResolvers argumentResolvers) {
this.argumentResolvers = argumentResolvers;
}
@Autowired(required = false)
@@ -88,12 +92,6 @@ public class Shell {
this.validator = validatorFactory.getValidator();
}
@Autowired
public void setParameterResolvers(List<ParameterResolver> resolvers) {
this.parameterResolvers = new ArrayList<>(resolvers);
AnnotationAwareOrderComparator.sort(parameterResolvers);
}
/**
* The main program loop: acquire input, try to match it to a command and evaluate. Repeat
* until a {@link ResultHandler} causes the process to exit or there is no input.
@@ -145,21 +143,24 @@ public class Shell {
String command = findLongestCommand(line);
List<String> words = input.words();
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
if (command != null) {
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
MethodTarget methodTarget = methodTargets.get(command);
Availability availability = methodTarget.getAvailability();
if (availability.isAvailable()) {
Optional<CommandRegistration> commandRegistration = commandRegistry.getRegistrations().values().stream()
.filter(r -> {
return r.getCommand().equals(command);
})
.findFirst();
if (commandRegistration.isPresent()) {
List<String> wordsForArgs = wordsForArguments(command, words);
Method method = methodTarget.getMethod();
Thread commandThread = Thread.currentThread();
Object sh = Signals.register("INT", () -> commandThread.interrupt());
try {
Object[] args = resolveArgs(method, wordsForArgs);
validateArgs(args, methodTarget);
return ReflectionUtils.invokeMethod(method, methodTarget.getBean(), args);
CommandExecution execution = CommandExecution
.of(argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal);
return execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0]));
}
catch (UndeclaredThrowableException e) {
if (e.getCause() instanceof InterruptedException || e.getCause() instanceof ClosedByInterruptException) {
@@ -167,6 +168,9 @@ public class Shell {
}
return e.getCause();
}
catch (CommandExecutionException e) {
return e.getCause();
}
catch (Exception e) {
return e;
}
@@ -175,7 +179,7 @@ public class Shell {
}
}
else {
return new CommandNotCurrentlyAvailable(command, availability);
return new CommandNotFound(words);
}
}
else {
@@ -183,6 +187,7 @@ public class Shell {
}
}
/**
* Return true if the parsed input ends up being empty (<em>e.g.</em> hitting ENTER on an
* empty line or blank space).
@@ -229,18 +234,11 @@ public class Shell {
if (best != null) {
CompletionContext argsContext = context.drop(best.split(" ").length);
// Try to complete arguments
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
MethodTarget methodTarget = methodTargets.get(best);
Method method = methodTarget.getMethod();
CommandRegistration registration = commandRegistry.getRegistrations().get(best);
List<MethodParameter> parameters = Utils.createMethodParameters(method).collect(Collectors.toList());
for (ParameterResolver resolver : parameterResolvers) {
for (int index = 0; index < parameters.size(); index++) {
MethodParameter parameter = parameters.get(index);
if (resolver.supports(parameter)) {
resolver.complete(parameter, argsContext).stream().forEach(candidates::add);
}
}
for (CompletionResolver resolver : completionResolvers) {
List<CompletionProposal> resolved = resolver.resolve(registration, argsContext);
candidates.addAll(resolved);
}
}
return candidates;
@@ -250,61 +248,23 @@ public class Shell {
// Workaround for https://github.com/spring-projects/spring-shell/issues/150
// (sadly, this ties this class to JLine somehow)
int lastWordStart = prefix.lastIndexOf(' ') + 1;
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
return methodTargets.entrySet().stream()
.filter(e -> e.getKey().startsWith(prefix))
.map(e -> toCommandProposal(e.getKey().substring(lastWordStart), e.getValue()))
.collect(Collectors.toList());
return commandRegistry.getRegistrations().values().stream()
.filter(r -> {
return r.getCommand().startsWith(prefix);
})
.map(r -> {
String c = r.getCommand();
c = c.substring(lastWordStart);
return toCommandProposal(c, r);
})
.collect(Collectors.toList());
}
private CompletionProposal toCommandProposal(String command, MethodTarget methodTarget) {
private CompletionProposal toCommandProposal(String command, CommandRegistration registration) {
return new CompletionProposal(command)
.dontQuote(true)
.category("Available commands")
.description(methodTarget.getHelp());
}
private void validateArgs(Object[] args, MethodTarget methodTarget) {
for (int i = 0; i < args.length; i++) {
if (args[i] == UNRESOLVED) {
MethodParameter methodParameter = Utils.createMethodParameter(methodTarget.getMethod(), i);
throw new IllegalStateException("Could not resolve " + methodParameter);
}
}
Set<ConstraintViolation<Object>> constraintViolations = validator.forExecutables().validateParameters(
methodTarget.getBean(),
methodTarget.getMethod(),
args);
if (constraintViolations.size() > 0) {
throw new ParameterValidationException(constraintViolations, methodTarget);
}
}
/**
* Use all known {@link ParameterResolver}s to try to compute a value for each parameter
* of the method to invoke.
* @param method the method for which parameters should be computed
* @param wordsForArgs the list of 'words' that should be converted to parameter values.
* May include markers for passing parameters 'by name'
* @return an array containing resolved parameter values, or {@link #UNRESOLVED} for
* parameters that could not be resolved
*/
private Object[] resolveArgs(Method method, List<String> wordsForArgs) {
log.debug("Resolving args {} {}", method, wordsForArgs);
List<MethodParameter> parameters = Utils.createMethodParameters(method).collect(Collectors.toList());
Object[] args = new Object[parameters.size()];
Arrays.fill(args, UNRESOLVED);
for (ParameterResolver resolver : parameterResolvers) {
log.debug("Resolving args with {}", resolver);
for (int argIndex = 0; argIndex < args.length; argIndex++) {
MethodParameter parameter = parameters.get(argIndex);
if (args[argIndex] == UNRESOLVED && resolver.supports(parameter)) {
args[argIndex] = resolver.resolve(parameter, wordsForArgs).resolvedValue();
log.debug("Resolved {} {} {} {}", method, args[argIndex], resolver, parameter);
}
}
}
return args;
.description(registration.getHelp());
}
/**
@@ -313,8 +273,7 @@ public class Shell {
* @return a valid command name, or {@literal null} if none matched
*/
private String findLongestCommand(String prefix) {
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
String result = methodTargets.keySet().stream()
String result = commandRegistry.getRegistrations().keySet().stream()
.filter(command -> prefix.equals(command) || prefix.startsWith(command + " "))
.reduce("", (c1, c2) -> c1.length() > c2.length() ? c1 : c2);
return "".equals(result) ? null : result;

View File

@@ -20,7 +20,10 @@ import java.lang.reflect.Constructor;
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.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
@@ -130,4 +133,37 @@ public class Utils {
public static Validator defaultValidator() {
return DEFAULT_VALIDATOR;
}
/**
* Split array into list of lists by predicate
*
* @param array the array
* @param predicate the predicate
* @return the list of lists
*/
public static <T> List<List<T>> split(T[] array, Predicate<T> predicate) {
List<T> list = Arrays.asList(array);
boolean[] boundaries = new boolean[array.length];
List<List<T>> split = new ArrayList<>();
for (int i = 0; i < array.length; i++) {
boundaries[i] = predicate.test(array[i]);
}
int tail = 0;
for (int i = 0; i < boundaries.length; i++) {
if (boundaries[i]) {
if (tail < i) {
split.add(list.subList(tail, i));
}
tail = i;
}
}
if (tail < array.length) {
split.add(list.subList(tail, array.length));
}
return split;
}
}

View File

@@ -0,0 +1,79 @@
/*
* 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.command;
import java.util.Arrays;
import java.util.List;
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.messaging.handler.annotation.Header;
import org.springframework.shell.support.AbstractArgumentMethodArgumentResolver;
import org.springframework.util.Assert;
/**
* Resolver for {@link Header @Header} arguments.
*
* @author Janne Valkealahti
*/
public class ArgumentHeaderMethodArgumentResolver extends AbstractArgumentMethodArgumentResolver {
public ArgumentHeaderMethodArgumentResolver(ConversionService conversionService,
@Nullable ConfigurableBeanFactory beanFactory) {
super(conversionService, beanFactory);
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(Header.class);
}
@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
Header annot = parameter.getParameterAnnotation(Header.class);
Assert.state(annot != null, "No Header annotation");
return new HeaderNamedValueInfo(annot);
}
@Override
@Nullable
protected Object resolveArgumentInternal(MethodParameter parameter, Message<?> message, List<String> names)
throws Exception {
if (names.size() == 1) {
return message.getHeaders().get(ARGUMENT_PREFIX + names.get(0));
}
else {
return null;
}
}
@Override
protected void handleMissingValue(List<String> headerName, MethodParameter parameter, Message<?> message) {
throw new MessageHandlingException(message, "Missing header '" + headerName +
"' for method parameter type [" + parameter.getParameterType() + "]");
}
private static final class HeaderNamedValueInfo extends NamedValueInfo {
private HeaderNamedValueInfo(Header annotation) {
super(Arrays.asList(annotation.name()), annotation.required(), annotation.defaultValue());
}
}
}

View File

@@ -0,0 +1,168 @@
/*
* 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.command;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.springframework.shell.context.InteractionMode;
import org.springframework.shell.context.ShellContext;
/**
* Interface defining contract to handle existing {@link CommandRegistration}s.
*
* @author Janne Valkealahti
*/
public interface CommandCatalog {
/**
* Register a {@link CommandRegistration}.
*
* @param registration the command registration
*/
void register(CommandRegistration... registration);
/**
* Unregister a {@link CommandRegistration}.
*
* @param registration the command registration
*/
void unregister(CommandRegistration... registration);
/**
* Unregister a {@link CommandRegistration} by its command name.
*
* @param commandName the command name
*/
void unregister(String... commandName);
/**
* Gets all {@link CommandRegistration}s mapped with their names.
* Returned map is a copy and cannot be used to register new commands.
*
* @return all command registrations
*/
Map<String, CommandRegistration> getRegistrations();
/**
* Gets an instance of a default {@link CommandCatalog}.
*
* @return default command catalog
*/
static CommandCatalog of() {
return new DefaultCommandCatalog(null, null);
}
/**
* Gets an instance of a default {@link CommandCatalog}.
*
* @param resolvers the command resolvers
* @param shellContext the shell context
* @return default command catalog
*/
static CommandCatalog of(Collection<CommandResolver> resolvers, ShellContext shellContext) {
return new DefaultCommandCatalog(resolvers, shellContext);
}
/**
* Default implementation of a {@link CommandCatalog}.
*/
static class DefaultCommandCatalog implements CommandCatalog {
private final Map<String, CommandRegistration> commandRegistrations = new HashMap<>();
private final Collection<CommandResolver> resolvers = new ArrayList<>();
private final ShellContext shellContext;
DefaultCommandCatalog(Collection<CommandResolver> resolvers, ShellContext shellContext) {
this.shellContext = shellContext;
if (resolvers != null) {
this.resolvers.addAll(resolvers);
}
}
@Override
public void register(CommandRegistration... registration) {
for (CommandRegistration r : registration) {
String commandName = r.getCommand();
commandRegistrations.put(commandName, r);
}
}
@Override
public void unregister(CommandRegistration... registration) {
for (CommandRegistration r : registration) {
String commandName = r.getCommand();
commandRegistrations.remove(commandName);
}
}
@Override
public void unregister(String... commandName) {
for (String n : commandName) {
commandRegistrations.remove(n);
}
}
@Override
public Map<String, CommandRegistration> getRegistrations() {
Map<String, CommandRegistration> regs = new HashMap<>();
regs.putAll(commandRegistrations);
for (CommandResolver resolver : resolvers) {
resolver.resolve().stream().forEach(r -> {
regs.put(r.getCommand(), r);
});
}
return regs.entrySet().stream()
.filter(filterByInteractionMode(shellContext))
.collect(Collectors.toMap(Entry::getKey, Entry::getValue));
}
/**
* Filter registration entries by currently set mode. Having it set to ALL or null
* effectively disables filtering as as we only care if mode is set to interactive
* or non-interactive.
*/
private static Predicate<Entry<String, CommandRegistration>> filterByInteractionMode(ShellContext shellContext) {
return e -> {
InteractionMode mim = e.getValue().getInteractionMode();
InteractionMode cim = shellContext != null ? shellContext.getInteractionMode() : InteractionMode.ALL;
if (mim == null || cim == null || mim == InteractionMode.ALL) {
return true;
}
else if (mim == InteractionMode.INTERACTIVE) {
return cim == InteractionMode.INTERACTIVE || cim == InteractionMode.ALL;
}
else if (mim == InteractionMode.NONINTERACTIVE) {
return cim == InteractionMode.NONINTERACTIVE || cim == InteractionMode.ALL;
}
return true;
};
}
// private static String commandName(String[] commands) {
// return Arrays.asList(commands).stream()
// .flatMap(c -> Stream.of(c.split(" ")))
// .filter(c -> StringUtils.hasText(c))
// .map(c -> c.trim())
// .collect(Collectors.joining(" "));
// }
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.command;
/**
* Interface to customize a {@link CommandCatalog}.
*
* @author Janne Valkealahti
*/
@FunctionalInterface
public interface CommandCatalogCustomizer {
/**
* Customize a command catalog.
*
* @param commandCatalog a command catalog
*/
void customize(CommandCatalog commandCatalog);
}

View File

@@ -0,0 +1,143 @@
/*
* 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.command;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Stream;
import org.jline.terminal.Terminal;
import org.springframework.shell.command.CommandParser.CommandParserResult;
import org.springframework.shell.command.CommandParser.CommandParserResults;
import org.springframework.util.ObjectUtils;
/**
* Interface containing information about current command execution.
*
* @author Janne Valkealahti
*/
public interface CommandContext {
/**
* Gets a raw args passed into a currently executing command.
*
* @return raw command arguments
*/
String[] getRawArgs();
/**
* Gets if option has been mapped.
*
* @param name the option name
* @return true if option has been mapped, false otherwise
*/
boolean hasMappedOption(String name);
/**
* Gets a command option parser results.
*
* @return the command option parser results
*/
CommandParserResults getParserResults();
/**
* Gets an mapped option value.
*
* @param <T> the type to map to
* @param name the option name
* @return mapped value
*/
<T> T getOptionValue(String name);
/**
* Gets a terminal.
*
* @return a terminal
*/
Terminal getTerminal();
/**
* Gets an instance of a default {@link CommandContext}.
*
* @param args the arguments
* @param results the results
* @param terminal the terminal
* @return a command context
*/
static CommandContext of(String[] args, CommandParserResults results, Terminal terminal) {
return new DefaultCommandContext(args, results, terminal);
}
/**
* Default implementation of a {@link CommandContext}.
*/
static class DefaultCommandContext implements CommandContext {
private String[] args;
private CommandParserResults results;
private Terminal terminal;
DefaultCommandContext(String[] args, CommandParserResults results, Terminal terminal) {
this.args = args;
this.results = results;
this.terminal = terminal;
}
@Override
public String[] getRawArgs() {
return args;
}
@Override
public boolean hasMappedOption(String name) {
return find(name).isPresent();
}
@Override
public CommandParserResults getParserResults() {
return results;
}
@Override
@SuppressWarnings("unchecked")
public <T> T getOptionValue(String name) {
Optional<CommandParserResult> find = find(name);
if (find.isPresent()) {
return (T) find.get().value();
}
return null;
}
@Override
public Terminal getTerminal() {
return terminal;
}
private Optional<CommandParserResult> find(String name) {
return results.results().stream()
.filter(r -> {
Stream<String> l = Arrays.asList(r.option().getLongNames()).stream();
Stream<String> s = Arrays.asList(r.option().getShortNames()).stream().map(n -> Character.toString(n));
return Stream.concat(l, s)
.filter(o -> ObjectUtils.nullSafeEquals(o, name))
.findFirst()
.isPresent();
})
.findFirst();
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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.command;
import java.util.Optional;
import org.springframework.core.MethodParameter;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
/**
* Implementation of a {@link HandlerMethodArgumentResolver} resolving
* {@link CommandContext}.
*
* @author Janne Valkealahti
*/
public class CommandContextMethodArgumentResolver implements HandlerMethodArgumentResolver {
public static final String HEADER_COMMAND_CONTEXT = "springShellCommandContext";
@Override
public boolean supportsParameter(MethodParameter parameter) {
MethodParameter nestedParameter = parameter.nestedIfOptional();
Class<?> paramType = nestedParameter.getNestedParameterType();
return CommandContext.class.isAssignableFrom(paramType);
}
@Override
public Object resolveArgument(MethodParameter parameter, Message<?> message){
CommandContext commandContext = message.getHeaders().get(HEADER_COMMAND_CONTEXT, CommandContext.class);
return parameter.isOptional() ? Optional.ofNullable(commandContext) : commandContext;
}
}

View File

@@ -0,0 +1,219 @@
/*
* 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.command;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.validation.Validator;
import org.jline.terminal.Terminal;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.shell.command.CommandParser.CommandParserException;
import org.springframework.shell.command.CommandParser.CommandParserResults;
import org.springframework.shell.command.CommandRegistration.TargetInfo;
import org.springframework.shell.command.CommandRegistration.TargetInfo.TargetType;
import org.springframework.shell.command.invocation.InvocableShellMethod;
import org.springframework.shell.command.invocation.ShellMethodArgumentResolverComposite;
/**
* Interface to evaluate a result from a command with an arguments.
*
* @author Janne Valkealahti
*/
public interface CommandExecution {
/**
* Evaluate a command with a given arguments.
*
* @param registration the command registration
* @param args the command args
* @return evaluated execution
*/
Object evaluate(CommandRegistration registration, String[] args);
/**
* Gets an instance of a default {@link CommandExecution}.
*
* @param resolvers the handler method argument resolvers
* @return default command execution
*/
public static CommandExecution of(List<? extends HandlerMethodArgumentResolver> resolvers) {
return new DefaultCommandExecution(resolvers, null, null);
}
/**
* Gets an instance of a default {@link CommandExecution}.
*
* @param resolvers the handler method argument resolvers
* @param validator the validator
* @param terminal the terminal
* @return default command execution
*/
public static CommandExecution of(List<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal) {
return new DefaultCommandExecution(resolvers, validator, terminal);
}
/**
* Default implementation of a {@link CommandExecution}.
*/
static class DefaultCommandExecution implements CommandExecution {
private List<? extends HandlerMethodArgumentResolver> resolvers;
private Validator validator;
private Terminal terminal;
public DefaultCommandExecution(List<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal) {
this.resolvers = resolvers;
this.validator = validator;
this.terminal = terminal;
}
public Object evaluate(CommandRegistration registration, String[] args) {
List<CommandOption> options = registration.getOptions();
CommandParser parser = CommandParser.of();
CommandParserResults results = parser.parse(options, args);
if (!results.errors().isEmpty()) {
throw new CommandParserExceptionsException("Command parser resulted errors", results.errors());
}
CommandContext ctx = CommandContext.of(args, results, terminal);
Object res = null;
TargetInfo targetInfo = registration.getTarget();
// pick the target to execute
if (targetInfo.getTargetType() == TargetType.FUNCTION) {
res = targetInfo.getFunction().apply(ctx);
}
else if (targetInfo.getTargetType() == TargetType.CONSUMER) {
targetInfo.getConsumer().accept(ctx);
}
else if (targetInfo.getTargetType() == TargetType.METHOD) {
try {
MessageBuilder<String[]> messageBuilder = MessageBuilder.withPayload(args);
Map<String, Object> paramValues = new HashMap<>();
results.results().stream().forEach(r -> {
if (r.option().getLongNames() != null) {
for (String n : r.option().getLongNames()) {
messageBuilder.setHeader(ArgumentHeaderMethodArgumentResolver.ARGUMENT_PREFIX + n, r.value());
paramValues.put(n, r.value());
}
}
if (r.option().getShortNames() != null) {
for (Character n : r.option().getShortNames()) {
messageBuilder.setHeader(ArgumentHeaderMethodArgumentResolver.ARGUMENT_PREFIX + n.toString(), r.value());
}
}
});
messageBuilder.setHeader(CommandContextMethodArgumentResolver.HEADER_COMMAND_CONTEXT, ctx);
InvocableShellMethod invocableShellMethod = new InvocableShellMethod(targetInfo.getBean(), targetInfo.getMethod());
invocableShellMethod.setValidator(validator);
ShellMethodArgumentResolverComposite argumentResolvers = new ShellMethodArgumentResolverComposite();
if (resolvers != null) {
argumentResolvers.addResolvers(resolvers);
}
if (!paramValues.isEmpty()) {
argumentResolvers.addResolver(new ParamNameHandlerMethodArgumentResolver(paramValues));
}
invocableShellMethod.setMessageMethodArgumentResolvers(argumentResolvers);
res = invocableShellMethod.invoke(messageBuilder.build(), (Object[])null);
} catch (Exception e) {
throw new CommandExecutionException(e);
}
}
return res;
}
}
@Order(100)
static class ParamNameHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
private final Map<String, Object> paramValues = new HashMap<>();
ConversionService conversionService = new DefaultConversionService();
ParamNameHandlerMethodArgumentResolver(Map<String, Object> paramValues) {
this.paramValues.putAll(paramValues);
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
String parameterName = parameter.getParameterName();
if (parameterName == null) {
return false;
}
return paramValues.containsKey(parameterName) && conversionService
.canConvert(paramValues.get(parameterName).getClass(), parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, Message<?> message) throws Exception {
return conversionService.convert(paramValues.get(parameter.getParameterName()), parameter.getParameterType());
}
}
static class CommandExecutionException extends RuntimeException {
public CommandExecutionException(Throwable cause) {
super(cause);
}
}
static class CommandParserExceptionsException extends RuntimeException {
private final List<CommandParserException> parserExceptions;
public CommandParserExceptionsException(String message, List<CommandParserException> parserExceptions) {
super(message);
this.parserExceptions = parserExceptions;
}
public List<CommandParserException> getParserExceptions() {
return parserExceptions;
}
}
static class CommandExecutionHandlerMethodArgumentResolvers {
private final List<? extends HandlerMethodArgumentResolver> resolvers;
public CommandExecutionHandlerMethodArgumentResolvers(List<? extends HandlerMethodArgumentResolver> resolvers) {
this.resolvers = resolvers;
}
public List<? extends HandlerMethodArgumentResolver> getResolvers() {
return resolvers;
}
}
}

View File

@@ -0,0 +1,210 @@
/*
* 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.command;
import org.springframework.core.ResolvableType;
/**
* Interface representing an option in a command.
*
* @author Janne Valkealahti
*/
public interface CommandOption {
/**
* Gets a long names of an option.
*
* @return long names of an option
*/
String[] getLongNames();
/**
* Gets a short names of an option.
*
* @return short names of an option
*/
Character[] getShortNames();
/**
* Gets a description of an option.
*
* @return description of an option
*/
String getDescription();
/**
* Gets a {@link ResolvableType} of an option.
*
* @return type of an option
*/
ResolvableType getType();
/**
* Gets a flag if option is required.
*
* @return the required flag
*/
boolean isRequired();
/**
* Gets a default value of an option.
*
* @return the default value
*/
String getDefaultValue();
/**
* Gets a positional value.
*
* @return the positional value
*/
int getPosition();
/**
* Gets a minimum arity.
*
* @return the minimum arity
*/
int getArityMin();
/**
* Gets a maximum arity.
*
* @return the maximum arity
*/
int getArityMax();
/**
* Gets an instance of a default {@link CommandOption}.
*
* @param longNames the long names
* @param shortNames the short names
* @param description the description
* @return default command option
*/
public static CommandOption of(String[] longNames, Character[] shortNames, String description) {
return of(longNames, shortNames, description, null, false, null, null, null, null);
}
/**
* Gets an instance of a default {@link CommandOption}.
*
* @param longNames the long names
* @param shortNames the short names
* @param description the description
* @param type the type
* @return default command option
*/
public static CommandOption of(String[] longNames, Character[] shortNames, String description,
ResolvableType type) {
return of(longNames, shortNames, description, type, false, null, null, null, null);
}
/**
* Gets an instance of a default {@link CommandOption}.
*
* @param longNames the long names
* @param shortNames the short names
* @param description the description
* @param type the type
* @param required the required flag
* @param defaultValue the default value
* @param position the position value
* @param arityMin the min arity
* @param arityMax the max arity
* @return default command option
*/
public static CommandOption of(String[] longNames, Character[] shortNames, String description,
ResolvableType type, boolean required, String defaultValue, Integer position, Integer arityMin, Integer arityMax) {
return new DefaultCommandOption(longNames, shortNames, description, type, required, defaultValue, position,
arityMin, arityMax);
}
/**
* Default implementation of {@link CommandOption}.
*/
public static class DefaultCommandOption implements CommandOption {
private String[] longNames;
private Character[] shortNames;
private String description;
private ResolvableType type;
private boolean required;
private String defaultValue;
private int position;
private int arityMin;
private int arityMax;
public DefaultCommandOption(String[] longNames, Character[] shortNames, String description,
ResolvableType type, boolean required, String defaultValue, Integer position,
Integer arityMin, Integer arityMax) {
this.longNames = longNames != null ? longNames : new String[0];
this.shortNames = shortNames != null ? shortNames : new Character[0];
this.description = description;
this.type = type;
this.required = required;
this.defaultValue = defaultValue;
this.position = position != null && position > -1 ? position : -1 ;
this.arityMin = arityMin != null ? arityMin : -1;
this.arityMax = arityMax != null ? arityMax : -1;
}
@Override
public String[] getLongNames() {
return longNames;
}
@Override
public Character[] getShortNames() {
return shortNames;
}
@Override
public String getDescription() {
return description;
}
@Override
public ResolvableType getType() {
return type;
}
@Override
public boolean isRequired() {
return required;
}
@Override
public String getDefaultValue() {
return defaultValue;
}
@Override
public int getPosition() {
return position;
}
@Override
public int getArityMin() {
return arityMin;
}
@Override
public int getArityMax() {
return arityMax;
}
}
}

View File

@@ -0,0 +1,479 @@
/*
* 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.command;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.core.ResolvableType;
import org.springframework.shell.Utils;
import org.springframework.util.StringUtils;
/**
* Interface parsing arguments for a {@link CommandRegistration}. A command is
* always identified by a set of words like
* {@code command subcommand1 subcommand2} and remaining part of it are options
* which this interface intercepts and translates into format we can understand.
*
* @author Janne Valkealahti
*/
public interface CommandParser {
/**
* Result of a parsing {@link CommandOption} with an argument.
*/
interface CommandParserResult {
/**
* Gets the {@link CommandOption}.
*
* @return the command option
*/
CommandOption option();
/**
* Gets the value.
*
* @return the value
*/
Object value();
/**
* Gets an instance of a default {@link CommandParserResult}.
*
* @param option the command option
* @param value the value
* @return a result
*/
static CommandParserResult of(CommandOption option, Object value) {
return new DefaultCommandParserResult(option, value);
}
}
/**
* Results of a {@link CommandParser}. Basically contains a list of {@link CommandParserResult}s.
*/
interface CommandParserResults {
/**
* Gets the results.
*
* @return the results
*/
List<CommandParserResult> results();
/**
* Gets the unmapped positional arguments.
*
* @return the unmapped positional arguments
*/
List<String> positional();
/**
* Gets parsing errors.
*
* @return the parsing errors
*/
List<CommandParserException> errors();
/**
* Gets an instance of a default {@link CommandParserResults}.
*
* @param results the results
* @param positional the list of positional arguments
* @param errors the parsing errors
* @return a new instance of results
*/
static CommandParserResults of(List<CommandParserResult> results, List<String> positional, List<CommandParserException> errors) {
return new DefaultCommandParserResults(results, positional, errors);
}
}
/**
* Parse options with a given arguments.
*
* May throw various runtime exceptions depending how parser is configure.
* For example if required option is missing an exception is thrown.
*
* @param options the command options
* @param args the arguments
* @return parsed results
*/
CommandParserResults parse(List<CommandOption> options, String[] args);
/**
* Gets an instance of a default command parser.
*
* @return instance of a default command parser
*/
static CommandParser of() {
return new DefaultCommandParser();
}
/**
* Default implementation of a {@link CommandParserResults}.
*/
static class DefaultCommandParserResults implements CommandParserResults {
private List<CommandParserResult> results;
private List<String> positional;
private List<CommandParserException> errors;
DefaultCommandParserResults(List<CommandParserResult> results, List<String> positional, List<CommandParserException> errors) {
this.results = results;
this.positional = positional;
this.errors = errors;
}
@Override
public List<CommandParserResult> results() {
return results;
}
@Override
public List<String> positional() {
return positional;
}
@Override
public List<CommandParserException> errors() {
return errors;
}
}
/**
* Default implementation of a {@link CommandParserResult}.
*/
static class DefaultCommandParserResult implements CommandParserResult {
private CommandOption option;
private Object value;
DefaultCommandParserResult(CommandOption option, Object value) {
this.option = option;
this.value = value;
}
@Override
public CommandOption option() {
return option;
}
@Override
public Object value() {
return value;
}
}
/**
* Default implementation of a {@link CommandParser}.
*/
static class DefaultCommandParser implements CommandParser {
@Override
public CommandParserResults parse(List<CommandOption> options, String[] args) {
List<CommandOption> requiredOptions = options.stream()
.filter(o -> o.isRequired())
.collect(Collectors.toList());
Lexer lexer = new Lexer(args);
List<List<String>> lexerResults = lexer.visit();
Parser parser = new Parser();
ParserResults parserResults = parser.visit(lexerResults, options);
List<CommandParserResult> results = new ArrayList<>();
List<String> positional = new ArrayList<>();
List<CommandParserException> errors = new ArrayList<>();
parserResults.results.stream().forEach(pr -> {
if (pr.option != null) {
results.add(new DefaultCommandParserResult(pr.option, pr.value));
requiredOptions.remove(pr.option);
}
else {
positional.addAll(pr.args);
}
if (pr.error != null) {
errors.add(pr.error);
}
});
Deque<ParserResult> queue = new ArrayDeque<>(parserResults.results);
options.stream()
.filter(o -> o.getPosition() > -1)
.sorted(Comparator.comparingInt(o -> o.getPosition()))
.forEach(o -> {
int arityMin = o.getArityMin();
int arityMax = o.getArityMax();
List<String> oargs = new ArrayList<>();
if (arityMin > -1) {
for (int i = 0; i < arityMax; i++) {
ParserResult pop = null;
if (!queue.isEmpty()) {
pop = queue.pop();
}
else {
break;
}
if (pop != null && pop.option == null) {
if (!pop.args.isEmpty()) {
oargs.add(pop.args.stream().collect(Collectors.joining(" ")));
}
}
}
}
if (!oargs.isEmpty()) {
results.add(new DefaultCommandParserResult(o, oargs.stream().collect(Collectors.joining(" "))));
requiredOptions.remove(o);
}
});
requiredOptions.stream().forEach(o -> {
String ln = o.getLongNames() != null ? Stream.of(o.getLongNames()).collect(Collectors.joining(",")) : "";
String sn = o.getShortNames() != null ? Stream.of(o.getShortNames()).map(n -> Character.toString(n))
.collect(Collectors.joining(",")) : "";
errors.add(MissingOptionException
.of(String.format("Missing option, longnames='%s', shortnames='%s'", ln, sn), o));
});
return new DefaultCommandParserResults(results, positional, errors);
}
private static class ParserResult {
private CommandOption option;
private List<String> args;
private Object value;
private CommandParserException error;
private ParserResult(CommandOption option, List<String> args, Object value, CommandParserException error) {
this.option = option;
this.args = args;
this.value = value;
this.error = error;
}
static ParserResult of(CommandOption option, List<String> args, Object value,
CommandParserException error) {
return new ParserResult(option, args, value, error);
}
}
private static class ParserResults {
private List<ParserResult> results;
private ParserResults(List<ParserResult> results) {
this.results = results;
}
static ParserResults of(List<ParserResult> results) {
return new ParserResults(results);
}
}
/**
* Parser works on a results from a lexer. It looks for given options
* and builds parsing results.
*/
private static class Parser {
ParserResults visit(List<List<String>> lexerResults, List<CommandOption> options) {
List<ParserResult> results = lexerResults.stream()
.flatMap(lr -> {
List<CommandOption> option = matchOptions(options, lr.get(0));
if (option.isEmpty()) {
return lr.stream().map(a -> ParserResult.of(null, Arrays.asList(a), null, null));
}
else {
return option.stream().flatMap(o -> {
List<String> subArgs = lr.subList(1, lr.size());
ConvertArgumentsHolder holder = convertArguments(o, subArgs);
Object value = holder.value;
Stream<ParserResult> unmapped = holder.unmapped.stream()
.map(um -> ParserResult.of(null, Arrays.asList(um), null, null));
Stream<ParserResult> res = Stream.of(ParserResult.of(o, subArgs, value, null));
return Stream.concat(res, unmapped);
});
}
})
.collect(Collectors.toList());
return ParserResults.of(results);
}
private List<CommandOption> matchOptions(List<CommandOption> options, String arg) {
List<CommandOption> matched = new ArrayList<>();
String trimmed = StringUtils.trimLeadingCharacter(arg, '-');
int count = arg.length() - trimmed.length();
if (count == 1) {
if (trimmed.length() == 1) {
Character trimmedChar = trimmed.charAt(0);
options.stream()
.filter(o -> {
for (Character sn : o.getShortNames()) {
if (trimmedChar.equals(sn)) {
return true;
}
}
return false;
})
.findFirst()
.ifPresent(o -> matched.add(o));
}
else if (trimmed.length() > 1) {
trimmed.chars().mapToObj(i -> (char)i)
.forEach(c -> {
options.stream().forEach(o -> {
for (Character sn : o.getShortNames()) {
if (c.equals(sn)) {
matched.add(o);
}
}
});
});
}
}
else if (count == 2) {
options.stream()
.filter(o -> {
for (String ln : o.getLongNames()) {
if (trimmed.equals(ln)) {
return true;
}
}
return false;
})
.findFirst()
.ifPresent(o -> matched.add(o));
}
return matched;
}
private ConvertArgumentsHolder convertArguments(CommandOption option, List<String> arguments) {
Object value = null;
List<String> unmapped = new ArrayList<>();
ResolvableType type = option.getType();
int arityMin = option.getArityMin();
int arityMax = option.getArityMax();
if (arityMin < 0 && type != null) {
if (type.isAssignableFrom(boolean.class)) {
arityMin = 1;
arityMax = 1;
}
}
if (type != null && type.isAssignableFrom(boolean.class)) {
if (arguments.size() == 0) {
value = true;
}
else {
value = Boolean.parseBoolean(arguments.get(0));
}
}
else if (type != null && type.isArray()) {
value = arguments.stream().collect(Collectors.toList()).toArray();
}
else {
if (!arguments.isEmpty()) {
if (arguments.size() == 1) {
value = arguments.get(0);
}
else {
if (arityMax > 0) {
int limit = Math.min(arguments.size(), arityMax);
value = arguments.stream().limit(limit).collect(Collectors.joining(" "));
unmapped.addAll(arguments.subList(limit, arguments.size()));
}
else {
value = arguments.get(0);
unmapped.addAll(arguments.subList(1, arguments.size()));
}
}
}
}
return ConvertArgumentsHolder.of(value, unmapped);
}
private static class ConvertArgumentsHolder {
Object value;
final List<String> unmapped = new ArrayList<>();
ConvertArgumentsHolder(Object value, List<String> unmapped) {
this.value = value;
if (unmapped != null) {
this.unmapped.addAll(unmapped);
}
}
static ConvertArgumentsHolder of(Object value, List<String> unmapped) {
return new ConvertArgumentsHolder(value, unmapped);
}
}
}
/**
* Lexers only responsibility is to splice arguments array into
* chunks which belongs together what comes for option structure.
*/
private static class Lexer {
private final String[] args;
Lexer(String[] args) {
this.args = args;
}
List<List<String>> visit() {
return Utils.split(args, t -> t.startsWith("-"));
}
}
}
static class CommandParserException extends RuntimeException {
public CommandParserException(String message) {
super(message);
}
public CommandParserException(String message, Throwable cause) {
super(message, cause);
}
public static CommandParserException of(String message) {
return new CommandParserException(message);
}
}
static class MissingOptionException extends CommandParserException {
private CommandOption option;
public MissingOptionException(String message, CommandOption option) {
super(message);
this.option = option;
}
public static MissingOptionException of(String message, CommandOption option) {
return new MissingOptionException(message, option);
}
public CommandOption getOption() {
return option;
}
}
}

View File

@@ -0,0 +1,781 @@
/*
* 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.command;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.core.ResolvableType;
import org.springframework.lang.Nullable;
import org.springframework.shell.Availability;
import org.springframework.shell.context.InteractionMode;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/**
* Interface defining a command registration endpoint.
*
* @author Janne Valkealahti
*/
public interface CommandRegistration {
/**
* Gets a command for this registration.
*
* @return command
*/
String getCommand();
/**
* Gets an {@link InteractionMode}.
*
* @return the interaction mode
*/
InteractionMode getInteractionMode();
/**
* Get help for a command.
*
* @return the help
*/
String getHelp();
/**
* Get group for a command.
*
* @return the group
*/
String getGroup();
/**
* Get description for a command.
*
* @return the description
*/
String getDescription();
/**
* Get {@link Availability} for a command
*
* @return the availability
*/
Availability getAvailability();
/**
* Gets target info.
*
* @return the target info
*/
TargetInfo getTarget();
/**
* Gets an options.
*
* @return the options
*/
List<CommandOption> getOptions();
/**
* Gets a new instance of a {@link Buidler}.
*
* @return a new builder instance
*/
public static Builder builder() {
return new DefaultBuilder();
}
/**
* Spec defining an option.
*/
public interface OptionSpec {
/**
* Define long option names.
*
* @param names the long option names
* @return option spec for chaining
*/
OptionSpec longNames(String... names);
/**
* Define short option names.
*
* @param names the long option names
* @return option spec for chaining
*/
OptionSpec shortNames(Character... names);
/**
* Define a type for an option.
*
* @param type the type
* @return option spec for chaining
*/
OptionSpec type(Type type);
/**
* Define a {@code description} for an option.
*
* @param description the option description
* @return option spec for chaining
*/
OptionSpec description(String description);
/**
* Define if option is required.
*
* @param required the required flag
* @return option spec for chaining
*/
OptionSpec required(boolean required);
/**
* Define option to be required. Syntatic sugar calling
* {@link #required(boolean)} with {@code true}.
*
* @return option spec for chaining
*/
OptionSpec required();
/**
* Define a {@code defaultValue} for an option.
*
* @param defaultValue the option default value
* @return option spec for chaining
*/
OptionSpec defaultValue(String defaultValue);
/**
* Define an optional hint for possible positional mapping.
*
* @param position the position
* @return option spec for chaining
*/
OptionSpec position(Integer position);
/**
* Define an {@code arity} for an option.
*
* @param min the min arity
* @param max the max arity
* @return option spec for chaining
*/
OptionSpec arity(int min, int max);
/**
* Define an {@code arity} for an option.
*
* @param arity the arity
* @return option spec for chaining
*/
OptionSpec arity(OptionArity arity);
/**
* Return a builder for chaining.
*
* @return a builder for chaining
*/
Builder and();
}
public enum OptionArity {
ZERO,
ZERO_OR_ONE,
EXACTLY_ONE,
ZERO_OR_MORE,
ONE_OR_MORE
}
/**
* Encapsulates info for {@link TargetSpec}.
*/
public interface TargetInfo {
/**
* Get target type
*
* @return the target type
*/
TargetType getTargetType();
/**
* Get the bean.
*
* @return the bean
*/
Object getBean();
/**
* Get the bean method
*
* @return the bean method
*/
Method getMethod();
/**
* Get the function
*
* @return the function
*/
Function<CommandContext, ?> getFunction();
/**
* Get the consumer
*
* @return the consumer
*/
Consumer<CommandContext> getConsumer();
static TargetInfo of(Object bean, Method method) {
return new DefaultTargetInfo(TargetType.METHOD, bean, method, null, null);
}
static TargetInfo of(Function<CommandContext, ?> function) {
return new DefaultTargetInfo(TargetType.FUNCTION, null, null, function, null);
}
static TargetInfo of(Consumer<CommandContext> consumer) {
return new DefaultTargetInfo(TargetType.CONSUMER, null, null, null, consumer);
}
enum TargetType {
METHOD, FUNCTION, CONSUMER;
}
static class DefaultTargetInfo implements TargetInfo {
private final TargetType targetType;
private final Object bean;
private final Method method;
private final Function<CommandContext, ?> function;
private final Consumer<CommandContext> consumer;
public DefaultTargetInfo(TargetType targetType, Object bean, Method method,
Function<CommandContext, ?> function, Consumer<CommandContext> consumer) {
this.targetType = targetType;
this.bean = bean;
this.method = method;
this.function = function;
this.consumer = consumer;
}
@Override
public TargetType getTargetType() {
return targetType;
}
@Override
public Object getBean() {
return bean;
}
@Override
public Method getMethod() {
return method;
}
@Override
public Function<CommandContext, ?> getFunction() {
return function;
}
@Override
public Consumer<CommandContext> getConsumer() {
return consumer;
}
}
}
/**
* Spec defining a target.
*/
public interface TargetSpec {
/**
* Register a method target.
*
* @param bean the bean
* @param method the method
* @param paramTypes the parameter types
* @return a target spec for chaining
*/
TargetSpec method(Object bean, String method, @Nullable Class<?>... paramTypes);
/**
* Register a method target.
*
* @param bean the bean
* @param method the method
* @return a target spec for chaining
*/
TargetSpec method(Object bean, Method method);
/**
* Register a function target.
*
* @param function the function to register
* @return a target spec for chaining
*/
TargetSpec function(Function<CommandContext, ?> function);
/**
* Register a consumer target.
*
* @param consumer the consumer to register
* @return a target spec for chaining
*/
TargetSpec consumer(Consumer<CommandContext> consumer);
/**
* Return a builder for chaining.
*
* @return a builder for chaining
*/
Builder and();
}
/**
* Builder interface for {@link CommandRegistration}.
*/
public interface Builder {
/**
* Define commands this registration uses. Essentially defines a full set of
* main and sub commands. It doesn't matter if full command is defined in one
* string or multiple strings as "words" are splitted and trimmed with
* whitespaces. You will get result of {@code command subcommand1 subcommand2, ...}.
*
* @param commands the commands
* @return builder for chaining
*/
Builder command(String... commands);
/**
* Define {@link InteractionMode} for a command.
*
* @param mode the interaction mode
* @return builder for chaining
*/
Builder interactionMode(InteractionMode mode);
/**
* Define a simple help text for a command.
*
* @param help the help text
* @return builder for chaining
*/
Builder help(String help);
/**
* Define an {@link Availability} suppliear for a command.
*
* @param availability the availability
* @return builder for chaining
*/
Builder availability(Supplier<Availability> availability);
/**
* Define a group for a command.
*
* @param group the group
* @return builder for chaining
*/
Builder group(String group);
/**
* Define an option what this command should user for. Can be used multiple
* times.
*
* @return option spec for chaining
*/
OptionSpec withOption();
/**
* Define a target what this command should execute
*
* @return target spec for chaining
*/
TargetSpec withTarget();
/**
* Builds a {@link CommandRegistration}.
*
* @return a command registration
*/
CommandRegistration build();
}
static class DefaultOptionSpec implements OptionSpec {
private BaseBuilder builder;
private String[] longNames;
private Character[] shortNames;
private ResolvableType type;
private String description;
private boolean required;
private String defaultValue;
private Integer position;
private Integer arityMin;
private Integer arityMax;
DefaultOptionSpec(BaseBuilder builder) {
this.builder = builder;
}
@Override
public OptionSpec longNames(String... names) {
this.longNames = names;
return this;
}
@Override
public OptionSpec shortNames(Character... names) {
this.shortNames = names;
return this;
}
@Override
public OptionSpec type(Type type) {
this.type = ResolvableType.forType(type);
return this;
}
@Override
public OptionSpec description(String description) {
this.description = description;
return this;
}
@Override
public OptionSpec required(boolean required) {
this.required = required;
return this;
}
@Override
public OptionSpec required() {
return required(true);
}
@Override
public OptionSpec defaultValue(String defaultValue) {
this.defaultValue = defaultValue;
return this;
}
@Override
public OptionSpec position(Integer position) {
this.position = position;
return this;
}
@Override
public OptionSpec arity(int min, int max) {
Assert.isTrue(min > -1, "arity min must be 0 or more");
Assert.isTrue(max >= min, "arity max must be equal more than min");
this.arityMin = min;
this.arityMax = max;
return this;
}
@Override
public OptionSpec arity(OptionArity arity) {
switch (arity) {
case ZERO:
this.arityMin = 0;
this.arityMax = 0;
break;
case ZERO_OR_ONE:
this.arityMin = 0;
this.arityMax = Integer.MAX_VALUE;
break;
case EXACTLY_ONE:
this.arityMin = 1;
this.arityMax = 1;
break;
case ZERO_OR_MORE:
this.arityMin = 0;
this.arityMax = Integer.MAX_VALUE;
break;
case ONE_OR_MORE:
this.arityMin = 1;
this.arityMax = Integer.MAX_VALUE;
break;
default:
this.arityMin = 0;
this.arityMax = 0;
break;
}
return this;
}
@Override
public Builder and() {
return builder;
}
public String[] getLongNames() {
return longNames;
}
public Character[] getShortNames() {
return shortNames;
}
public ResolvableType getType() {
return type;
}
public String getDescription() {
return description;
}
public boolean isRequired() {
return required;
}
public String getDefaultValue() {
return defaultValue;
}
public Integer getPosition() {
return position;
}
public Integer getArityMin() {
return arityMin;
}
public Integer getArityMax() {
return arityMax;
}
}
static class DefaultTargetSpec implements TargetSpec {
private BaseBuilder builder;
private Object bean;
private Method method;
private Function<CommandContext, ?> function;
private Consumer<CommandContext> consumer;
DefaultTargetSpec(BaseBuilder builder) {
this.builder = builder;
}
@Override
public TargetSpec method(Object bean, Method method) {
this.bean = bean;
this.method = method;
return this;
}
@Override
public TargetSpec method(Object bean, String method, Class<?>... paramTypes) {
this.bean = bean;
this.method = ReflectionUtils.findMethod(bean.getClass(), method,
ObjectUtils.isEmpty(paramTypes) ? null : paramTypes);
return this;
}
@Override
public TargetSpec function(Function<CommandContext, ?> function) {
this.function = function;
return this;
}
@Override
public TargetSpec consumer(Consumer<CommandContext> consumer) {
this.consumer = consumer;
return this;
}
@Override
public Builder and() {
return builder;
}
}
static class DefaultCommandRegistration implements CommandRegistration {
private String command;
private InteractionMode interactionMode;
private String help;
private String group;
private String description;
private Supplier<Availability> availability;
private List<DefaultOptionSpec> optionSpecs;
private DefaultTargetSpec targetSpec;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String help,
String group, String description, Supplier<Availability> availability,
List<DefaultOptionSpec> optionSpecs, DefaultTargetSpec targetSpec) {
this.command = commandArrayToName(commands);
this.interactionMode = interactionMode;
this.help = help;
this.group = group;
this.description = description;
this.availability = availability;
this.optionSpecs = optionSpecs;
this.targetSpec = targetSpec;
}
@Override
public String getCommand() {
return command;
}
@Override
public InteractionMode getInteractionMode() {
return interactionMode;
}
@Override
public String getHelp() {
return help;
}
@Override
public String getGroup() {
return group;
}
@Override
public String getDescription() {
return description;
}
@Override
public Availability getAvailability() {
return availability != null ? availability.get() : Availability.available();
}
@Override
public List<CommandOption> getOptions() {
return optionSpecs.stream()
.map(o -> CommandOption.of(o.getLongNames(), o.getShortNames(), o.getDescription(), o.getType(),
o.isRequired(), o.getDefaultValue(), o.getPosition(), o.getArityMin(), o.getArityMax()))
.collect(Collectors.toList());
}
@Override
public TargetInfo getTarget() {
if (targetSpec.bean != null) {
return TargetInfo.of(targetSpec.bean, targetSpec.method);
}
if (targetSpec.function != null) {
return TargetInfo.of(targetSpec.function);
}
if (targetSpec.consumer != null) {
return TargetInfo.of(targetSpec.consumer);
}
throw new IllegalArgumentException("No bean, function or consumer defined");
}
private static String commandArrayToName(String[] commands) {
return Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
.filter(c -> StringUtils.hasText(c))
.map(c -> c.trim())
.collect(Collectors.joining(" "));
}
}
static class DefaultBuilder extends BaseBuilder {
}
static class BaseBuilder implements Builder {
private String[] commands;
private InteractionMode interactionMode = InteractionMode.ALL;
private String help;
private String group;
private String description;
private Supplier<Availability> availability;
private List<DefaultOptionSpec> optionSpecs = new ArrayList<>();
private DefaultTargetSpec targetSpec;
@Override
public Builder command(String... commands) {
Assert.notNull(commands, "commands must be set");
this.commands = Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
.filter(c -> StringUtils.hasText(c))
.map(c -> c.trim())
.collect(Collectors.toList())
.toArray(new String[0]);
return this;
}
@Override
public Builder interactionMode(InteractionMode mode) {
this.interactionMode = mode != null ? mode : InteractionMode.ALL;
return this;
}
@Override
public Builder help(String help) {
this.help = help;
return this;
}
@Override
public Builder group(String group) {
this.group = group;
return this;
}
@Override
public Builder availability(Supplier<Availability> availability) {
this.availability = availability;
return this;
}
@Override
public OptionSpec withOption() {
DefaultOptionSpec spec = new DefaultOptionSpec(this);
optionSpecs.add(spec);
return spec;
}
@Override
public TargetSpec withTarget() {
DefaultTargetSpec spec = new DefaultTargetSpec(this);
targetSpec = spec;
return spec;
}
@Override
public CommandRegistration build() {
Assert.notNull(commands, "command cannot be empty");
Assert.notNull(targetSpec, "target cannot be empty");
Assert.state(!(targetSpec.bean != null && targetSpec.function != null), "only one target can exist");
return new DefaultCommandRegistration(commands, interactionMode, help, group, description, availability,
optionSpecs, targetSpec);
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.command;
import java.util.List;
/**
* Interface to resolve currently existing commands. It is useful to have fully
* dynamic set of commands which may exists only if some conditions in a running
* shell are met. For example if shell is targeting arbitrary server environment
* some commands may or may not exist depending on a runtime state.
*
* @author Janne Valkealahti
*/
@FunctionalInterface
public interface CommandResolver {
/**
* Resolve command registrations.
*
* @return command registrations
*/
List<CommandRegistration> resolve();
}

View File

@@ -0,0 +1,630 @@
/*
* 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.command.invocation;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.core.BridgeMethodResolver;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.SynthesizingMethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.shell.ParameterValidationException;
import org.springframework.shell.Utils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/**
* Encapsulates information about a handler method consisting of a
* {@linkplain #getMethod() method} and a {@linkplain #getBean() bean}.
* Provides convenient access to method parameters, the method return value,
* method annotations, etc.
*
* <p>The class may be created with a bean instance or with a bean name
* (e.g. lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()}
* to obtain a {@code HandlerMethod} instance with a bean instance resolved
* through the associated {@link BeanFactory}.
*
* @author Janne Valkealahti
*/
public class InvocableShellMethod {
/** Public for wrapping with fallback logger. */
public static final Logger log = LoggerFactory.getLogger(InvocableShellMethod.class);
private static final Object[] EMPTY_ARGS = new Object[0];
private final Object bean;
@Nullable
private final BeanFactory beanFactory;
private final Class<?> beanType;
private final Method method;
private final Method bridgedMethod;
private final MethodParameter[] parameters;
@Nullable
private InvocableShellMethod resolvedFromHandlerMethod;
private ShellMethodArgumentResolverComposite resolvers = new ShellMethodArgumentResolverComposite();
private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
private Validator validator;
/**
* Create an instance from a bean instance and a method.
*/
public InvocableShellMethod(Object bean, Method method) {
Assert.notNull(bean, "Bean is required");
Assert.notNull(method, "Method is required");
this.bean = bean;
this.beanFactory = null;
this.beanType = ClassUtils.getUserClass(bean);
this.method = method;
this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method);
ReflectionUtils.makeAccessible(this.bridgedMethod);
this.parameters = initMethodParameters();
}
/**
* Create an instance from a bean instance, method name, and parameter types.
* @throws NoSuchMethodException when the method cannot be found
*/
public InvocableShellMethod(Object bean, String methodName, Class<?>... parameterTypes) throws NoSuchMethodException {
Assert.notNull(bean, "Bean is required");
Assert.notNull(methodName, "Method name is required");
this.bean = bean;
this.beanFactory = null;
this.beanType = ClassUtils.getUserClass(bean);
this.method = bean.getClass().getMethod(methodName, parameterTypes);
this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(this.method);
ReflectionUtils.makeAccessible(this.bridgedMethod);
this.parameters = initMethodParameters();
}
/**
* Create an instance from a bean name, a method, and a {@code BeanFactory}.
* The method {@link #createWithResolvedBean()} may be used later to
* re-create the {@code HandlerMethod} with an initialized bean.
*/
public InvocableShellMethod(String beanName, BeanFactory beanFactory, Method method) {
Assert.hasText(beanName, "Bean name is required");
Assert.notNull(beanFactory, "BeanFactory is required");
Assert.notNull(method, "Method is required");
this.bean = beanName;
this.beanFactory = beanFactory;
Class<?> beanType = beanFactory.getType(beanName);
if (beanType == null) {
throw new IllegalStateException("Cannot resolve bean type for bean with name '" + beanName + "'");
}
this.beanType = ClassUtils.getUserClass(beanType);
this.method = method;
this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method);
ReflectionUtils.makeAccessible(this.bridgedMethod);
this.parameters = initMethodParameters();
}
/**
* Copy constructor for use in subclasses.
*/
protected InvocableShellMethod(InvocableShellMethod handlerMethod) {
Assert.notNull(handlerMethod, "HandlerMethod is required");
this.bean = handlerMethod.bean;
this.beanFactory = handlerMethod.beanFactory;
this.beanType = handlerMethod.beanType;
this.method = handlerMethod.method;
this.bridgedMethod = handlerMethod.bridgedMethod;
this.parameters = handlerMethod.parameters;
this.resolvedFromHandlerMethod = handlerMethod.resolvedFromHandlerMethod;
}
/**
* Re-create HandlerMethod with the resolved handler.
*/
private InvocableShellMethod(InvocableShellMethod handlerMethod, Object handler) {
Assert.notNull(handlerMethod, "HandlerMethod is required");
Assert.notNull(handler, "Handler object is required");
this.bean = handler;
this.beanFactory = handlerMethod.beanFactory;
this.beanType = handlerMethod.beanType;
this.method = handlerMethod.method;
this.bridgedMethod = handlerMethod.bridgedMethod;
this.parameters = handlerMethod.parameters;
this.resolvedFromHandlerMethod = handlerMethod;
}
public void setValidator(Validator validator) {
this.validator = validator;
}
/**
* Set {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} to use to use for resolving method argument values.
*/
public void setMessageMethodArgumentResolvers(ShellMethodArgumentResolverComposite argumentResolvers) {
this.resolvers = argumentResolvers;
}
/**
* Set the ParameterNameDiscoverer for resolving parameter names when needed
* (e.g. default request attribute name).
* <p>Default is a {@link org.springframework.core.DefaultParameterNameDiscoverer}.
*/
public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) {
this.parameterNameDiscoverer = parameterNameDiscoverer;
}
/**
* Invoke the method after resolving its argument values in the context of the given message.
* <p>Argument values are commonly resolved through
* {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}.
* The {@code providedArgs} parameter however may supply argument values to be used directly,
* i.e. without argument resolution.
* <p>Delegates to {@link #getMethodArgumentValues} and calls {@link #doInvoke} with the
* resolved arguments.
* @param message the current message being processed
* @param providedArgs "given" arguments matched by type, not resolved
* @return the raw value returned by the invoked method
* @throws Exception raised if no suitable argument resolver can be found,
* or if the method raised an exception
* @see #getMethodArgumentValues
* @see #doInvoke
*/
@Nullable
public Object invoke(Message<?> message, Object... providedArgs) throws Exception {
Object[] args = getMethodArgumentValues(message, providedArgs);
if (log.isTraceEnabled()) {
log.trace("Arguments: " + Arrays.toString(args));
}
return doInvoke(args);
}
/**
* Get the method argument values for the current message, checking the provided
* argument values and falling back to the configured argument resolvers.
* <p>The resulting array will be passed into {@link #doInvoke}.
*/
protected Object[] getMethodArgumentValues(Message<?> message, Object... providedArgs) throws Exception {
ConversionService conversionService = new DefaultConversionService();
MethodParameter[] parameters = getMethodParameters();
if (ObjectUtils.isEmpty(parameters)) {
return EMPTY_ARGS;
}
ResolvedHolder[] holders = new ResolvedHolder[parameters.length];
int unresolvedCount = 0;
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
boolean supports = this.resolvers.supportsParameter(parameter);
Object arg = null;
if (supports) {
arg = this.resolvers.resolveArgument(parameter, message);
}
else {
unresolvedCount++;
}
holders[i] = new ResolvedHolder(supports, parameter, arg);
}
Object[] args = new Object[parameters.length];
int providedArgsIndex = 0;
for (int i = 0; i < parameters.length; i++) {
if (!holders[i].resolved) {
if (providedArgs != null && unresolvedCount <= providedArgs.length) {
if (conversionService.canConvert(providedArgs[providedArgsIndex].getClass(), holders[i].parameter.getParameterType())) {
holders[i].arg = conversionService.convert(providedArgs[providedArgsIndex], holders[i].parameter.getParameterType());
providedArgsIndex++;
}
}
}
args[i] = holders[i].arg;
}
return args;
}
private static class ResolvedHolder {
boolean resolved;
MethodParameter parameter;
Object arg;
public ResolvedHolder(boolean resolved, MethodParameter parameter, Object arg) {
this.resolved = resolved;
this.parameter = parameter;
this.arg = arg;
}
}
/**
* Invoke the handler method with the given argument values.
*/
@Nullable
protected Object doInvoke(Object... args) throws Exception {
try {
if (validator != null) {
Method bridgedMethod = getBridgedMethod();
Validator validator = Utils.defaultValidator();
Set<ConstraintViolation<Object>> constraintViolations = validator.forExecutables()
.validateParameters(getBean(), bridgedMethod, args);
if (constraintViolations.size() > 0) {
throw new ParameterValidationException(constraintViolations);
}
}
return getBridgedMethod().invoke(getBean(), args);
}
catch (IllegalArgumentException ex) {
assertTargetBean(getBridgedMethod(), getBean(), args);
String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument");
throw new IllegalStateException(formatInvokeError(text, args), ex);
}
catch (InvocationTargetException ex) {
// Unwrap for HandlerExceptionResolvers ...
Throwable targetException = ex.getTargetException();
if (targetException instanceof RuntimeException) {
throw (RuntimeException) targetException;
}
else if (targetException instanceof Error) {
throw (Error) targetException;
}
else if (targetException instanceof Exception) {
throw (Exception) targetException;
}
else {
throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException);
}
}
}
MethodParameter getAsyncReturnValueType(@Nullable Object returnValue) {
return new AsyncResultMethodParameter(returnValue);
}
private MethodParameter[] initMethodParameters() {
int count = this.bridgedMethod.getParameterCount();
MethodParameter[] result = new MethodParameter[count];
for (int i = 0; i < count; i++) {
result[i] = new HandlerMethodParameter(i);
}
return result;
}
/**
* Return the bean for this handler method.
*/
public Object getBean() {
return this.bean;
}
/**
* Return the method for this handler method.
*/
public Method getMethod() {
return this.method;
}
/**
* This method returns the type of the handler for this handler method.
* <p>Note that if the bean type is a CGLIB-generated class, the original
* user-defined class is returned.
*/
public Class<?> getBeanType() {
return this.beanType;
}
/**
* If the bean method is a bridge method, this method returns the bridged
* (user-defined) method. Otherwise it returns the same method as {@link #getMethod()}.
*/
protected Method getBridgedMethod() {
return this.bridgedMethod;
}
/**
* Return the method parameters for this handler method.
*/
public MethodParameter[] getMethodParameters() {
return this.parameters;
}
/**
* Return the HandlerMethod return type.
*/
public MethodParameter getReturnType() {
return new HandlerMethodParameter(-1);
}
/**
* Return the actual return value type.
*/
public MethodParameter getReturnValueType(@Nullable Object returnValue) {
return new ReturnValueMethodParameter(returnValue);
}
/**
* Return {@code true} if the method return type is void, {@code false} otherwise.
*/
public boolean isVoid() {
return Void.TYPE.equals(getReturnType().getParameterType());
}
/**
* Return a single annotation on the underlying method traversing its super methods
* if no annotation can be found on the given method itself.
* <p>Also supports <em>merged</em> composed annotations with attribute
* overrides.
* @param annotationType the type of annotation to introspect the method for
* @return the annotation, or {@code null} if none found
* @see AnnotatedElementUtils#findMergedAnnotation
*/
@Nullable
public <A extends Annotation> A getMethodAnnotation(Class<A> annotationType) {
return AnnotatedElementUtils.findMergedAnnotation(this.method, annotationType);
}
/**
* Return whether the parameter is declared with the given annotation type.
* @param annotationType the annotation type to look for
* @see AnnotatedElementUtils#hasAnnotation
*/
public <A extends Annotation> boolean hasMethodAnnotation(Class<A> annotationType) {
return AnnotatedElementUtils.hasAnnotation(this.method, annotationType);
}
/**
* Return the HandlerMethod from which this HandlerMethod instance was
* resolved via {@link #createWithResolvedBean()}.
*/
@Nullable
public InvocableShellMethod getResolvedFromHandlerMethod() {
return this.resolvedFromHandlerMethod;
}
/**
* If the provided instance contains a bean name rather than an object instance,
* the bean name is resolved before a {@link HandlerMethod} is created and returned.
*/
public InvocableShellMethod createWithResolvedBean() {
Object handler = this.bean;
if (this.bean instanceof String) {
Assert.state(this.beanFactory != null, "Cannot resolve bean name without BeanFactory");
String beanName = (String) this.bean;
handler = this.beanFactory.getBean(beanName);
}
return new InvocableShellMethod(this, handler);
}
/**
* Return a short representation of this handler method for log message purposes.
*/
public String getShortLogMessage() {
int args = this.method.getParameterCount();
return getBeanType().getSimpleName() + "#" + this.method.getName() + "[" + args + " args]";
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof InvocableShellMethod)) {
return false;
}
InvocableShellMethod otherMethod = (InvocableShellMethod) other;
return (this.bean.equals(otherMethod.bean) && this.method.equals(otherMethod.method));
}
@Override
public int hashCode() {
return (this.bean.hashCode() * 31 + this.method.hashCode());
}
@Override
public String toString() {
return this.method.toGenericString();
}
// Support methods for use in "InvocableHandlerMethod" sub-class variants..
@Nullable
protected static Object findProvidedArgument(MethodParameter parameter, @Nullable Object... providedArgs) {
if (!ObjectUtils.isEmpty(providedArgs)) {
for (Object providedArg : providedArgs) {
if (parameter.getParameterType().isInstance(providedArg)) {
return providedArg;
}
}
}
return null;
}
protected static String formatArgumentError(MethodParameter param, String message) {
return "Could not resolve parameter [" + param.getParameterIndex() + "] in " +
param.getExecutable().toGenericString() + (StringUtils.hasText(message) ? ": " + message : "");
}
/**
* Assert that the target bean class is an instance of the class where the given
* method is declared. In some cases the actual endpoint instance at request-
* processing time may be a JDK dynamic proxy (lazy initialization, prototype
* beans, and others). Endpoint classes that require proxying should prefer
* class-based proxy mechanisms.
*/
protected void assertTargetBean(Method method, Object targetBean, Object[] args) {
Class<?> methodDeclaringClass = method.getDeclaringClass();
Class<?> targetBeanClass = targetBean.getClass();
if (!methodDeclaringClass.isAssignableFrom(targetBeanClass)) {
String text = "The mapped handler method class '" + methodDeclaringClass.getName() +
"' is not an instance of the actual endpoint bean class '" +
targetBeanClass.getName() + "'. If the endpoint requires proxying " +
"(e.g. due to @Transactional), please use class-based proxying.";
throw new IllegalStateException(formatInvokeError(text, args));
}
}
protected String formatInvokeError(String text, Object[] args) {
String formattedArgs = IntStream.range(0, args.length)
.mapToObj(i -> (args[i] != null ?
"[" + i + "] [type=" + args[i].getClass().getName() + "] [value=" + args[i] + "]" :
"[" + i + "] [null]"))
.collect(Collectors.joining(",\n", " ", " "));
return text + "\n" +
"Endpoint [" + getBeanType().getName() + "]\n" +
"Method [" + getBridgedMethod().toGenericString() + "] " +
"with argument values:\n" + formattedArgs;
}
/**
* A MethodParameter with HandlerMethod-specific behavior.
*/
protected class HandlerMethodParameter extends SynthesizingMethodParameter {
public HandlerMethodParameter(int index) {
super(InvocableShellMethod.this.bridgedMethod, index);
}
protected HandlerMethodParameter(HandlerMethodParameter original) {
super(original);
}
@Override
public Class<?> getContainingClass() {
return InvocableShellMethod.this.getBeanType();
}
@Override
public <T extends Annotation> T getMethodAnnotation(Class<T> annotationType) {
return InvocableShellMethod.this.getMethodAnnotation(annotationType);
}
@Override
public <T extends Annotation> boolean hasMethodAnnotation(Class<T> annotationType) {
return InvocableShellMethod.this.hasMethodAnnotation(annotationType);
}
@Override
public HandlerMethodParameter clone() {
return new HandlerMethodParameter(this);
}
}
/**
* A MethodParameter for a HandlerMethod return type based on an actual return value.
*/
private class ReturnValueMethodParameter extends HandlerMethodParameter {
@Nullable
private final Object returnValue;
public ReturnValueMethodParameter(@Nullable Object returnValue) {
super(-1);
this.returnValue = returnValue;
}
protected ReturnValueMethodParameter(ReturnValueMethodParameter original) {
super(original);
this.returnValue = original.returnValue;
}
@Override
public Class<?> getParameterType() {
return (this.returnValue != null ? this.returnValue.getClass() : super.getParameterType());
}
@Override
public ReturnValueMethodParameter clone() {
return new ReturnValueMethodParameter(this);
}
}
private class AsyncResultMethodParameter extends HandlerMethodParameter {
@Nullable
private final Object returnValue;
private final ResolvableType returnType;
public AsyncResultMethodParameter(@Nullable Object returnValue) {
super(-1);
this.returnValue = returnValue;
this.returnType = ResolvableType.forType(super.getGenericParameterType()).getGeneric();
}
protected AsyncResultMethodParameter(AsyncResultMethodParameter original) {
super(original);
this.returnValue = original.returnValue;
this.returnType = original.returnType;
}
@Override
public Class<?> getParameterType() {
if (this.returnValue != null) {
return this.returnValue.getClass();
}
if (!ResolvableType.NONE.equals(this.returnType)) {
return this.returnType.toClass();
}
return super.getParameterType();
}
@Override
public Type getGenericParameterType() {
return this.returnType.getType();
}
@Override
public AsyncResultMethodParameter clone() {
return new AsyncResultMethodParameter(this);
}
}
}

View File

@@ -0,0 +1,143 @@
/*
* 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.command.invocation;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
/**
* Resolves method parameters by delegating to a list of registered
* {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}.
* Previously resolved method parameters are cached for faster lookups.
*
* @author Rossen Stoyanchev
* @author Juergen Hoeller
*/
public class ShellMethodArgumentResolverComposite implements HandlerMethodArgumentResolver {
private final List<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<>();
private final Map<MethodParameter, HandlerMethodArgumentResolver> argumentResolverCache =
new ConcurrentHashMap<>(256);
/**
* Add the given {@link HandlerMethodArgumentResolver}.
*/
public ShellMethodArgumentResolverComposite addResolver(HandlerMethodArgumentResolver resolver) {
this.argumentResolvers.add(resolver);
AnnotationAwareOrderComparator.sort(this.argumentResolvers);
return this;
}
/**
* Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}.
* @since 4.3
*/
public ShellMethodArgumentResolverComposite addResolvers(
@Nullable HandlerMethodArgumentResolver... resolvers) {
if (resolvers != null) {
Collections.addAll(this.argumentResolvers, resolvers);
}
AnnotationAwareOrderComparator.sort(this.argumentResolvers);
return this;
}
/**
* Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}.
*/
public ShellMethodArgumentResolverComposite addResolvers(
@Nullable List<? extends HandlerMethodArgumentResolver> resolvers) {
if (resolvers != null) {
this.argumentResolvers.addAll(resolvers);
}
AnnotationAwareOrderComparator.sort(this.argumentResolvers);
return this;
}
/**
* Return a read-only list with the contained resolvers, or an empty list.
*/
public List<HandlerMethodArgumentResolver> getResolvers() {
return Collections.unmodifiableList(this.argumentResolvers);
}
/**
* Clear the list of configured resolvers and the resolver cache.
*/
public void clear() {
this.argumentResolvers.clear();
this.argumentResolverCache.clear();
}
/**
* Whether the given {@linkplain MethodParameter method parameter} is
* supported by any registered {@link HandlerMethodArgumentResolver}.
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
return getArgumentResolver(parameter) != null;
}
/**
* Iterate over registered
* {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}
* and invoke the one that supports it.
* @throws IllegalArgumentException if no suitable argument resolver is found
*/
@Override
@Nullable
public Object resolveArgument(MethodParameter parameter, Message<?> message) throws Exception {
HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unsupported parameter type [" +
parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
}
return resolver.resolveArgument(parameter, message);
}
/**
* Find a registered {@link HandlerMethodArgumentResolver} that supports
* the given method parameter.
*/
@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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.completion;
import java.util.List;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.command.CommandRegistration;
/**
* Interface resolving completion proposals.
*
* @author Janne Valkealahti
*/
public interface CompletionResolver {
/**
* Resolve completions.
*
* @param registration the command registration
* @param context the completion context
* @return list of resolved completions
*/
List<CompletionProposal> resolve(CommandRegistration registration, CompletionContext context);
}

View File

@@ -0,0 +1,46 @@
/*
* 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.completion;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.command.CommandRegistration;
/**
* Default implementation of a {@link CompletionResolver}.
*
* @author Janne Valkealahti
*/
public class DefaultCompletionResolver implements CompletionResolver {
@Override
public List<CompletionProposal> resolve(CommandRegistration registration, CompletionContext context) {
List<CompletionProposal> candidates = new ArrayList<>();
registration.getOptions().stream()
.flatMap(o -> Stream.of(o.getLongNames()))
.map(ln -> new CompletionProposal("--" + ln))
.forEach(candidates::add);
registration.getOptions().stream()
.flatMap(o -> Stream.of(o.getShortNames()))
.map(ln -> new CompletionProposal("-" + ln))
.forEach(candidates::add);
return candidates;
}
}

View File

@@ -0,0 +1,30 @@
package org.springframework.shell.result;
import org.jline.terminal.Terminal;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;;
/**
* Displays command parsing errors on the terminal.
*
* @author Janne Valkealahti
*/
public class CommandParserExceptionsExceptionResultHandler extends TerminalAwareResultHandler<CommandParserExceptionsException> {
public CommandParserExceptionsExceptionResultHandler(Terminal terminal) {
super(terminal);
}
@Override
protected void doHandleResult(CommandParserExceptionsException result) {
AttributedStringBuilder builder = new AttributedStringBuilder();
result.getParserExceptions().stream().forEach(e -> {
builder.append(new AttributedString(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
builder.append("\n");
});
terminal.writer().append(builder.toAnsi());
}
}

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.
@@ -13,33 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.result;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import javax.validation.ElementKind;
import javax.validation.Path;
import org.jline.terminal.Terminal;
import org.jline.utils.AttributedString;
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.shell.ParameterDescription;
import org.springframework.shell.ParameterResolver;
import org.springframework.shell.ParameterValidationException;
import org.springframework.shell.Utils;
/**
* Displays validation errors on the terminal.
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
public class ParameterValidationExceptionResultHandler
extends TerminalAwareResultHandler<ParameterValidationException> {
@@ -48,48 +34,15 @@ public class ParameterValidationExceptionResultHandler
super(terminal);
}
@Autowired
private List<ParameterResolver> parameterResolvers;
@Override
protected void doHandleResult(ParameterValidationException result) {
terminal.writer().println(new AttributedString("The following constraints were not met:",
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi());
result.getConstraintViolations().stream()
.forEach(v -> {
Optional<Integer> parameterIndex = StreamSupport.stream(v.getPropertyPath().spliterator(), false)
.filter(n -> n.getKind() == ElementKind.PARAMETER)
.map(n -> ((Path.ParameterNode) n).getParameterIndex())
.findFirst();
MethodParameter methodParameter = Utils.createMethodParameter(result.getMethodTarget().getMethod(),
parameterIndex.get());
List<ParameterDescription> descriptions = findParameterResolver(methodParameter)
.describe(methodParameter).collect(Collectors.toList());
if (descriptions.size() == 1) {
ParameterDescription description = descriptions.get(0);
AttributedStringBuilder ansi = new AttributedStringBuilder(100);
ansi.append("\t").append(description.keys().get(0), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED).bold());
ansi.append(" ").append(description.formal(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED).underline());
String msg = String.format(" : %s (You passed '%s')",
v.getMessage(),
String.valueOf(v.getInvalidValue())
);
ansi.append(msg, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED));
terminal.writer().println(ansi.toAnsi(terminal));
}
// Several formals for one method param, must be framework like JCommander, etc
else {
// Output toString() for now...
terminal.writer().println(new AttributedString(v.toString(),
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi(terminal));
}
terminal.writer().println(new AttributedString(v.toString(),
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi(terminal));
});
}
private ParameterResolver findParameterResolver(MethodParameter methodParameter) {
return parameterResolvers.stream().filter(pr -> pr.supports(methodParameter)).findFirst().get();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2017-2021 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.
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.result;
import org.jline.terminal.Terminal;
@@ -22,8 +21,8 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.TerminalSizeAware;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.jline.InteractiveShellRunner;
/**
@@ -57,9 +56,13 @@ public class ResultHandlerConfig {
}
@Bean
public ThrowableResultHandler throwableResultHandler(Terminal terminal, CommandRegistry commandRegistry,
ObjectProvider<InteractiveShellRunner> interactiveApplicationRunner) {
return new ThrowableResultHandler(terminal, commandRegistry, interactiveApplicationRunner);
public CommandParserExceptionsExceptionResultHandler commandParserExceptionsExceptionResultHandler(Terminal terminal) {
return new CommandParserExceptionsExceptionResultHandler(terminal);
}
@Bean
public ThrowableResultHandler throwableResultHandler(Terminal terminal, CommandCatalog commandCatalog,
ObjectProvider<InteractiveShellRunner> interactiveApplicationRunner) {
return new ThrowableResultHandler(terminal, commandCatalog, interactiveApplicationRunner);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2015 the original author or authors.
* Copyright 2015-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.
@@ -22,8 +22,8 @@ import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.ResultHandler;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.jline.InteractiveShellRunner;
import org.springframework.util.StringUtils;
@@ -43,14 +43,14 @@ public class ThrowableResultHandler extends TerminalAwareResultHandler<Throwable
private Throwable lastError;
private CommandRegistry commandRegistry;
private CommandCatalog commandCatalog;
private ObjectProvider<InteractiveShellRunner> interactiveRunner;
public ThrowableResultHandler(Terminal terminal, CommandRegistry commandRegistry,
public ThrowableResultHandler(Terminal terminal, CommandCatalog commandCatalog,
ObjectProvider<InteractiveShellRunner> interactiveRunner) {
super(terminal);
this.commandRegistry = commandRegistry;
this.commandCatalog = commandCatalog;
this.interactiveRunner = interactiveRunner;
}
@@ -60,7 +60,7 @@ public class ThrowableResultHandler extends TerminalAwareResultHandler<Throwable
String toPrint = StringUtils.hasLength(result.getMessage()) ? result.getMessage() : result.toString();
terminal.writer().println(new AttributedString(toPrint,
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi());
if (interactiveRunner.getIfAvailable() != null && commandRegistry.listCommands().containsKey(DETAILS_COMMAND_NAME)) {
if (interactiveRunner.getIfAvailable() != null && commandCatalog.getRegistrations().keySet().contains(DETAILS_COMMAND_NAME)) {
terminal.writer().println(
new AttributedStringBuilder()
.append("Details of the error have been omitted. You can use the ", AttributedStyle.DEFAULT.foreground(AttributedStyle.RED))

View File

@@ -0,0 +1,253 @@
/*
* 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.support;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.config.BeanExpressionContext;
import org.springframework.beans.factory.config.BeanExpressionResolver;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.core.MethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.ValueConstants;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.util.ClassUtils;
/**
* Abstract base class to resolve method arguments from a named value, e.g.
* message headers or destination variables. Named values could have one or more
* of a name, a required flag, and a default value.
*
* <p>Subclasses only need to define specific steps such as how to obtain named
* value details from a method parameter, how to resolve to argument values, or
* how to handle missing values.
*
* <p>A default value string can contain ${...} placeholders and Spring
* Expression Language {@code #{...}} expressions which will be resolved if a
* {@link ConfigurableBeanFactory} is supplied to the class constructor.
*
* <p>A {@link ConversionService} is used to convert a resolved String argument
* value to the expected target method parameter type.
*
* @author Janne Valkealahti
*/
public abstract class AbstractArgumentMethodArgumentResolver implements HandlerMethodArgumentResolver {
public static final String ARGUMENT_PREFIX = "springShellArgument.";
private final ConversionService conversionService;
@Nullable
private final ConfigurableBeanFactory configurableBeanFactory;
@Nullable
private final BeanExpressionContext expressionContext;
private final Map<MethodParameter, NamedValueInfo> namedValueInfoCache = new ConcurrentHashMap<>(256);
/**
* Constructor with a {@link ConversionService} and a {@link BeanFactory}.
* @param conversionService conversion service for converting String values
* to the target method parameter type
* @param beanFactory a bean factory for resolving {@code ${...}}
* placeholders and {@code #{...}} SpEL expressions in default values
*/
protected AbstractArgumentMethodArgumentResolver(ConversionService conversionService,
@Nullable ConfigurableBeanFactory beanFactory) {
// Fallback on shared ConversionService for now for historic reasons.
// Possibly remove after discussion in gh-23882.
//noinspection ConstantConditions
this.conversionService = (conversionService != null ?
conversionService : DefaultConversionService.getSharedInstance());
this.configurableBeanFactory = beanFactory;
this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null);
}
@Override
public Object resolveArgument(MethodParameter parameter, Message<?> message) throws Exception {
NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
MethodParameter nestedParameter = parameter.nestedIfOptional();
Object arg = resolveArgumentInternal(nestedParameter, message, namedValueInfo.names);
if (arg == null) {
if (namedValueInfo.defaultValue != null) {
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
}
else if (namedValueInfo.required && !nestedParameter.isOptional()) {
handleMissingValue(namedValueInfo.names, nestedParameter, message);
}
arg = handleNullValue(namedValueInfo.names, arg, nestedParameter.getNestedParameterType());
}
else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
}
if (parameter != nestedParameter || !ClassUtils.isAssignableValue(parameter.getParameterType(), arg)) {
arg = this.conversionService.convert(arg, TypeDescriptor.forObject(arg), new TypeDescriptor(parameter));
// Check for null value after conversion of incoming argument value
if (arg == null && namedValueInfo.defaultValue == null &&
namedValueInfo.required && !nestedParameter.isOptional()) {
handleMissingValue(namedValueInfo.names, nestedParameter, message);
}
}
handleResolvedValue(arg, namedValueInfo.names, parameter, message);
return arg;
}
/**
* Obtain the named value for the given method parameter.
*/
private NamedValueInfo getNamedValueInfo(MethodParameter parameter) {
NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter);
if (namedValueInfo == null) {
namedValueInfo = createNamedValueInfo(parameter);
namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo);
this.namedValueInfoCache.put(parameter, namedValueInfo);
}
return namedValueInfo;
}
/**
* Create the {@link NamedValueInfo} object for the given method parameter.
* Implementations typically retrieve the method annotation by means of
* {@link MethodParameter#getParameterAnnotation(Class)}.
* @param parameter the method parameter
* @return the named value information
*/
protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter);
/**
* Fall back on the parameter name from the class file if necessary and
* replace {@link ValueConstants#DEFAULT_NONE} with null.
*/
private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) {
List<String> names = info.names;
if (info.names.isEmpty()) {
String name = parameter.getParameterName();
if (name == null) {
throw new IllegalArgumentException(
"Name for argument of type [" + parameter.getNestedParameterType().getName() +
"] not specified, and parameter name information not found in class file either.");
}
names.add(name);
}
return new NamedValueInfo(names, info.required,
ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue);
}
/**
* Resolve the given annotation-specified value,
* potentially containing placeholders and expressions.
*/
@Nullable
private Object resolveEmbeddedValuesAndExpressions(String value) {
if (this.configurableBeanFactory == null || this.expressionContext == null) {
return value;
}
String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value);
BeanExpressionResolver exprResolver = this.configurableBeanFactory.getBeanExpressionResolver();
if (exprResolver == null) {
return value;
}
return exprResolver.evaluate(placeholdersResolved, this.expressionContext);
}
/**
* Resolves the given parameter type and value name into an argument value.
* @param parameter the method parameter to resolve to an argument value
* @param message the current request
* @param name the name of the value being resolved
* @return the resolved argument. May be {@code null}
* @throws Exception in case of errors
*/
@Nullable
protected abstract Object resolveArgumentInternal(MethodParameter parameter, Message<?> message, List<String> names)
throws Exception;
/**
* Invoked when a value is required, but {@link #resolveArgumentInternal}
* returned {@code null} and there is no default value. Sub-classes can
* throw an appropriate exception for this case.
* @param names the name for the value
* @param parameter the target method parameter
* @param message the message being processed
*/
protected abstract void handleMissingValue(List<String> names, MethodParameter parameter, Message<?> message);
/**
* One last chance to handle a possible null value.
* Specifically for booleans method parameters, use {@link Boolean#FALSE}.
* Also raise an ISE for primitive types.
*/
@Nullable
private Object handleNullValue(List<String> name, @Nullable Object value, Class<?> paramType) {
if (value == null) {
if (Boolean.TYPE.equals(paramType)) {
return Boolean.FALSE;
}
else if (paramType.isPrimitive()) {
throw new IllegalStateException("Optional " + paramType + " parameter '" + name +
"' is present but cannot be translated into a null value due to being " +
"declared as a primitive type. Consider declaring it as object wrapper " +
"for the corresponding primitive type.");
}
}
return value;
}
/**
* Invoked after a value is resolved.
* @param arg the resolved argument value
* @param name the argument name
* @param parameter the argument parameter type
* @param message the message
*/
protected void handleResolvedValue(
@Nullable Object arg, List<String> name, MethodParameter parameter, Message<?> message) {
}
/**
* Represents a named value declaration.
*/
protected static class NamedValueInfo {
private final List<String> names;
private final boolean required;
@Nullable
private final String defaultValue;
protected NamedValueInfo(List<String> names, boolean required, @Nullable String defaultValue) {
this.names = names;
this.required = required;
this.defaultValue = defaultValue;
}
}
}

View File

@@ -1,52 +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
*
* 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;
import org.junit.jupiter.api.Test;
import org.springframework.shell.context.DefaultShellContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ConfigurableCommandRegistry}.
*
* @author Eric Bottard
*/
public class ConfigurableCommandRegistryTest {
@Test
public void testRegistration() {
ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(new DefaultShellContext());
registry.register("foo", MethodTarget.of("toString", this, new Command.Help("some command")));
assertThat(registry.listCommands()).containsKeys("foo");
}
@Test
public void testDoubleRegistration() {
ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(new DefaultShellContext());
registry.register("foo", MethodTarget.of("toString", this, new Command.Help("some command")));
assertThatThrownBy(() -> {
registry.register("foo", MethodTarget.of("hashCode", this, new Command.Help("some command")));
}).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("foo")
.hasMessageContaining("toString")
.hasMessageContaining("hashCode");
}
}

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.
@@ -13,12 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -31,6 +29,10 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.completion.CompletionResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
@@ -44,7 +46,7 @@ import static org.mockito.Mockito.when;
* @author Eric Bottard
*/
@ExtendWith(MockitoExtension.class)
public class ShellTest {
public class ShellTests {
@Mock
private InputProvider inputProvider;
@@ -53,12 +55,10 @@ public class ShellTest {
ResultHandlerService resultHandlerService;
@Mock
CommandRegistry commandRegistry;
CommandCatalog commandRegistry;
@Mock
private ParameterResolver parameterResolver;
private ValueResult valueResult;
private CompletionResolver completionResolver;
@InjectMocks
private Shell shell;
@@ -67,26 +67,30 @@ public class ShellTest {
@BeforeEach
public void setUp() {
shell.parameterResolvers = Arrays.asList(parameterResolver);
shell.setCompletionResolvers(Arrays.asList(completionResolver));
}
@Test
public void commandMatch() throws IOException {
when(parameterResolver.supports(any())).thenReturn(true);
when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?");
valueResult = new ValueResult(null, "test");
when(parameterResolver.resolve(any(), any())).thenReturn(valueResult);
doThrow(new Exit()).when(resultHandlerService).handle(any());
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("hello world",
MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
CommandRegistration registration = CommandRegistration.builder()
.command("hello world")
.withTarget()
.method(this, "helloWorld")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
fail("Exit expected");
}
catch (Exit expected) {
System.out.println(expected);
}
assertThat(invoked).isTrue();
@@ -97,8 +101,15 @@ public class ShellTest {
when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?");
doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class));
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("bonjour",
MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
CommandRegistration registration = CommandRegistration.builder()
.command("bonjour")
.withTarget()
.method(this, "helloWorld")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -115,8 +126,15 @@ public class ShellTest {
when(inputProvider.readInput()).thenReturn(() -> "helloworld how are you doing ?");
doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class));
when(commandRegistry.listCommands()).thenReturn(
Collections.singletonMap("hello", MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
CommandRegistration registration = CommandRegistration.builder()
.command("hello world")
.withTarget()
.method(this, "helloWorld")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -129,14 +147,18 @@ public class ShellTest {
@Test
public void noCommand() throws IOException {
when(parameterResolver.supports(any())).thenReturn(true);
when(inputProvider.readInput()).thenReturn(() -> "", () -> "hello world how are you doing ?", null);
valueResult = new ValueResult(null, "test");
when(parameterResolver.resolve(any(), any())).thenReturn(valueResult);
doThrow(new Exit()).when(resultHandlerService).handle(any());
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("hello world",
MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))));
CommandRegistration registration = CommandRegistration.builder()
.command("hello world")
.withTarget()
.method(this, "helloWorld")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -154,8 +176,16 @@ public class ShellTest {
when(inputProvider.readInput()).thenReturn(() -> "fail");
doThrow(new Exit()).when(resultHandlerService).handle(isA(SomeException.class));
when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("fail",
MethodTarget.of("failing", this, new Command.Help("Will throw an exception"))));
CommandRegistration registration = CommandRegistration.builder()
.command("fail")
.withTarget()
.method(this, "failing")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("fail", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -177,17 +207,27 @@ public class ShellTest {
@Test
public void commandNameCompletion() throws Exception {
Map<String, MethodTarget> methodTargets = new HashMap<>();
methodTargets.put("hello world", MethodTarget.of("helloWorld", this, new Command.Help("hellow world")));
methodTargets.put("another command", MethodTarget.of("helloWorld", this, new Command.Help("another command")));
when(parameterResolver.supports(any())).thenReturn(true);
when(commandRegistry.listCommands()).thenReturn(methodTargets);
CommandRegistration registration1 = CommandRegistration.builder()
.command("hello world")
.withTarget()
.method(this, "helloWorld")
.and()
.build();
CommandRegistration registration2 = CommandRegistration.builder()
.command("another command")
.withTarget()
.method(this, "helloWorld")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration1);
registrations.put("another command", registration2);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
// Invoke at very start
List<String> proposals = shell.complete(new CompletionContext(Arrays.asList(""), 0, "".length()))
.stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(proposals).containsExactlyInAnyOrder("another command", "hello world");
// assertThat(proposals).containsExactly("another command", "hello world");
// Invoke in middle of first word
proposals = shell.complete(new CompletionContext(Arrays.asList("hel"), 0, "hel".length()))
@@ -231,6 +271,51 @@ public class ShellTest {
throw new SomeException();
}
@Test
public void completionArgWithMethod() throws Exception {
when(completionResolver.resolve(any(), any())).thenReturn(Arrays.asList(new CompletionProposal("--arg1")));
CommandRegistration registration1 = CommandRegistration.builder()
.command("hello world")
.withTarget()
.method(this, "helloWorld")
.and()
.withOption()
.longNames("arg1")
.description("arg1 desc")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration1);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
List<String> proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length()))
.stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(proposals).containsExactlyInAnyOrder("--arg1");
}
@Test
public void completionArgWithFunction() throws Exception {
when(completionResolver.resolve(any(), any())).thenReturn(Arrays.asList(new CompletionProposal("--arg1")));
CommandRegistration registration1 = CommandRegistration.builder()
.command("hello world")
.withTarget()
.function(ctx -> {
return null;
})
.and()
.withOption()
.longNames("arg1")
.description("arg1 desc")
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration1);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
List<String> proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length()))
.stream().map(CompletionProposal::value).collect(Collectors.toList());
assertThat(proposals).containsExactlyInAnyOrder("--arg1");
}
private static class Exit extends RuntimeException {
}
@@ -238,6 +323,4 @@ public class ShellTest {
private static class SomeException extends RuntimeException {
}
}

View File

@@ -1,37 +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
*
* 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;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link Utils}.
*
* @author Eric Bottard
*/
public class UtilsTest {
@Test
public void testUnCamelify() throws Exception {
assertThat(Utils.unCamelify("HelloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorldHowAreYou")).isEqualTo("hello-world-how-are-you");
assertThat(Utils.unCamelify("URL")).isEqualTo("url");
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2015-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;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link Utils}.
*
* @author Eric Bottard
*/
public class UtilsTests {
@Test
public void testUnCamelify() throws Exception {
assertThat(Utils.unCamelify("HelloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorldHowAreYou")).isEqualTo("hello-world-how-are-you");
assertThat(Utils.unCamelify("URL")).isEqualTo("url");
}
@Test
public void testSplit() {
Predicate<String> predicate = t -> t.startsWith("-");
List<List<String>> split = null;
split = Utils.split(new String[] { "-a1", "a1" }, predicate);
assertThat(split).containsExactly(Arrays.asList("-a1", "a1"));
split = Utils.split(new String[] { "-a1", "a1", "-a2", "a2" }, predicate);
assertThat(split).containsExactly(Arrays.asList("-a1", "a1"), Arrays.asList("-a2", "a2"));
split = Utils.split(new String[] { "a0", "-a1", "a1" }, predicate);
assertThat(split).containsExactly(Arrays.asList("a0"), Arrays.asList("-a1", "a1"));
split = Utils.split(new String[] { "-a1", "-a2" }, predicate);
assertThat(split).containsExactly(Arrays.asList("-a1"), Arrays.asList("-a2"));
split = Utils.split(new String[] { "a1", "a2" }, predicate);
assertThat(split).containsExactly(Arrays.asList("a1", "a2"));
split = Utils.split(new String[] { "-a1", "a1", "a2" }, predicate);
assertThat(split).containsExactly(Arrays.asList("-a1", "a1", "a2"));
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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.command;
import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.messaging.handler.annotation.Header;
public abstract class AbstractCommandTests {
protected Pojo1 pojo1;
protected Function<CommandContext, String> function1 = ctx -> {
String arg1 = ctx.getOptionValue("arg1");
return "hi" + arg1;
};
protected Function<CommandContext, Void> function2 = ctx -> {
return null;
};
@BeforeEach
public void setupAbstractCommandTests() {
pojo1 = new Pojo1();
}
protected static class Pojo1 {
public int method1Count;
public CommandContext method1Ctx;
public int method2Count;
public int method3Count;
public int method4Count;
public String method4Arg1;
public Boolean method5ArgA;
public Boolean method5ArgB;
public Boolean method5ArgC;
public int method6Count;
public String method6Arg1;
public String method6Arg2;
public String method6Arg3;
public int method7Count;
public int method7Arg1;
public int method7Arg2;
public int method7Arg3;
public int method8Count;
public float[] method8Arg1;
public void method1(CommandContext ctx) {
method1Ctx = ctx;
method1Count++;
}
public String method2() {
method2Count++;
return "hi";
}
public String method3(@Header("arg1") String arg1) {
method3Count++;
return "hi" + arg1;
}
public String method4(String arg1) {
method4Arg1 = arg1;
method4Count++;
return "hi" + arg1;
}
public void method5(@Header("a") boolean a, @Header("b") boolean b, @Header("c") boolean c) {
method5ArgA = a;
method5ArgB = b;
method5ArgC = c;
}
public String method6(String arg1, String arg2, String arg3) {
method6Arg1 = arg1;
method6Arg2 = arg2;
method6Arg3 = arg3;
method6Count++;
return "hi" + arg1 + arg2 + arg3;
}
public void method7(int arg1, int arg2, int arg3) {
method7Arg1 = arg1;
method7Arg2 = arg2;
method7Arg3 = arg3;
method7Count++;
}
public void method8(float[] arg1) {
method8Arg1 = arg1;
method8Count++;
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.command;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class CommandCatalogTests extends AbstractCommandTests {
@Test
public void testCommandCatalog () {
CommandRegistration r1 = CommandRegistration.builder()
.command("group1 sub1")
.withTarget()
.function(function1)
.and()
.build();
CommandCatalog catalog = CommandCatalog.of();
catalog.register(r1);
assertThat(catalog.getRegistrations()).hasSize(1);
catalog.unregister(r1);
assertThat(catalog.getRegistrations()).hasSize(0);
}
@Test
public void testResolver() {
// catalog itself would not have any registered command but
// this custom resolver adds one which may dymanically go away.
DynamicCommandResolver resolver = new DynamicCommandResolver();
CommandCatalog catalog = CommandCatalog.of(Arrays.asList(resolver), null);
assertThat(catalog.getRegistrations()).hasSize(1);
resolver.enabled = false;
assertThat(catalog.getRegistrations()).hasSize(0);
}
class DynamicCommandResolver implements CommandResolver {
CommandRegistration r1 = CommandRegistration.builder()
.command("group1 sub1")
.withTarget()
.function(function1)
.and()
.build();
boolean enabled = true;
@Override
public List<CommandRegistration> resolve() {
List<CommandRegistration> regs = new ArrayList<>();
if (enabled) {
regs.add(r1);
}
return regs;
}
}
}

View File

@@ -0,0 +1,441 @@
/*
* 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.command;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;
import org.springframework.shell.command.CommandRegistration.OptionArity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class CommandExecutionTests extends AbstractCommandTests {
private CommandExecution execution;
@BeforeEach
public void setupCommandExecutionTests() {
List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>();
resolvers.add(new ArgumentHeaderMethodArgumentResolver(new DefaultConversionService(), null));
resolvers.add(new CommandContextMethodArgumentResolver());
execution = CommandExecution.of(resolvers);
}
@Test
public void testFunctionExecution() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withTarget()
.function(function1)
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
assertThat(result).isEqualTo("himyarg1value");
}
@Test
public void testMethodExecution1() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withTarget()
.method(pojo1, "method3", String.class)
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
assertThat(result).isEqualTo("himyarg1value");
assertThat(pojo1.method3Count).isEqualTo(1);
}
@Test
public void testMethodExecution2() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withTarget()
.method(pojo1, "method1")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
assertThat(pojo1.method1Count).isEqualTo(1);
assertThat(pojo1.method1Ctx).isNotNull();
}
@Test
public void testMethodSinglePositionalArgs() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.position(0)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value"});
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value");
}
@Test
public void testMethodSingleWithNamedArgs() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value");
assertThat(result).isEqualTo("himyarg1value");
}
@Test
public void testMethodMultiPositionalArgs() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.position(0)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"});
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value1");
}
@Test
public void testMethodMultiPositionalArgsAll() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.position(0)
.arity(OptionArity.ONE_OR_MORE)
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"});
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value1 myarg1value2");
}
@Test
public void testMethodMultipleArgs() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withOption()
.longNames("arg2")
.description("some arg2")
.and()
.withOption()
.longNames("arg3")
.description("some arg3")
.and()
.withTarget()
.method(pojo1, "method6")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value"});
assertThat(pojo1.method6Count).isEqualTo(1);
assertThat(pojo1.method6Arg1).isEqualTo("myarg1value");
assertThat(pojo1.method6Arg2).isEqualTo("myarg2value");
assertThat(pojo1.method6Arg3).isEqualTo("myarg3value");
}
@Test
public void testMethodMultipleIntArgs() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withOption()
.longNames("arg2")
.description("some arg2")
.and()
.withOption()
.longNames("arg3")
.description("some arg3")
.and()
.withTarget()
.method(pojo1, "method7")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "1", "--arg2", "2", "--arg3", "3"});
assertThat(pojo1.method7Count).isEqualTo(1);
assertThat(pojo1.method7Arg1).isEqualTo(1);
assertThat(pojo1.method7Arg2).isEqualTo(2);
assertThat(pojo1.method7Arg3).isEqualTo(3);
}
@Test
public void testMethodMultiplePositionalStringArgs() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.position(0)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withOption()
.longNames("arg2")
.description("some arg2")
.position(1)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withOption()
.longNames("arg3")
.description("some arg3")
.position(2)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withTarget()
.method(pojo1, "method6")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value", "myarg2value", "myarg3value"});
assertThat(pojo1.method6Count).isEqualTo(1);
assertThat(pojo1.method6Arg1).isEqualTo("myarg1value");
assertThat(pojo1.method6Arg2).isEqualTo("myarg2value");
assertThat(pojo1.method6Arg3).isEqualTo("myarg3value");
}
@ParameterizedTest
@ValueSource(strings = {
"myarg1value --arg2 myarg2value --arg3 myarg3value",
"--arg1 myarg1value myarg2value --arg3 myarg3value",
"--arg1 myarg1value --arg2 myarg2value myarg3value"
})
public void testMethodMultiplePositionalStringArgsMixed(String arg) {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.position(0)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withOption()
.longNames("arg2")
.description("some arg2")
.position(1)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withOption()
.longNames("arg3")
.description("some arg3")
.position(2)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withTarget()
.method(pojo1, "method6")
.and()
.build();
String[] args = arg.split(" ");
// execution.evaluate(r1, new String[]{"myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value"});
execution.evaluate(r1, args);
assertThat(pojo1.method6Count).isEqualTo(1);
assertThat(pojo1.method6Arg1).isEqualTo("myarg1value");
assertThat(pojo1.method6Arg2).isEqualTo("myarg2value");
assertThat(pojo1.method6Arg3).isEqualTo("myarg3value");
}
@Test
public void testShortCombinedWithoutValue() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('a')
.description("short arg a")
.type(boolean.class)
.and()
.withOption()
.shortNames('b')
.description("short arg b")
.type(boolean.class)
.and()
.withOption()
.shortNames('c')
.description("short arg c")
.type(boolean.class)
.and()
.withTarget()
.method(pojo1, "method5")
.and()
.build();
execution.evaluate(r1, new String[]{"-abc"});
assertThat(pojo1.method5ArgA).isTrue();
assertThat(pojo1.method5ArgB).isTrue();
assertThat(pojo1.method5ArgC).isTrue();
}
@Test
public void testShortCombinedSomeHavingValue() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('a')
.description("short arg a")
.type(boolean.class)
.and()
.withOption()
.shortNames('b')
.description("short arg b")
.type(boolean.class)
.and()
.withOption()
.shortNames('c')
.description("short arg c")
.type(boolean.class)
.and()
.withTarget()
.method(pojo1, "method5")
.and()
.build();
execution.evaluate(r1, new String[]{"-ac", "-b", "false"});
assertThat(pojo1.method5ArgA).isTrue();
assertThat(pojo1.method5ArgB).isFalse();
assertThat(pojo1.method5ArgC).isTrue();
}
@Test
public void testFloatArrayOne() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.type(float[].class)
.and()
.withTarget()
.method(pojo1, "method8")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "0.1"});
assertThat(pojo1.method8Count).isEqualTo(1);
assertThat(pojo1.method8Arg1).isEqualTo(new float[]{0.1f});
}
@Test
public void testFloatArrayTwo() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.type(float[].class)
.and()
.withTarget()
.method(pojo1, "method8")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "0.1", "0.2"});
assertThat(pojo1.method8Count).isEqualTo(1);
assertThat(pojo1.method8Arg1).isEqualTo(new float[]{0.1f, 0.2f});
}
@Test
public void testDefaultValueAsNull() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{});
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isNull();
}
@Test
public void testRequiredArg() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.withOption()
.longNames("arg1")
.required()
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
assertThatThrownBy(() -> {
execution.evaluate(r1, new String[]{});
}).isInstanceOf(CommandParserExceptionsException.class);
}
}

View File

@@ -0,0 +1,355 @@
/*
* 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.command;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.shell.command.CommandParser.CommandParserResults;
import static org.assertj.core.api.Assertions.assertThat;
public class CommandParserTests extends AbstractCommandTests {
private CommandParser parser;
@BeforeEach
public void setupCommandParserTests() {
parser = CommandParser.of();
}
@Test
public void testEmptyOptionsAndArgs() {
CommandParserResults results = parser.parse(Collections.emptyList(), new String[0]);
assertThat(results.results()).hasSize(0);
}
@Test
public void testLongName() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
}
@Test
public void testShortName() {
CommandOption option1 = shortOption('a');
CommandOption option2 = shortOption('b');
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"-a", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
}
@Test
public void testMultipleArgs() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo", "--arg2", "bar"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("bar");
}
@Test
public void testMultipleArgsWithMultiValues() {
CommandOption option1 = longOption("arg1", null, false, null, 1, 2);
CommandOption option2 = longOption("arg2", null, false, null, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo1", "foo2", "--arg2", "bar1", "bar2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo1 foo2");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("bar1 bar2");
assertThat(results.positional()).isEmpty();
}
@Test
public void testBooleanWithoutArg() {
ResolvableType type = ResolvableType.forType(boolean.class);
CommandOption option1 = shortOption('v', type);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"-v"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(true);
}
@Test
public void testBooleanWithArg() {
ResolvableType type = ResolvableType.forType(boolean.class);
CommandOption option1 = shortOption('v', type);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"-v", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(false);
}
@Test
public void testMissingRequiredOption() {
CommandOption option1 = longOption("arg1", true);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{};
CommandParserResults results = parser.parse(options, args);
assertThat(results.errors()).hasSize(1);
}
@Test
public void testSpaceInArgWithOneArg() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "foo bar"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo bar");
}
@Test
public void testSpaceInArgWithMultipleArgs() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo bar", "--arg2", "hi"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo bar");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("hi");
}
@Test
public void testNonMappedArgs() {
String[] args = new String[]{"arg1", "arg2"};
CommandParserResults results = parser.parse(Collections.emptyList(), args);
assertThat(results.results()).hasSize(0);
assertThat(results.positional()).containsExactly("arg1", "arg2");
}
@Test
public void testNonMappedArgBeforeOption() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"foo", "--arg1", "value"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value");
assertThat(results.positional()).containsExactly("foo");
}
@Test
public void testNonMappedArgAfterOption() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value");
assertThat(results.positional()).containsExactly("foo");
}
@Test
public void testNonMappedArgWithoutOption() {
CommandOption option1 = longOption("arg1", 0, 1, 2);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value foo");
assertThat(results.positional()).containsExactly("value", "foo");
}
@Test
public void testNonMappedArgWithoutOptionHavingType() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(String.class), false, 0, 1, 2);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value foo");
assertThat(results.positional()).containsExactly("value", "foo");
}
@Test
public void testShortOptionsCombined() {
CommandOption optionA = shortOption('a');
CommandOption optionB = shortOption('b');
CommandOption optionC = shortOption('c');
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isNull();
assertThat(results.results().get(1).value()).isNull();
assertThat(results.results().get(2).value()).isNull();
}
@Test
public void testShortOptionsCombinedBooleanType() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isEqualTo(true);
assertThat(results.results().get(1).value()).isEqualTo(true);
assertThat(results.results().get(2).value()).isEqualTo(true);
}
@Test
public void testShortOptionsCombinedBooleanTypeArgFalse() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isEqualTo(false);
assertThat(results.results().get(1).value()).isEqualTo(false);
assertThat(results.results().get(2).value()).isEqualTo(false);
}
@Test
public void testShortOptionsCombinedBooleanTypeSomeArgFalse() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-ac", "-b", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionC);
assertThat(results.results().get(2).option()).isSameAs(optionB);
assertThat(results.results().get(0).value()).isEqualTo(true);
assertThat(results.results().get(1).value()).isEqualTo(true);
assertThat(results.results().get(2).value()).isEqualTo(false);
}
@Test
public void testLongOptionsWithArray() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(int[].class));
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(new String[] { "1", "2" });
}
@Test
public void testMapPositionalArgs1() {
CommandOption option1 = longOption("arg1", 0, 1, 1);
CommandOption option2 = longOption("arg2", 1, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo("1");
assertThat(results.results().get(1).value()).isEqualTo("2");
}
@Test
public void testMapPositionalArgs2() {
CommandOption option1 = longOption("arg1", 0, 1, 1);
CommandOption option2 = longOption("arg2", 1, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo("1");
assertThat(results.results().get(1).value()).isEqualTo("2");
}
private static CommandOption longOption(String name) {
return longOption(name, null);
}
private static CommandOption longOption(String name, boolean required) {
return longOption(name, null, required, null);
}
private static CommandOption longOption(String name, ResolvableType type) {
return longOption(name, type, false, null);
}
private static CommandOption longOption(String name, int position, int arityMin, int arityMax) {
return longOption(name, null, false, position, arityMin, arityMax);
}
private static CommandOption longOption(String name, ResolvableType type, boolean required, Integer position) {
return longOption(name, type, required, position, null, null);
}
private static CommandOption longOption(String name, ResolvableType type, boolean required, Integer position, Integer arityMin, Integer arityMax) {
return CommandOption.of(new String[] { name }, new Character[0], "desc", type, required, null, position,
arityMin, arityMax);
}
private static CommandOption shortOption(char name) {
return shortOption(name, null);
}
private static CommandOption shortOption(char name, ResolvableType type) {
return CommandOption.of(new String[0], new Character[] { name }, "desc", type);
}
}

View File

@@ -0,0 +1,357 @@
/*
* 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.command;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.shell.command.CommandRegistration.OptionArity;
import org.springframework.shell.command.CommandRegistration.TargetInfo.TargetType;
import org.springframework.shell.context.InteractionMode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class CommandRegistrationTests extends AbstractCommandTests {
@Test
public void testCommandMustBeSet() {
assertThatThrownBy(() -> {
CommandRegistration.builder().build();
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("command cannot be empty");
}
@Test
public void testBasics() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getGroup()).isNull();
assertThat(registration.getInteractionMode()).isEqualTo(InteractionMode.ALL);
registration = CommandRegistration.builder()
.command("command1")
.interactionMode(InteractionMode.NONINTERACTIVE)
.group("fakegroup")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getInteractionMode()).isEqualTo(InteractionMode.NONINTERACTIVE);
assertThat(registration.getGroup()).isEqualTo("fakegroup");
}
@Test
public void testCommandStructures() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
registration = CommandRegistration.builder()
.command("command1", "command2")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1 command2");
registration = CommandRegistration.builder()
.command("command1 command2")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1 command2");
registration = CommandRegistration.builder()
.command(" command1 command2 ")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1 command2");
}
@Test
public void testFunctionRegistration() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getTarget().getTargetType()).isEqualTo(TargetType.FUNCTION);
assertThat(registration.getTarget().getFunction()).isNotNull();
assertThat(registration.getTarget().getBean()).isNull();
assertThat(registration.getTarget().getMethod()).isNull();
}
@Test
public void testConsumerRegistration() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withTarget()
.consumer(ctx -> {})
.and()
.build();
assertThat(registration.getTarget().getTargetType()).isEqualTo(TargetType.CONSUMER);
assertThat(registration.getTarget().getFunction()).isNull();
assertThat(registration.getTarget().getConsumer()).isNotNull();
assertThat(registration.getTarget().getBean()).isNull();
assertThat(registration.getTarget().getMethod()).isNull();
}
@Test
public void testMethodRegistration() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withTarget()
.method(pojo1, "method3", String.class)
.and()
.build();
assertThat(registration.getTarget().getTargetType()).isEqualTo(TargetType.METHOD);
assertThat(registration.getTarget().getFunction()).isNull();
assertThat(registration.getTarget().getBean()).isNotNull();
assertThat(registration.getTarget().getMethod()).isNotNull();
}
@Test
public void testCanUseOnlyOneTarget() {
assertThatThrownBy(() -> {
CommandRegistration.builder()
.command("command1")
.withTarget()
.method(pojo1, "method3", String.class)
.function(function1)
.and()
.build();
}).isInstanceOf(IllegalStateException.class).hasMessageContaining("only one target can exist");
}
@Test
public void testSimpleFullRegistrationWithFunction() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getHelp()).isEqualTo("help");
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getLongNames()).containsExactly("arg1");
}
@Test
public void testSimpleFullRegistrationWithMethod() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withTarget()
.method(pojo1, "method3", String.class)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getHelp()).isEqualTo("help");
assertThat(registration.getOptions()).hasSize(1);
}
@Test
public void testOptionWithType() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('v')
.type(boolean.class)
.description("some arg1")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getHelp()).isEqualTo("help");
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getShortNames()).containsExactly('v');
assertThat(registration.getOptions().get(0).getType()).isEqualTo(ResolvableType.forType(boolean.class));
}
@Test
public void testOptionWithRequired() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('v')
.type(boolean.class)
.description("some arg1")
.required(true)
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getHelp()).isEqualTo("help");
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getShortNames()).containsExactly('v');
assertThat(registration.getOptions().get(0).isRequired()).isTrue();
registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('v')
.type(boolean.class)
.description("some arg1")
.required(false)
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).isRequired()).isFalse();
registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('v')
.type(boolean.class)
.description("some arg1")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).isRequired()).isFalse();
registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('v')
.type(boolean.class)
.description("some arg1")
.required()
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).isRequired()).isTrue();
}
@Test
public void testOptionWithDefaultValue() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.help("help")
.withOption()
.shortNames('v')
.type(boolean.class)
.description("some arg1")
.defaultValue("defaultValue")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getHelp()).isEqualTo("help");
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getDefaultValue()).isEqualTo("defaultValue");
}
@Test
public void testOptionWithPositionValue() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withOption()
.longNames("arg1")
.position(1)
.and()
.withOption()
.longNames("arg2")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getOptions()).hasSize(2);
assertThat(registration.getOptions().get(0).getPosition()).isEqualTo(1);
assertThat(registration.getOptions().get(1).getPosition()).isEqualTo(-1);
}
@Test
public void testArityViaInts() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withOption()
.longNames("arg1")
.arity(0, 0)
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0);
assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0);
}
@Test
public void testArityViaEnum() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withOption()
.longNames("arg1")
.arity(OptionArity.ZERO)
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0);
assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0);
}
}