@@ -23,6 +23,8 @@ import java.util.Optional;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
|
||||
import javax.validation.metadata.ElementDescriptor;
|
||||
|
||||
/**
|
||||
* Encapsulates information about a shell invokable method parameter, so that it can be documented.
|
||||
*
|
||||
@@ -75,6 +77,14 @@ public class ParameterDescription {
|
||||
*/
|
||||
private String help = "";
|
||||
|
||||
/**
|
||||
* Allows discovery of bean validation constraints for the command parameter.
|
||||
* <p>Note that most often, constraints will directly come from parameter constraints,
|
||||
* but sometimes (<em>e.g.</em> in case of one method argument mapping to multiple
|
||||
* command options) may come from property constraints.</p>
|
||||
*/
|
||||
private ElementDescriptor elementDescriptor;
|
||||
|
||||
public ParameterDescription(MethodParameter parameter, String type) {
|
||||
this.parameter = parameter;
|
||||
this.type = type;
|
||||
@@ -123,6 +133,17 @@ public class ParameterDescription {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return an ElementDescriptor used to discover constraints. May be {@literal null}.
|
||||
*/
|
||||
public ElementDescriptor elementDescriptor() {
|
||||
return this.elementDescriptor;
|
||||
}
|
||||
|
||||
public ParameterDescription elementDescriptor(ElementDescriptor descriptor) {
|
||||
this.elementDescriptor = descriptor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String type() {
|
||||
return type;
|
||||
|
||||
@@ -29,6 +29,7 @@ import java.util.stream.Collectors;
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import javax.validation.executable.ExecutableValidator;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -54,6 +55,9 @@ public class Shell implements CommandRegistry {
|
||||
@Autowired
|
||||
protected ApplicationContext applicationContext;
|
||||
|
||||
@Autowired(required = false)
|
||||
private Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
protected Map<String, MethodTarget> methodTargets = new HashMap<>();
|
||||
|
||||
protected List<ParameterResolver> parameterResolvers;
|
||||
@@ -63,9 +67,6 @@ public class Shell implements CommandRegistry {
|
||||
*/
|
||||
protected static final Object UNRESOLVED = new Object();
|
||||
|
||||
private final ExecutableValidator executableValidator = Validation
|
||||
.buildDefaultValidatorFactory().getValidator().forExecutables();
|
||||
|
||||
public Shell(ResultHandler resultHandler) {
|
||||
this.resultHandler = resultHandler;
|
||||
}
|
||||
@@ -232,7 +233,7 @@ public class Shell implements CommandRegistry {
|
||||
throw new IllegalStateException("Could not resolve " + methodParameter);
|
||||
}
|
||||
}
|
||||
Set<ConstraintViolation<Object>> constraintViolations = executableValidator.validateParameters(
|
||||
Set<ConstraintViolation<Object>> constraintViolations = validator.forExecutables().validateParameters(
|
||||
methodTarget.getBean(),
|
||||
methodTarget.getMethod(),
|
||||
args
|
||||
|
||||
@@ -27,6 +27,9 @@ import org.springframework.core.convert.ConversionService;
|
||||
import org.springframework.core.convert.support.DefaultConversionService;
|
||||
import org.springframework.shell.result.ResultHandlerConfig;
|
||||
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
|
||||
/**
|
||||
* Creates supporting beans for running the Shell
|
||||
*/
|
||||
@@ -40,10 +43,15 @@ public class SpringShellAutoConfiguration {
|
||||
return new DefaultConversionService();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(Validator.class)
|
||||
public Validator validator() {
|
||||
return Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Shell shell(@Qualifier("main") ResultHandler resultHandler) {
|
||||
return new Shell(resultHandler);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.shell.CompletionContext;
|
||||
import org.springframework.shell.CompletionProposal;
|
||||
@@ -42,6 +43,12 @@ import com.beust.jcommander.Parameter;
|
||||
import com.beust.jcommander.ParameterException;
|
||||
import com.beust.jcommander.ParametersDelegate;
|
||||
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import javax.validation.metadata.BeanDescriptor;
|
||||
import javax.validation.metadata.MethodDescriptor;
|
||||
import javax.validation.metadata.ParameterDescriptor;
|
||||
|
||||
/**
|
||||
* Provides integration with JCommander.
|
||||
*
|
||||
@@ -52,6 +59,9 @@ public class JCommanderParameterResolver implements ParameterResolver {
|
||||
private static final Collection<Class<? extends Annotation>> JCOMMANDER_ANNOTATIONS =
|
||||
Arrays.asList(Parameter.class, DynamicParameter.class, ParametersDelegate.class);
|
||||
|
||||
@Autowired(required = false)
|
||||
private Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter parameter) {
|
||||
AtomicBoolean isSupported = new AtomicBoolean(false);
|
||||
@@ -94,6 +104,9 @@ public class JCommanderParameterResolver implements ParameterResolver {
|
||||
public Stream<ParameterDescription> describe(MethodParameter parameter) {
|
||||
JCommander jCommander = createJCommander(parameter);
|
||||
Stream<com.beust.jcommander.ParameterDescription> jCommanderDescriptions = streamAllJCommanderDescriptions(jCommander);
|
||||
|
||||
BeanDescriptor constraintsForClass = validator.getConstraintsForClass(parameter.getParameterType());
|
||||
|
||||
return jCommanderDescriptions
|
||||
.map(j -> new ParameterDescription(parameter, unCamelify(j.getParameterized().getType().getSimpleName()))
|
||||
.keys(Arrays.asList(j.getParameter().names()))
|
||||
@@ -101,6 +114,7 @@ public class JCommanderParameterResolver implements ParameterResolver {
|
||||
.mandatoryKey(!j.equals(jCommander.getMainParameter()))
|
||||
// Not ideal as this does not take reverse-conversion into account, but just toString()
|
||||
.defaultValue(j.getDefault() == null ? "" : String.valueOf(j.getDefault()))
|
||||
.elementDescriptor(constraintsForClass.getConstraintsForProperty(j.getParameterized().getName()))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import org.springframework.shell.core.annotation.CliOption;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
|
||||
/**
|
||||
* A sample of legacy Shell 1 commands that can be run thanks to the legacy adapter.
|
||||
*
|
||||
@@ -76,7 +78,7 @@ public class LegacyCommands implements CommandMarker {
|
||||
|
||||
@CliCommand(value = "sum2", help = "adds two numbers")
|
||||
public int sum2(
|
||||
@CliOption(key = "v1", unspecifiedDefaultValue = "38") int a,
|
||||
@CliOption(key = "v1", unspecifiedDefaultValue = "38") @Min(5) int a,
|
||||
@CliOption(key = "v2", specifiedDefaultValue = "42", mandatory = true) int b,
|
||||
@CliOption(key = "v3", mandatory = false) int c) {
|
||||
return a + b + c;
|
||||
|
||||
@@ -37,6 +37,11 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import javax.validation.metadata.MethodDescriptor;
|
||||
import javax.validation.metadata.ParameterDescriptor;
|
||||
|
||||
/**
|
||||
* Resolves parameters by looking at the {@link CliOption} annotation and acting
|
||||
* accordingly.
|
||||
@@ -58,6 +63,9 @@ public class LegacyParameterResolver implements ParameterResolver {
|
||||
@Autowired(required = false)
|
||||
private Collection<Converter<?>> converters = new ArrayList<>();
|
||||
|
||||
@Autowired(required = false)
|
||||
private Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter parameter) {
|
||||
return parameter.hasParameterAnnotation(CliOption.class);
|
||||
@@ -118,6 +126,15 @@ public class LegacyParameterResolver implements ParameterResolver {
|
||||
}
|
||||
boolean containsEmptyKey = keys.contains("");
|
||||
result.mandatoryKey(!containsEmptyKey);
|
||||
|
||||
MethodDescriptor constraintsForMethod = validator.getConstraintsForClass(parameter.getDeclaringClass())
|
||||
.getConstraintsForMethod(parameter.getMethod().getName(), parameter.getMethod().getParameterTypes());
|
||||
if (constraintsForMethod != null) {
|
||||
ParameterDescriptor constraintsDescriptor = constraintsForMethod
|
||||
.getParameterDescriptors().get(parameter.getParameterIndex());
|
||||
result.elementDescriptor(constraintsDescriptor);
|
||||
}
|
||||
|
||||
return Stream.of(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.validator.internal.engine.MessageInterpolatorContext;
|
||||
import org.jline.utils.AttributedStringBuilder;
|
||||
import org.jline.utils.AttributedStyle;
|
||||
|
||||
@@ -37,6 +38,10 @@ import org.springframework.shell.standard.ShellComponent;
|
||||
import org.springframework.shell.standard.ShellMethod;
|
||||
import org.springframework.shell.standard.ShellOption;
|
||||
|
||||
import javax.validation.MessageInterpolator;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.metadata.ConstraintDescriptor;
|
||||
|
||||
/**
|
||||
* A command to display help about all available commands.
|
||||
*
|
||||
@@ -48,20 +53,29 @@ public class Help {
|
||||
/**
|
||||
* Marker interface for beans providing {@literal help} functionality to the shell.
|
||||
*
|
||||
* <p>To override the help command, simply register your own bean implementing that interface
|
||||
* and the standard implementation will back off.</p>
|
||||
* <p>
|
||||
* To override the help command, simply register your own bean implementing that interface
|
||||
* and the standard implementation will back off.
|
||||
* </p>
|
||||
*
|
||||
* <p>To disable the {@literal help} command entirely, set the {@literal spring.shell.command.help.enabled=false}
|
||||
* property in the environment.</p>
|
||||
* <p>
|
||||
* To disable the {@literal help} command entirely, set the
|
||||
* {@literal spring.shell.command.help.enabled=false} property in the environment.
|
||||
* </p>
|
||||
*
|
||||
* @author Eric Bottard
|
||||
*/
|
||||
public interface Command {}
|
||||
public interface Command {
|
||||
}
|
||||
|
||||
private final List<ParameterResolver> parameterResolvers;
|
||||
|
||||
private CommandRegistry commandRegistry;
|
||||
|
||||
@Autowired(required = false)
|
||||
private MessageInterpolator messageInterpolator = Validation.buildDefaultValidatorFactory()
|
||||
.getMessageInterpolator();
|
||||
|
||||
@Autowired
|
||||
public Help(List<ParameterResolver> parameterResolvers) {
|
||||
this.parameterResolvers = parameterResolvers;
|
||||
@@ -74,10 +88,9 @@ public class Help {
|
||||
|
||||
@ShellMethod(value = "Display help about available commands.", prefix = "-")
|
||||
public CharSequence help(
|
||||
@ShellOption(defaultValue = ShellOption.NULL,
|
||||
valueProvider = CommandValueProvider.class,
|
||||
value = {"-C", "--command"},
|
||||
help = "The command to obtain help for.") String command) throws IOException {
|
||||
@ShellOption(defaultValue = ShellOption.NULL, valueProvider = CommandValueProvider.class, value = { "-C",
|
||||
"--command" }, help = "The command to obtain help for.") String command)
|
||||
throws IOException {
|
||||
if (command == null) {
|
||||
return listCommands();
|
||||
}
|
||||
@@ -96,25 +109,46 @@ public class Help {
|
||||
throw new IllegalArgumentException("Unknown command '" + command + "'");
|
||||
}
|
||||
|
||||
// NAME
|
||||
AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n");
|
||||
result.append("NAME", AttributedStyle.BOLD).append("\n\t");
|
||||
result.append(command).append(" - ").append(methodTarget.getHelp()).append("\n\n");
|
||||
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
|
||||
|
||||
// NAME
|
||||
documentCommandName(result, command, methodTarget.getHelp());
|
||||
|
||||
// SYNOPSYS
|
||||
documentSynopsys(result, command, parameterDescriptions);
|
||||
|
||||
// OPTIONS
|
||||
documentOptions(result, parameterDescriptions);
|
||||
|
||||
// ALSO KNOWN AS
|
||||
documentAliases(result, command, methodTarget);
|
||||
|
||||
// AVAILABILITY
|
||||
documentAvailability(result, methodTarget);
|
||||
|
||||
result.append("\n");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void documentCommandName(AttributedStringBuilder result, String command, String help) {
|
||||
result.append("NAME", AttributedStyle.BOLD).append("\n\t");
|
||||
result.append(command).append(" - ").append(help).append("\n\n");
|
||||
}
|
||||
|
||||
private void documentSynopsys(AttributedStringBuilder result, String command,
|
||||
List<ParameterDescription> parameterDescriptions) {
|
||||
result.append("SYNOPSYS", AttributedStyle.BOLD).append("\n\t");
|
||||
result.append(command, AttributedStyle.BOLD);
|
||||
result.append(" ");
|
||||
|
||||
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
|
||||
|
||||
for (ParameterDescription description : parameterDescriptions) {
|
||||
|
||||
if (description.defaultValue().isPresent() && description.formal().length()>0) {
|
||||
if (description.defaultValue().isPresent() && description.formal().length() > 0) {
|
||||
result.append("["); // Whole parameter is optional, as there is a default value (1)
|
||||
}
|
||||
List<String> keys = description.keys();
|
||||
if(!keys.isEmpty()) {
|
||||
if (!keys.isEmpty()) {
|
||||
if (!description.mandatoryKey()) {
|
||||
result.append("["); // Specifying a key is optional (ie positional params). (2)
|
||||
}
|
||||
@@ -133,19 +167,21 @@ public class Help {
|
||||
if (description.defaultValueWhenFlag().isPresent()) {
|
||||
result.append("]"); // (close 3)
|
||||
}
|
||||
if (description.defaultValue().isPresent() && description.formal().length()>0) {
|
||||
if (description.defaultValue().isPresent() && description.formal().length() > 0) {
|
||||
result.append("]"); // (close 1)
|
||||
}
|
||||
result.append(" "); // two spaces between each param for better legibility
|
||||
}
|
||||
result.append("\n\n");
|
||||
}
|
||||
|
||||
// OPTIONS
|
||||
private void documentOptions(AttributedStringBuilder result, List<ParameterDescription> parameterDescriptions) {
|
||||
if (!parameterDescriptions.isEmpty()) {
|
||||
result.append("OPTIONS", AttributedStyle.BOLD).append("\n");
|
||||
}
|
||||
for (ParameterDescription description : parameterDescriptions) {
|
||||
result.append("\t").append(description.keys().stream().collect(Collectors.joining(" or ")), AttributedStyle.BOLD);
|
||||
result.append("\t").append(description.keys().stream().collect(Collectors.joining(" or ")),
|
||||
AttributedStyle.BOLD);
|
||||
if (description.formal().length() > 0) {
|
||||
if (!description.keys().isEmpty()) {
|
||||
result.append(" ");
|
||||
@@ -159,34 +195,42 @@ public class Help {
|
||||
result.append("\n\t");
|
||||
}
|
||||
result.append("\t");
|
||||
result.append(description.help());
|
||||
result.append(description.help()).append('\n');
|
||||
// Optional parameter
|
||||
if (description.defaultValue().isPresent()) {
|
||||
result
|
||||
.append(" [Optional, default = ", AttributedStyle.BOLD)
|
||||
.append("\t\t[Optional, default = ", AttributedStyle.BOLD)
|
||||
.append(description.defaultValue().get(), AttributedStyle.BOLD.italic());
|
||||
description.defaultValueWhenFlag().ifPresent(
|
||||
s -> result.append(", or ", AttributedStyle.BOLD)
|
||||
.append(s, AttributedStyle.BOLD.italic())
|
||||
.append(" if used as a flag", AttributedStyle.BOLD)
|
||||
);
|
||||
s -> result.append(", or ", AttributedStyle.BOLD)
|
||||
.append(s, AttributedStyle.BOLD.italic())
|
||||
.append(" if used as a flag", AttributedStyle.BOLD));
|
||||
|
||||
result.append("]", AttributedStyle.BOLD);
|
||||
} // Mandatory parameter, but with a default when used as a flag
|
||||
else if (description.defaultValueWhenFlag().isPresent()) {
|
||||
result
|
||||
.append(" [Mandatory, default = ", AttributedStyle.BOLD)
|
||||
.append(description.defaultValueWhenFlag().get(), AttributedStyle.BOLD.italic())
|
||||
.append(" when used as a flag]", AttributedStyle.BOLD)
|
||||
;
|
||||
.append("\t\t[Mandatory, default = ", AttributedStyle.BOLD)
|
||||
.append(description.defaultValueWhenFlag().get(), AttributedStyle.BOLD.italic())
|
||||
.append(" when used as a flag]", AttributedStyle.BOLD);
|
||||
} // true mandatory parameter
|
||||
else {
|
||||
result.append(" [Mandatory]", AttributedStyle.BOLD);
|
||||
result.append("\t\t[Mandatory]", AttributedStyle.BOLD);
|
||||
}
|
||||
result.append("\n\n");
|
||||
result.append('\n');
|
||||
if (description.elementDescriptor() != null) {
|
||||
for (ConstraintDescriptor<?> constraintDescriptor : description.elementDescriptor()
|
||||
.getConstraintDescriptors()) {
|
||||
String friendlyConstraint = messageInterpolator.interpolate(
|
||||
constraintDescriptor.getMessageTemplate(), new DummyContext(constraintDescriptor));
|
||||
result.append("\t\t[" + friendlyConstraint + "]\n", AttributedStyle.BOLD);
|
||||
}
|
||||
}
|
||||
result.append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ALSO KNOWN AS
|
||||
private void documentAliases(AttributedStringBuilder result, String command, MethodTarget methodTarget) {
|
||||
Set<String> aliases = commandRegistry.listCommands().entrySet().stream()
|
||||
.filter(e -> e.getValue().equals(methodTarget))
|
||||
.map(Map.Entry::getKey)
|
||||
@@ -199,7 +243,9 @@ public class Help {
|
||||
result.append('\t').append(alias).append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void documentAvailability(AttributedStringBuilder result, MethodTarget methodTarget) {
|
||||
Availability availability = methodTarget.getAvailability();
|
||||
if (!availability.isAvailable()) {
|
||||
result.append("CURRENTLY UNAVAILABLE", AttributedStyle.BOLD).append("\n");
|
||||
@@ -207,9 +253,6 @@ public class Help {
|
||||
.append(availability.getReason())
|
||||
.append(".\n");
|
||||
}
|
||||
|
||||
result.append("\n");
|
||||
return result;
|
||||
}
|
||||
|
||||
private String first(List<String> keys) {
|
||||
@@ -219,7 +262,8 @@ public class Help {
|
||||
private CharSequence listCommands() {
|
||||
Map<String, Set<String>> groupedByMethodTarget = commandRegistry.listCommands().entrySet().stream()
|
||||
.collect(Collectors.groupingBy(e -> e.getValue().getHelp(), // Use help() as the grouping key
|
||||
mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into a sorted set
|
||||
mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into
|
||||
// a sorted set
|
||||
|
||||
// Then display commands, sorted alphabetically by their first alias
|
||||
AttributedStringBuilder result = new AttributedStringBuilder();
|
||||
@@ -228,16 +272,16 @@ public class Help {
|
||||
groupedByMethodTarget.entrySet().stream()
|
||||
.sorted(sortByFirstElement())
|
||||
.forEach(e -> result.append(isAvailable(e) ? " " : " * ")
|
||||
.append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD)
|
||||
.append(": ")
|
||||
.append(e.getKey())
|
||||
.append('\n')
|
||||
);
|
||||
.append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD)
|
||||
.append(": ")
|
||||
.append(e.getKey())
|
||||
.append('\n'));
|
||||
|
||||
groupedByMethodTarget.entrySet().stream()
|
||||
.filter(e -> !isAvailable(e))
|
||||
.findAny()
|
||||
.ifPresent(e -> result.append("\nCommands marked with (*) are currently unavailable.\nType `help <command>` to learn more.\n"));
|
||||
.ifPresent(e -> result.append(
|
||||
"\nCommands marked with (*) are currently unavailable.\nType `help <command>` to learn more.\n"));
|
||||
|
||||
return result.append("\n");
|
||||
}
|
||||
@@ -264,9 +308,34 @@ public class Help {
|
||||
|
||||
private List<ParameterDescription> getParameterDescriptions(MethodTarget methodTarget) {
|
||||
return Utils.createMethodParameters(methodTarget.getMethod())
|
||||
.flatMap(mp -> parameterResolvers.stream().filter(pr -> pr.supports(mp)).limit(1L).flatMap(pr -> pr.describe(mp)))
|
||||
.collect(Collectors.toList());
|
||||
.flatMap(mp -> parameterResolvers.stream().filter(pr -> pr.supports(mp)).limit(1L)
|
||||
.flatMap(pr -> pr.describe(mp)))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
}
|
||||
|
||||
private static class DummyContext implements MessageInterpolator.Context {
|
||||
|
||||
private final ConstraintDescriptor<?> descriptor;
|
||||
|
||||
private DummyContext(ConstraintDescriptor<?> descriptor) {
|
||||
this.descriptor = descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConstraintDescriptor<?> getConstraintDescriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getValidatedValue() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unwrap(Class<T> type) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
|
||||
import javax.validation.constraints.Max;
|
||||
|
||||
/**
|
||||
* Tests for the {@link Help} command.
|
||||
*
|
||||
@@ -133,7 +135,8 @@ public class HelpTest {
|
||||
// Multiple keys and arity 0. Help displayed on next line
|
||||
@ShellOption(help = "Do not ask for confirmation. YOLO", arity = 0, value = {"-f", "--force"}) boolean force,
|
||||
// Single key, arity >= 1. Help displayed on next line. Optional
|
||||
@ShellOption(help = "The answer to everything", defaultValue = "42", value = "-n") int n,
|
||||
// Also, bears bean validation annotation
|
||||
@ShellOption(help = "The answer to everything", defaultValue = "42", value = "-n") @Max(5) int n,
|
||||
// Single key, arity > 1.
|
||||
@ShellOption(help = "Some other parameters", arity = 3, value = "-o") float[] o
|
||||
) {
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
&
|
||||
&
|
||||
NAME&
|
||||
first-command - A rather extensive description of some command.&
|
||||
&
|
||||
SYNOPSYS&
|
||||
first-command [-r] [-f] [[-n] int] [-o] float float float &
|
||||
&
|
||||
OPTIONS&
|
||||
-r Whether to delete recursively [Optional, default = false]&
|
||||
&
|
||||
-f or --force&
|
||||
Do not ask for confirmation. YOLO [Optional, default = false]&
|
||||
&
|
||||
-n int&
|
||||
The answer to everything [Optional, default = 42]&
|
||||
&
|
||||
-o float float float&
|
||||
Some other parameters [Mandatory]&
|
||||
&
|
||||
ALSO KNOWN AS&
|
||||
1st-command&
|
||||
&
|
||||
|
||||
|
||||
NAME
|
||||
first-command - A rather extensive description of some command.
|
||||
|
||||
SYNOPSYS
|
||||
first-command [-r] [-f] [[-n] int] [-o] float float float
|
||||
|
||||
OPTIONS
|
||||
-r Whether to delete recursively
|
||||
[Optional, default = false]
|
||||
|
||||
-f or --force
|
||||
Do not ask for confirmation. YOLO
|
||||
[Optional, default = false]
|
||||
|
||||
-n int
|
||||
The answer to everything
|
||||
[Optional, default = 42]
|
||||
[must be less than or equal to 5]
|
||||
|
||||
-o float float float
|
||||
Some other parameters
|
||||
[Mandatory]
|
||||
|
||||
ALSO KNOWN AS
|
||||
1st-command
|
||||
|
||||
|
||||
@@ -54,26 +54,39 @@ import org.springframework.util.Assert;
|
||||
import org.springframework.util.ConcurrentReferenceHashMap;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import javax.validation.MessageInterpolator;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import javax.validation.metadata.MethodDescriptor;
|
||||
import javax.validation.metadata.ParameterDescriptor;
|
||||
|
||||
/**
|
||||
* Default ParameterResolver implementation that supports the following features:<ul>
|
||||
* <li>named parameters (recognized because they start with some {@link ShellMethod#prefix()})</li>
|
||||
* Default ParameterResolver implementation that supports the following features:
|
||||
* <ul>
|
||||
* <li>named parameters (recognized because they start with some
|
||||
* {@link ShellMethod#prefix()})</li>
|
||||
* <li>implicit named parameters (from the actual method parameter name)</li>
|
||||
* <li>positional parameters (in order, for all parameter values that were not resolved <i>via</i> named
|
||||
* parameters)</li>
|
||||
* <li>positional parameters (in order, for all parameter values that were not resolved
|
||||
* <i>via</i> named parameters)</li>
|
||||
* <li>default values (for all remaining parameters)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Method arguments can consume several words of input at once (driven by {@link ShellOption#arity()}, default 1).
|
||||
* If several words are consumed, they will be joined together as a comma separated value and passed to the {@link
|
||||
* ConversionService}
|
||||
* (which will typically return a List or array).</p>
|
||||
* <p>
|
||||
* Method arguments can consume several words of input at once (driven by
|
||||
* {@link ShellOption#arity()}, default 1). If several words are consumed, they will be
|
||||
* joined together as a comma separated value and passed to the {@link ConversionService}
|
||||
* (which will typically return a List or array).
|
||||
* </p>
|
||||
*
|
||||
* <p>Boolean parameters are by default expected to have an arity of 0, allowing invocations in the form {@code rm
|
||||
* --force --dir /foo}:
|
||||
* the presence of {@code --force} passes {@code true} as a parameter value, while its absence passes {@code false}.
|
||||
* Both
|
||||
* the default arity of 0 and the default value of {@code false} can be overridden <i>via</i> {@link ShellOption}
|
||||
* if needed.</p>
|
||||
* <p>
|
||||
* Boolean parameters are by default expected to have an arity of 0, allowing invocations
|
||||
* in the form {@code rm
|
||||
* --force --dir /foo}: the presence of {@code --force} passes {@code true} as a parameter
|
||||
* value, while its absence passes {@code false}. Both the default arity of 0 and the
|
||||
* default value of {@code false} can be overridden <i>via</i> {@link ShellOption} if
|
||||
* needed.
|
||||
* </p>
|
||||
* @author Eric Bottard
|
||||
* @author Florent Biville
|
||||
* @author Camilo Gonzalez
|
||||
@@ -86,9 +99,9 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
private Collection<ValueProvider> valueProviders = new HashSet<>();
|
||||
|
||||
/**
|
||||
* A cache from method+input to String representation of actual parameter values.
|
||||
* Note that the converted result is not cached, to allow dynamic computation to happen at every invocation
|
||||
* if needed (e.g. if a remote service is involved).
|
||||
* A cache from method+input to String representation of actual parameter values. Note
|
||||
* that the converted result is not cached, to allow dynamic computation to happen at
|
||||
* every invocation if needed (e.g. if a remote service is involved).
|
||||
*/
|
||||
private final Map<CacheKey, Map<Parameter, ParameterRawValue>> parameterCache = new ConcurrentReferenceHashMap<>();
|
||||
|
||||
@@ -102,9 +115,13 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
this.valueProviders = valueProviders;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
private Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter parameter) {
|
||||
boolean optOut = parameter.hasParameterAnnotation(ShellOption.class) && parameter.getParameterAnnotation(ShellOption.class).optOut();
|
||||
boolean optOut = parameter.hasParameterAnnotation(ShellOption.class)
|
||||
&& parameter.getParameterAnnotation(ShellOption.class).optOut();
|
||||
return !optOut && parameter.getMethodAnnotation(ShellMethod.class) != null;
|
||||
}
|
||||
|
||||
@@ -120,7 +137,7 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
|
||||
Map<Parameter, ParameterRawValue> result = new HashMap<>();
|
||||
Map<String, String> namedParameters = new HashMap<>();
|
||||
|
||||
|
||||
// index of words that haven't yet been used to resolve parameter values
|
||||
List<Integer> unusedWords = new ArrayList<>();
|
||||
|
||||
@@ -137,17 +154,22 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
|
||||
if (i + 1 + arity > words.size()) {
|
||||
String input = words.subList(i, words.size()).stream().collect(Collectors.joining(" "));
|
||||
throw new UnfinishedParameterResolutionException(describe(Utils.createMethodParameter(parameter)).findFirst().get(), input);
|
||||
throw new UnfinishedParameterResolutionException(
|
||||
describe(Utils.createMethodParameter(parameter)).findFirst().get(), input);
|
||||
}
|
||||
Assert.isTrue(i + 1 + arity <= words.size(), String.format("Not enough input for parameter '%s'", word));
|
||||
Assert.isTrue(i + 1 + arity <= words.size(),
|
||||
String.format("Not enough input for parameter '%s'", word));
|
||||
String raw = words.subList(i + 1, i + 1 + arity).stream().collect(Collectors.joining(","));
|
||||
Assert.isTrue(!namedParameters.containsKey(key), String.format("Parameter for '%s' has already been specified", word));
|
||||
Assert.isTrue(!namedParameters.containsKey(key),
|
||||
String.format("Parameter for '%s' has already been specified", word));
|
||||
namedParameters.put(key, raw);
|
||||
if (arity == 0) {
|
||||
boolean defaultValue = booleanDefaultValue(parameter);
|
||||
// Boolean parameter has been specified. Use the opposite of the default value
|
||||
result.put(parameter, ParameterRawValue.explicit(String.valueOf(!defaultValue), key, from, from));
|
||||
} else {
|
||||
result.put(parameter,
|
||||
ParameterRawValue.explicit(String.valueOf(!defaultValue), key, from, from));
|
||||
}
|
||||
else {
|
||||
i += arity;
|
||||
result.put(parameter, ParameterRawValue.explicit(raw, key, from, i));
|
||||
}
|
||||
@@ -162,8 +184,10 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
Parameter[] parameters = methodParameter.getMethod().getParameters();
|
||||
for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) {
|
||||
Parameter parameter = parameters[i];
|
||||
// Compute the intersection between possible keys for the param and what we've already seen for named params
|
||||
Collection<String> keys = getKeysForParameter(methodParameter.getMethod(), i).collect(Collectors.toSet());
|
||||
// Compute the intersection between possible keys for the param and what we've already
|
||||
// seen for named params
|
||||
Collection<String> keys = getKeysForParameter(methodParameter.getMethod(), i)
|
||||
.collect(Collectors.toSet());
|
||||
Collection<String> copy = new HashSet<>(keys);
|
||||
copy.retainAll(namedParameters.keySet());
|
||||
if (copy.isEmpty()) { // Was not set via a key (including aliases), must be positional
|
||||
@@ -179,11 +203,13 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
} // No more input. Try defaultValues
|
||||
else {
|
||||
Optional<String> defaultValue = defaultValueFor(parameter);
|
||||
defaultValue.ifPresent(value -> result.put(parameter, ParameterRawValue.implicit(value, null, null, null)));
|
||||
defaultValue.ifPresent(
|
||||
value -> result.put(parameter, ParameterRawValue.implicit(value, null, null, null)));
|
||||
}
|
||||
}
|
||||
else if (copy.size() > 1) {
|
||||
throw new IllegalArgumentException("Named parameter has been specified multiple times via " + quote(copy));
|
||||
throw new IllegalArgumentException(
|
||||
"Named parameter has been specified multiple times via " + quote(copy));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +239,7 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private BitSet getWordsUsedForValue(ParameterRawValue parameterRawValue) {
|
||||
if (parameterRawValue.from != null) {
|
||||
BitSet wordsUsedForValue = new BitSet();
|
||||
@@ -232,7 +258,8 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
return conversionService.convert(s, TypeDescriptor.valueOf(String.class), new TypeDescriptor(methodParameter));
|
||||
return conversionService.convert(s, TypeDescriptor.valueOf(String.class),
|
||||
new TypeDescriptor(methodParameter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +303,8 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
if (i > 0) {
|
||||
sb.append(" ");
|
||||
}
|
||||
sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName()) : unCamelify(type.getSimpleName()));
|
||||
sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName())
|
||||
: unCamelify(type.getSimpleName()));
|
||||
}
|
||||
ParameterDescription result = ParameterDescription.outOf(parameter);
|
||||
result.formal(sb.toString());
|
||||
@@ -292,6 +320,14 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
.collect(Collectors.toList()))
|
||||
.mandatoryKey(false);
|
||||
|
||||
MethodDescriptor constraintsForMethod = validator.getConstraintsForClass(parameter.getDeclaringClass())
|
||||
.getConstraintsForMethod(parameter.getMethod().getName(), parameter.getMethod().getParameterTypes());
|
||||
if (constraintsForMethod != null) {
|
||||
ParameterDescriptor constraintsDescriptor = constraintsForMethod
|
||||
.getParameterDescriptors().get(parameter.getParameterIndex());
|
||||
result.elementDescriptor(constraintsDescriptor);
|
||||
}
|
||||
|
||||
return Stream.of(result);
|
||||
}
|
||||
|
||||
@@ -309,16 +345,20 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
arity = getArity(parameter);
|
||||
parameterRawValue = parameterCache.get(cacheKey).get(parameter);
|
||||
set = parameterRawValue.explicit;
|
||||
} catch (ParameterMissingResolutionException e) {
|
||||
}
|
||||
catch (ParameterMissingResolutionException e) {
|
||||
set = false;
|
||||
} catch (UnfinishedParameterResolutionException e) {
|
||||
}
|
||||
catch (UnfinishedParameterResolutionException e) {
|
||||
if (e.getParameterDescription().parameter().equals(methodParameter)) {
|
||||
unfinished = e;
|
||||
set = false;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
}
|
||||
catch (Exception e) {
|
||||
unfinished = e;
|
||||
set = false;
|
||||
// Most likely what is already typed would fail resolution (eg type conversion failure)
|
||||
@@ -330,8 +370,10 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
// 1) parameter not set at all
|
||||
// 2) parameter set via its key, not enough input to consume a value
|
||||
// 3) parameter set with multiple values, enough to cover arity. We're done
|
||||
// 4) parameter set, and some value bound. But maybe that value is just a prefix to what the user actually wants
|
||||
// 4.1) or maybe that value was resolved by position, but is a prefix of an actual valid key
|
||||
// 4) parameter set, and some value bound. But maybe that value is just a prefix to what
|
||||
// the user actually wants
|
||||
// 4.1) or maybe that value was resolved by position, but is a prefix of an actual valid
|
||||
// key
|
||||
|
||||
if (!set) {
|
||||
if (unfinished == null) { // case 1 above
|
||||
@@ -356,21 +398,24 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
}
|
||||
|
||||
if (parameterRawValue.positional()) {
|
||||
// Case 4.1: There exists "--command foo" and user has typed "--comm" which (wrongly) got resolved as a positional param
|
||||
// Case 4.1: There exists "--command foo" and user has typed "--comm" which (wrongly) got
|
||||
// resolved as a positional param
|
||||
result.addAll(argumentKeysThatStartWithContextPrefix(methodParameter, context));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private List<CompletionProposal> valueCompletions(MethodParameter methodParameter, CompletionContext completionContext) {
|
||||
private List<CompletionProposal> valueCompletions(MethodParameter methodParameter,
|
||||
CompletionContext completionContext) {
|
||||
return valueProviders.stream()
|
||||
.filter(vp -> vp.supports(methodParameter, completionContext))
|
||||
.map(vp -> vp.complete(methodParameter, completionContext, null))
|
||||
.findFirst().orElseGet(() -> Collections.emptyList());
|
||||
}
|
||||
|
||||
private List<CompletionProposal> argumentKeysThatStartWithContextPrefix(MethodParameter methodParameter, CompletionContext context) {
|
||||
private List<CompletionProposal> argumentKeysThatStartWithContextPrefix(MethodParameter methodParameter,
|
||||
CompletionContext context) {
|
||||
String prefix = context.currentWordUpToCursor() != null ? context.currentWordUpToCursor() : "";
|
||||
return describe(methodParameter).flatMap(pd -> pd.keys().stream())
|
||||
.filter(k -> k.startsWith(prefix))
|
||||
@@ -402,8 +447,8 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the arity of a given parameter. The default arity is 1, except for
|
||||
* booleans where arity is 0 (can be overridden back to 1 via an annotation)
|
||||
* Return the arity of a given parameter. The default arity is 1, except for booleans
|
||||
* where arity is 0 (can be overridden back to 1 via an annotation)
|
||||
*/
|
||||
private int getArity(Parameter parameter) {
|
||||
ShellOption option = parameter.getAnnotation(ShellOption.class);
|
||||
@@ -412,8 +457,8 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key(s) for the i-th parameter of the command method, resolved either from the {@link ShellOption}
|
||||
* annotation, or from the actual parameter name.
|
||||
* Return the key(s) for the i-th parameter of the command method, resolved either from
|
||||
* the {@link ShellOption} annotation, or from the actual parameter name.
|
||||
*/
|
||||
private Stream<String> getKeysForParameter(Method method, int index) {
|
||||
Parameter p = method.getParameters()[index];
|
||||
@@ -459,8 +504,10 @@ public class StandardParameterResolver implements ParameterResolver {
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
CacheKey cacheKey = (CacheKey) o;
|
||||
return Objects.equals(method, cacheKey.method) &&
|
||||
Objects.equals(words, cacheKey.words);
|
||||
|
||||
Reference in New Issue
Block a user