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

@@ -15,8 +15,6 @@
*/
package org.springframework.shell.standard.commands;
import java.util.stream.Collectors;
import org.springframework.core.io.ResourceLoader;
import org.springframework.shell.standard.AbstractShellComponent;
import org.springframework.shell.standard.ShellComponent;
@@ -51,8 +49,7 @@ public class Completion extends AbstractShellComponent {
@ShellMethod(key = "completion bash", value = "Generate bash completion script")
public String bash() {
BashCompletions bashCompletions = new BashCompletions(resourceLoader, getCommandRegistry(),
getParameterResolver().collect(Collectors.toList()));
BashCompletions bashCompletions = new BashCompletions(resourceLoader, getCommandCatalog());
return bashCompletions.generate(rootCommand);
}
}

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,15 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.standard.commands;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
@@ -37,24 +36,26 @@ import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.Availability;
import org.springframework.shell.MethodTarget;
import org.springframework.shell.ParameterDescription;
import org.springframework.shell.Utils;
import org.springframework.shell.command.CommandOption;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.standard.AbstractShellComponent;
import org.springframework.shell.standard.CommandValueProvider;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
import org.springframework.util.StringUtils;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toCollection;
import static java.util.stream.Collectors.toMap;
/**
* A command to display help about all available commands.
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
@ShellComponent
public class Help extends AbstractShellComponent {
@@ -91,7 +92,7 @@ public class Help extends AbstractShellComponent {
@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)
"--command" }, help = "The command to obtain help for.", arity = Integer.MAX_VALUE) String command)
throws IOException {
if (command == null) {
return listCommands();
@@ -116,16 +117,17 @@ public class Help extends AbstractShellComponent {
* Return a description of a specific command. Uses a layout inspired by *nix man pages.
*/
private CharSequence documentCommand(String command) {
MethodTarget methodTarget = getCommandRegistry().listCommands().get(command);
if (methodTarget == null) {
Map<String, CommandRegistration> registrations = getCommandCatalog().getRegistrations();
CommandRegistration registration = registrations.get(command);
if (registration == null) {
throw new IllegalArgumentException("Unknown command '" + command + "'");
}
AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n");
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(registration);
// NAME
documentCommandName(result, command, methodTarget.getHelp());
documentCommandName(result, command, registration.getHelp());
// SYNOPSYS
documentSynopsys(result, command, parameterDescriptions);
@@ -134,10 +136,10 @@ public class Help extends AbstractShellComponent {
documentOptions(result, parameterDescriptions);
// ALSO KNOWN AS
documentAliases(result, command, methodTarget);
documentAliases(result, command, registrations, registration);
// AVAILABILITY
documentAvailability(result, methodTarget);
documentAvailability(result, registration);
result.append("\n");
return result;
@@ -242,12 +244,13 @@ public class Help extends AbstractShellComponent {
}
}
private void documentAliases(AttributedStringBuilder result, String command, MethodTarget methodTarget) {
Set<String> aliases = getCommandRegistry().listCommands().entrySet().stream()
.filter(e -> e.getValue().equals(methodTarget))
.map(Map.Entry::getKey)
.filter(c -> !command.equals(c))
.collect(toCollection(TreeSet::new));
private void documentAliases(AttributedStringBuilder result, String command,
Map<String, CommandRegistration> registrations, CommandRegistration registration) {
List<String> aliases = registrations.entrySet().stream()
.filter(e -> e.getValue().equals(registration))
.map(Map.Entry::getKey)
.filter(c -> !command.equals(c))
.collect(Collectors.toList());
if (!aliases.isEmpty()) {
result.append("ALSO KNOWN AS", AttributedStyle.BOLD).append("\n");
@@ -257,8 +260,8 @@ public class Help extends AbstractShellComponent {
}
}
private void documentAvailability(AttributedStringBuilder result, MethodTarget methodTarget) {
Availability availability = methodTarget.getAvailability();
private void documentAvailability(AttributedStringBuilder result, CommandRegistration registration) {
Availability availability = registration.getAvailability();
if (!availability.isAvailable()) {
result.append("CURRENTLY UNAVAILABLE", AttributedStyle.BOLD).append("\n");
result.append('\t').append("This command is currently not available because ")
@@ -272,20 +275,21 @@ public class Help extends AbstractShellComponent {
}
private CharSequence listCommands() {
Map<String, MethodTarget> commandsByName = getCommandRegistry().listCommands();
AttributedStringBuilder result = new AttributedStringBuilder();
result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD);
SortedMap<String, Map<String, MethodTarget>> commandsByGroupAndName = commandsByName.entrySet().stream()
.collect(groupingBy(e -> e.getValue().getGroup(), TreeMap::new, // group by and sort by command group
toMap(Entry::getKey, Entry::getValue)));
// display groups, sorted alphabetically, "Default" first
SortedMap<String, Map<String, CommandRegistration>> commandsByGroupAndName = getCommandCatalog().getRegistrations().entrySet().stream()
.collect(Collectors.groupingBy(
e -> StringUtils.hasText(e.getValue().getGroup()) ? e.getValue().getGroup() : "",
TreeMap::new,
Collectors.toMap(Entry::getKey, Entry::getValue)
));
commandsByGroupAndName.forEach((group, commandsInGroup) -> {
if (showGroups) {
result.append("".equals(group) ? "Default" : group, AttributedStyle.BOLD).append('\n');
}
Map<MethodTarget, SortedSet<String>> commandNamesByMethod = commandsInGroup.entrySet().stream()
Map<CommandRegistration, SortedSet<String>> commandNamesByMethod = commandsInGroup.entrySet().stream()
.collect(groupingBy(Entry::getValue, // group by command method
mapping(Entry::getKey, toCollection(TreeSet::new)))); // sort command names
// display commands, sorted alphabetically by their first alias
@@ -304,19 +308,15 @@ public class Help extends AbstractShellComponent {
}
});
if (commandsByName.values().stream().distinct().anyMatch(m -> !isAvailable(m))) {
result.append("Commands marked with (*) are currently unavailable.\nType `help <command>` to learn more.\n\n");
}
return result;
}
private Comparator<Entry<MethodTarget, SortedSet<String>>> sortByFirstCommandName() {
private Comparator<Entry<CommandRegistration, SortedSet<String>>> sortByFirstCommandName() {
return Comparator.comparing(e -> e.getValue().first());
}
private boolean isAvailable(MethodTarget methodTarget) {
return methodTarget.getAvailability().isAvailable();
private boolean isAvailable(CommandRegistration methodTarget) {
return true;
}
private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDescription description) {
@@ -330,12 +330,50 @@ public class Help extends AbstractShellComponent {
}
}
private List<ParameterDescription> getParameterDescriptions(MethodTarget methodTarget) {
return Utils.createMethodParameters(methodTarget.getMethod())
.flatMap(mp -> getParameterResolver().filter(pr -> pr.supports(mp)).limit(1L)
.flatMap(pr -> pr.describe(mp)))
.collect(Collectors.toList());
private List<ParameterDescription> getParameterDescriptions(CommandRegistration registration) {
List<CommandOption> options = registration.getOptions();
List<ParameterDescription> descriptions = new ArrayList<>();
for (CommandOption option : options) {
ParameterDescription description = new ParameterDescription();
if (option.getType() != null) {
description.type(option.getType().toString());
description.formal(option.getType().toClass().getSimpleName());
}
else {
description.formal("");
}
description.help(option.getDescription());
description.mandatoryKey(option.isRequired());
if (option.getType() != null && option.getType().isAssignableFrom(boolean.class)) {
description.defaultValue("false");
}
else {
description.defaultValue(option.getDefaultValue());
}
List<String> keys = new ArrayList<>();
if (option.getLongNames() != null) {
for (String ln : option.getLongNames()) {
keys.add("--" + ln);
}
}
if (option.getShortNames() != null) {
for (Character sn : option.getShortNames()) {
keys.add("-" + String.valueOf(sn));
}
}
description.keys(keys);
descriptions.add(description);
}
// return Utils.createMethodParameters(registration.getTarget().getMethod())
// .flatMap(mp -> getParameterResolver().filter(pr -> pr.supports(mp)).limit(1L)
// .flatMap(pr -> pr.describe(mp)))
// .collect(Collectors.toList());
return descriptions;
}
private static class DummyContext implements MessageInterpolator.Context {
@@ -361,5 +399,4 @@ public class Help extends AbstractShellComponent {
return null;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2017 the original author or authors.
* 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.
@@ -13,14 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.standard.commands;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
@@ -28,44 +26,45 @@ import java.util.Optional;
import javax.validation.constraints.Max;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.io.ClassPathResource;
import org.springframework.shell.Command;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.MethodTarget;
import org.springframework.shell.ParameterResolver;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
import org.springframework.shell.standard.StandardParameterResolver;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.FileCopyUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for the {@link Help} command.
*
* @author Eric Bottard
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = HelpTest.Config.class)
public class HelpTest {
@ContextConfiguration(classes = HelpTests.Config.class)
public class HelpTests {
private static Locale previousLocale;
private String testName;
private Map<String, CommandRegistration> registrations = new HashMap<>();
private CommandsPojo commandsPojo = new CommandsPojo();
@MockBean
private CommandCatalog commandCatalog;
@Autowired
private Help help;
@BeforeAll
public static void setAssumedLocale() {
@@ -80,25 +79,106 @@ public class HelpTest {
@BeforeEach
public void setup(TestInfo testInfo) {
registrations.clear();
Optional<Method> testMethod = testInfo.getTestMethod();
if (testMethod.isPresent()) {
this.testName = testMethod.get().getName();
}
Mockito.when(commandCatalog.getRegistrations()).thenReturn(registrations);
}
@Autowired
private Help help;
@Test
public void testCommandHelp() throws Exception {
CommandRegistration registration = CommandRegistration.builder()
.command("first-command")
.help("A rather extensive description of some command.")
.withTarget()
.method(commandsPojo, "firstCommand")
.and()
.withOption()
.shortNames('r')
.description("Whether to delete recursively")
.type(boolean.class)
.and()
.withOption()
.shortNames('f')
.description("Do not ask for confirmation. YOLO")
.type(boolean.class)
.and()
.withOption()
.shortNames('n')
.description("The answer to everything")
.defaultValue("42")
.type(int.class)
.and()
.withOption()
.shortNames('o')
.description("Some other parameters")
.type(float[].class)
.and()
.build();
registrations.put("first-command", registration);
registrations.put("1st-command", registration);
CharSequence help = this.help.help("first-command").toString();
Assertions.assertThat(help).isEqualTo(sample());
assertThat(help).isEqualTo(sample());
}
@Test
public void testCommandList() throws Exception {
CommandRegistration registration1 = CommandRegistration.builder()
.command("first-command")
.help("A rather extensive description of some command.")
.withTarget()
.method(commandsPojo, "firstCommand")
.and()
.withOption()
.shortNames('r')
.and()
.build();
registrations.put("first-command", registration1);
registrations.put("1st-command", registration1);
CommandRegistration registration2 = CommandRegistration.builder()
.command("second-command")
.help("The second command. This one is known under several aliases as well.")
.withTarget()
.method(commandsPojo, "secondCommand")
.and()
.build();
registrations.put("second-command", registration2);
registrations.put("yet-another-command", registration2);
CommandRegistration registration3 = CommandRegistration.builder()
.command("second-command")
.help("The last command.")
.withTarget()
.method(commandsPojo, "thirdCommand")
.and()
.build();
registrations.put("third-command", registration3);
CommandRegistration registration4 = CommandRegistration.builder()
.command("first-group-command")
.help("The first command in a separate group.")
.group("Example Group")
.withTarget()
.method(commandsPojo, "firstCommandInGroup")
.and()
.build();
registrations.put("first-group-command", registration4);
CommandRegistration registration5 = CommandRegistration.builder()
.command("second-group-command")
.help("The second command in a separate group.")
.group("Example Group")
.withTarget()
.method(commandsPojo, "secondCommandInGroup")
.and()
.build();
registrations.put("second-group-command", registration5);
String list = this.help.help(null).toString();
Assertions.assertThat(list).isEqualTo(sample());
assertThat(list).isEqualTo(sample());
}
@Test
@@ -109,7 +189,7 @@ public class HelpTest {
}
private String sample() throws IOException {
InputStream is = new ClassPathResource(HelpTest.class.getSimpleName() + "-" + testName + ".txt", HelpTest.class).getInputStream();
InputStream is = new ClassPathResource(HelpTests.class.getSimpleName() + "-" + testName + ".txt", HelpTests.class).getInputStream();
return FileCopyUtils.copyToString(new InputStreamReader(is, "UTF-8")).replace("&", "");
}
@@ -117,62 +197,18 @@ public class HelpTest {
static class Config {
@Bean
public Help help(CommandRegistry commandRegistry) {
public Help help() {
return new Help();
}
@Bean
public CommandRegistry shell() {
return new CommandRegistry() {
@Override
public Map<String, MethodTarget> listCommands() {
Map<String, MethodTarget> result = new HashMap<>();
MethodTarget methodTarget = MethodTarget.of("firstCommand", commands(), new Command.Help("A rather extensive description of some command."));
result.put("first-command", methodTarget);
result.put("1st-command", methodTarget);
methodTarget = MethodTarget.of("secondCommand", commands(), new Command.Help("The second command. This one is known under several aliases as well."));
result.put("second-command", methodTarget);
result.put("yet-another-command", methodTarget);
methodTarget = MethodTarget.of("thirdCommand", commands(), new Command.Help("The last command."));
result.put("third-command", methodTarget);
methodTarget = MethodTarget.of("firstCommandInGroup", commands(), new Command.Help("The first command in a separate group.", "Example Group"));
result.put("first-group-command", methodTarget);
methodTarget = MethodTarget.of("secondCommandInGroup", commands(), new Command.Help("The second command in a separate group.", "Example Group"));
result.put("second-group-command", methodTarget);
return result;
}
@Override
public void addCommand(String name, MethodTarget target) {
}
@Override
public void removeCommand(String name) {
}
};
}
@Bean
public ParameterResolver parameterResolver() {
return new StandardParameterResolver(new DefaultConversionService(), Collections.emptySet());
}
@Bean
public Object commands() {
return new Commands();
}
// @Bean
// public ParameterResolver parameterResolver() {
// return new StandardParameterResolver(new DefaultConversionService(), Collections.emptySet());
// }
}
@ShellComponent
static class Commands {
static class CommandsPojo {
@ShellMethod(prefix = "--")
public void firstCommand(
@@ -186,28 +222,22 @@ public class HelpTest {
// Single key, arity > 1.
@ShellOption(help = "Some other parameters", arity = 3, value = "-o") float[] o
) {
}
@ShellMethod
public void secondCommand() {
}
@ShellMethod
public void thirdCommand() {
}
@ShellMethod
public void firstCommandInGroup() {
}
@ShellMethod
public void secondCommandInGroup() {
}
}
}

View File

@@ -4,22 +4,22 @@ NAME
first-command - A rather extensive description of some command.
SYNOPSYS
first-command [-r] [-f] [[-n] int] [-o] float float float
first-command [[-r] boolean] [[-f] boolean] [[-n] int] [-o] float[]
OPTIONS
-r Whether to delete recursively
-r boolean
Whether to delete recursively
[Optional, default = false]
-f or --force
-f boolean
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
-o float[]
Some other parameters
[Mandatory]