Add support for global help options

- Essentially this commit registeres on default `--help` and
  `-h` options to every command and execution short circuits
  in presense of help options to help command.
- Add Supplier<CommandRegistration.Builder> as a bean which
  can be autowired registration beans.
- Make this common bean customisable via CommandRegistrationCustomizer.
- Change StandardMethodTargetRegistrar to use supplier so that
  annotated commands gets common customizations.
- Change sample commands to use supplier.
- Add new group, spring.shell.help to config props.
- Docs changes
- Fixes #582
- Fixes #585
This commit is contained in:
Janne Valkealahti
2022-12-04 17:23:25 +00:00
parent b555148ce9
commit ef191e66f3
27 changed files with 773 additions and 78 deletions

View File

@@ -236,7 +236,7 @@ public class Shell {
CommandExecution execution = CommandExecution.of(
argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal,
conversionService);
conversionService, commandRegistry);
List<CommandExceptionResolver> commandExceptionResolvers = commandRegistration.get().getExceptionResolvers();

View File

@@ -15,12 +15,12 @@
*/
package org.springframework.shell.command;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import jakarta.validation.Validator;
import org.jline.terminal.Terminal;
import org.springframework.core.MethodParameter;
@@ -33,10 +33,12 @@ import org.springframework.shell.Availability;
import org.springframework.shell.CommandNotCurrentlyAvailable;
import org.springframework.shell.command.CommandParser.CommandParserException;
import org.springframework.shell.command.CommandParser.CommandParserResults;
import org.springframework.shell.command.CommandRegistration.HelpOptionInfo;
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;
import org.springframework.util.ObjectUtils;
/**
* Interface to evaluate a result from a command with an arguments.
@@ -61,7 +63,7 @@ public interface CommandExecution {
* @return default command execution
*/
public static CommandExecution of(List<? extends HandlerMethodArgumentResolver> resolvers) {
return new DefaultCommandExecution(resolvers, null, null, null);
return new DefaultCommandExecution(resolvers, null, null, null, null);
}
/**
@@ -75,7 +77,21 @@ public interface CommandExecution {
*/
public static CommandExecution of(List<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal, ConversionService conversionService) {
return new DefaultCommandExecution(resolvers, validator, terminal, conversionService);
return new DefaultCommandExecution(resolvers, validator, terminal, conversionService, null);
}
/**
* Gets an instance of a default {@link CommandExecution}.
*
* @param resolvers the handler method argument resolvers
* @param validator the validator
* @param terminal the terminal
* @param conversionService the conversion services
* @return default command execution
*/
public static CommandExecution of(List<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal, ConversionService conversionService, CommandCatalog commandCatalog) {
return new DefaultCommandExecution(resolvers, validator, terminal, conversionService, commandCatalog);
}
/**
@@ -87,13 +103,15 @@ public interface CommandExecution {
private Validator validator;
private Terminal terminal;
private ConversionService conversionService;
private CommandCatalog commandCatalog;
public DefaultCommandExecution(List<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal, ConversionService conversionService) {
Terminal terminal, ConversionService conversionService, CommandCatalog commandCatalog) {
this.resolvers = resolvers;
this.validator = validator;
this.terminal = terminal;
this.conversionService = conversionService;
this.commandCatalog = commandCatalog;
}
public Object evaluate(CommandRegistration registration, String[] args) {
@@ -107,15 +125,59 @@ public interface CommandExecution {
CommandParser parser = CommandParser.of(conversionService);
CommandParserResults results = parser.parse(options, args);
// check help options to short circuit
boolean handleHelpOption = false;
HelpOptionInfo helpOption = registration.getHelpOption();
if (helpOption.isEnabled() && helpOption.getCommand() != null && (!ObjectUtils.isEmpty(helpOption.getLongNames()) || !ObjectUtils.isEmpty(helpOption.getShortNames()))) {
handleHelpOption = results.results().stream()
.filter(cpr -> {
boolean present = false;
if (helpOption.getLongNames() != null) {
present = Arrays.asList(cpr.option().getLongNames()).stream()
.filter(ln -> ObjectUtils.containsElement(helpOption.getLongNames(), ln))
.findFirst()
.isPresent();
}
if (present) {
return true;
}
if (helpOption.getShortNames() != null) {
present = Arrays.asList(cpr.option().getShortNames()).stream()
.filter(sn -> ObjectUtils.containsElement(helpOption.getShortNames(), sn))
.findFirst()
.isPresent();
}
return present;
})
.findFirst()
.isPresent();
}
// if needed switch registration to help command if we're short circuiting
CommandRegistration usedRegistration;
if (handleHelpOption) {
String command = registration.getCommand();
CommandParser helpParser = CommandParser.of(conversionService);
CommandRegistration helpCommandRegistration = commandCatalog.getRegistrations()
.get(registration.getHelpOption().getCommand());
List<CommandOption> helpOptions = helpCommandRegistration.getOptions();
CommandParserResults helpResults = helpParser.parse(helpOptions, new String[] { "--command", command });
results = helpResults;
usedRegistration = helpCommandRegistration;
}
else {
usedRegistration = registration;
}
if (!results.errors().isEmpty()) {
throw new CommandParserExceptionsException("Command parser resulted errors", results.errors());
}
CommandContext ctx = CommandContext.of(args, results, terminal, registration);
CommandContext ctx = CommandContext.of(args, results, terminal, usedRegistration);
Object res = null;
TargetInfo targetInfo = registration.getTarget();
TargetInfo targetInfo = usedRegistration.getTarget();
// pick the target to execute
if (targetInfo.getTargetType() == TargetType.FUNCTION) {

View File

@@ -121,6 +121,13 @@ public interface CommandRegistration {
*/
List<CommandExceptionResolver> getExceptionResolvers();
/**
* Gets a help option info.
*
* @return the help option info
*/
HelpOptionInfo getHelpOption();
/**
* Gets a new instance of a {@link Builder}.
*
@@ -475,6 +482,125 @@ public interface CommandRegistration {
Builder and();
}
public interface HelpOptionInfo {
/**
* Gets whether help options are enabled.
*
* @return whether help options are enabled
*/
boolean isEnabled();
/**
* Gets long names options for help.
*
* @return long names options for help
*/
String[] getLongNames();
/**
* Gets short names options for help.
*
* @return short names options for help
*/
Character[] getShortNames();
/**
* Gets command for help.
*
* @return command for help
*/
String getCommand();
static HelpOptionInfo of() {
return of(false, null, null, null);
}
static HelpOptionInfo of(boolean enabled, String[] longNames, Character[] shortNames, String command) {
return new DefaultHelpOptionInfo(enabled, longNames, shortNames, command);
}
static class DefaultHelpOptionInfo implements HelpOptionInfo {
private final String command;
private final String[] longNames;
private final Character[] shortNames;
private final boolean enabled;
public DefaultHelpOptionInfo(boolean enabled, String[] longNames, Character[] shortNames, String command) {
this.command = command;
this.longNames = longNames;
this.shortNames = shortNames;
this.enabled = enabled;
}
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public String[] getLongNames() {
return longNames;
}
@Override
public Character[] getShortNames() {
return shortNames;
}
@Override
public String getCommand() {
return command;
}
}
}
/**
* Spec defining help options.
*/
public interface HelpOptionsSpec {
/**
* Whether help options are enabled.
*
* @param enabled the enabled flag
* @return a help option for chaining
*/
HelpOptionsSpec enabled(boolean enabled);
/**
* Sets long names options for help.
*
* @param longNames the long names
* @return a help option for chaining
*/
HelpOptionsSpec longNames(String... longNames);
/**
* Sets short names options for help.
*
* @param shortNames the short names
* @return a help option for chaining
*/
HelpOptionsSpec shortNames(Character... shortNames);
/**
* Sets command used for help.
*
* @param command the command
* @return a help option for chaining
*/
HelpOptionsSpec command(String command);
/**
* Return a builder for chaining.
*
* @return a builder for chaining
*/
Builder and();
}
/**
* Builder interface for {@link CommandRegistration}.
*/
@@ -575,6 +701,13 @@ public interface CommandRegistration {
*/
ErrorHandlingSpec withErrorHandling();
/**
* Define help options what this command should use.
*
* @return help options spec for chaining
*/
HelpOptionsSpec withHelpOptions();
/**
* Builds a {@link CommandRegistration}.
*
@@ -884,6 +1017,57 @@ public interface CommandRegistration {
}
}
static class DefaultHelpOptionsSpec implements HelpOptionsSpec {
private BaseBuilder builder;
private String command;
private String[] longNames;
private Character[] shortNames;
private boolean enabled = true;
DefaultHelpOptionsSpec(BaseBuilder builder) {
this.builder = builder;
}
DefaultHelpOptionsSpec(BaseBuilder otherBuilder, DefaultHelpOptionsSpec otherSpec) {
this.builder = otherBuilder;
this.builder.helpOptionsSpec = this;
this.command = otherSpec.command;
this.longNames = otherSpec.longNames.clone();
this.shortNames = otherSpec.shortNames.clone();
this.enabled = otherSpec.enabled;
}
@Override
public HelpOptionsSpec command(String command) {
this.command = command;
return this;
}
@Override
public HelpOptionsSpec longNames(String... longNames) {
this.longNames = longNames;
return this;
}
@Override
public HelpOptionsSpec shortNames(Character... shortNames) {
this.shortNames = shortNames;
return this;
}
@Override
public HelpOptionsSpec enabled(boolean enabled) {
this.enabled = enabled;
return this;
}
@Override
public Builder and() {
return builder;
}
}
static class DefaultCommandRegistration implements CommandRegistration {
private String command;
@@ -897,11 +1081,12 @@ public interface CommandRegistration {
private List<DefaultAliasSpec> aliasSpecs;
private DefaultExitCodeSpec exitCodeSpec;
private DefaultErrorHandlingSpec errorHandlingSpec;
private DefaultHelpOptionsSpec helpOptionsSpec;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String group,
boolean hidden, String description, Supplier<Availability> availability,
List<DefaultOptionSpec> optionSpecs, DefaultTargetSpec targetSpec, List<DefaultAliasSpec> aliasSpecs,
DefaultExitCodeSpec exitCodeSpec, DefaultErrorHandlingSpec errorHandlingSpec) {
DefaultExitCodeSpec exitCodeSpec, DefaultErrorHandlingSpec errorHandlingSpec, DefaultHelpOptionsSpec helpOptionsSpec) {
this.command = commandArrayToName(commands);
this.interactionMode = interactionMode;
this.group = group;
@@ -913,6 +1098,7 @@ public interface CommandRegistration {
this.aliasSpecs = aliasSpecs;
this.exitCodeSpec = exitCodeSpec;
this.errorHandlingSpec = errorHandlingSpec;
this.helpOptionsSpec = helpOptionsSpec;
}
@Override
@@ -947,11 +1133,17 @@ public interface CommandRegistration {
@Override
public List<CommandOption> getOptions() {
return optionSpecs.stream()
List<CommandOption> options = optionSpecs.stream()
.map(o -> CommandOption.of(o.getLongNames(), o.getShortNames(), o.getDescription(), o.getType(),
o.isRequired(), o.getDefaultValue(), o.getPosition(), o.getArityMin(), o.getArityMax(),
o.getLabel(), o.getCompletion()))
.collect(Collectors.toList());
if (helpOptionsSpec != null) {
String[] longNames = helpOptionsSpec.longNames != null ? helpOptionsSpec.longNames : null;
Character[] shortNames = helpOptionsSpec.shortNames != null ? helpOptionsSpec.shortNames : null;
options.add(CommandOption.of(longNames, shortNames, "help for " + command));
}
return options;
}
@Override
@@ -997,6 +1189,17 @@ public interface CommandRegistration {
}
}
@Override
public HelpOptionInfo getHelpOption() {
if (this.helpOptionsSpec == null) {
return HelpOptionInfo.of();
}
else {
return HelpOptionInfo.of(helpOptionsSpec.enabled, helpOptionsSpec.longNames, helpOptionsSpec.shortNames,
helpOptionsSpec.command);
}
}
private static String commandArrayToName(String[] commands) {
return Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
@@ -1007,10 +1210,9 @@ public interface CommandRegistration {
}
static class DefaultBuilder extends BaseBuilder {
}
static class BaseBuilder implements Builder {
static abstract class BaseBuilder implements Builder {
private String[] commands;
private InteractionMode interactionMode = InteractionMode.ALL;
@@ -1023,6 +1225,7 @@ public interface CommandRegistration {
private DefaultTargetSpec targetSpec;
private DefaultExitCodeSpec exitCodeSpec;
private DefaultErrorHandlingSpec errorHandlingSpec;
private DefaultHelpOptionsSpec helpOptionsSpec;
@Override
public Builder command(String... commands) {
@@ -1106,13 +1309,21 @@ public interface CommandRegistration {
return spec;
}
@Override
public HelpOptionsSpec withHelpOptions() {
if (this.helpOptionsSpec == null) {
this.helpOptionsSpec = new DefaultHelpOptionsSpec(this);
}
return this.helpOptionsSpec;
}
@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, group, hidden, description, availability,
optionSpecs, targetSpec, aliasSpecs, exitCodeSpec, errorHandlingSpec);
optionSpecs, targetSpec, aliasSpecs, exitCodeSpec, errorHandlingSpec, helpOptionsSpec);
}
}
}

View File

@@ -523,4 +523,24 @@ public class CommandRegistrationTests extends AbstractCommandTests {
.build();
assertThat(registration.isHidden()).isTrue();
}
@Test
public void testHelpOption() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withHelpOptions()
.enabled(true)
.longNames(new String[] { "help" })
.shortNames(new Character[] { 'h' })
.command("help")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getLongNames()).containsExactly("help");
assertThat(registration.getOptions().get(0).getShortNames()).containsExactly('h');
}
}