diff --git a/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java b/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java index 8685968a..3b2521f2 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java @@ -37,16 +37,22 @@ public class MethodTarget { private final String help; + private final String group; + /** * If not null, returns whether or not the command is currently available. Implementations must be idempotent. */ private final Supplier availabilityIndicator; public MethodTarget(Method method, Object bean, String help) { - this(method, bean, help, null); + this(method, bean, help, null, null); } public MethodTarget(Method method, Object bean, String help, Supplier availabilityIndicator) { + this(method, bean, help, null, availabilityIndicator); + } + + public MethodTarget(Method method, Object bean, String help, String group, Supplier availabilityIndicator) { Assert.notNull(method, "Method cannot be null"); Assert.notNull(bean, "Bean cannot be null"); Assert.hasText(help, String.format("Help cannot be blank when trying to define command based on '%s'", method)); @@ -54,6 +60,7 @@ public class MethodTarget { this.method = method; this.bean = bean; this.help = help; + this.group = group != null ? group : ""; this.availabilityIndicator = availabilityIndicator != null ? availabilityIndicator : () -> Availability.available(); } @@ -62,21 +69,29 @@ public class MethodTarget { * in case of overloaded method. */ public static MethodTarget of(String name, Object bean, String help) { - return of(name, bean, help, null); + return of(name, bean, help, null, 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, String help, Supplier availabilityIndicator) { + public static MethodTarget of(String name, Object bean, String help, String group) { + return of(name, bean, help, group, 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, String help, String group, Supplier availabilityIndicator) { Set 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); + return new MethodTarget(found.iterator().next(), bean, help, group, availabilityIndicator); } public Method getMethod() { @@ -91,6 +106,10 @@ public class MethodTarget { return help; } + public String getGroup() { + return group; + } + public Availability getAvailability() { return availabilityIndicator.get(); } @@ -104,6 +123,7 @@ public class MethodTarget { if (!method.equals(that.method)) return false; if (!bean.equals(that.bean)) return false; + if (!group.equals(that.group)) return false; return help.equals(that.help); } @@ -113,6 +133,7 @@ public class MethodTarget { int result = method.hashCode(); result = 31 * result + bean.hashCode(); result = 31 * result + help.hashCode(); + result = 31 * result + group.hashCode(); return result; } diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java index 336c16a1..23fd4159 100644 --- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java @@ -16,14 +16,20 @@ package org.springframework.shell.standard.commands; +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; import java.io.IOException; 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; import java.util.TreeSet; import java.util.stream.Collectors; @@ -267,39 +273,49 @@ public class Help { } private CharSequence listCommands() { - Map> 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 + Map commandsByName = commandRegistry.listCommands(); + + SortedMap> commandsByGroupAndName = commandsByName.entrySet().stream() + .collect(groupingBy(e -> e.getValue().getGroup(), TreeMap::new, // group by and sort by command group + toMap(Entry::getKey, Entry::getValue))); - // Then display commands, sorted alphabetically by their first alias AttributedStringBuilder result = new AttributedStringBuilder(); result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD); - groupedByMethodTarget.entrySet().stream() - .sorted(sortByFirstElement()) - .forEach(e -> result.append(isAvailable(e) ? " " : " * ") - .append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD) + // display groups, sorted alphabetically, "Default" first + commandsByGroupAndName.forEach((group, commandsInGroup) -> { + result.append("".equals(group) ? "Default" : group, AttributedStyle.BOLD).append('\n'); + + Map> 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 + commandNamesByMethod.entrySet().stream().sorted(sortByFirstCommandName()).forEach(e -> { + result + .append(isAvailable(e.getKey()) ? " " : " * ") + .append(String.join(", ", e.getValue()), AttributedStyle.BOLD) .append(": ") - .append(e.getKey()) - .append('\n')); + .append(e.getKey().getHelp()) + .append('\n'); + }); - groupedByMethodTarget.entrySet().stream() - .filter(e -> !isAvailable(e)) - .findAny() - .ifPresent(e -> result.append( - "\nCommands marked with (*) are currently unavailable.\nType `help ` to learn more.\n")); + result.append('\n'); + }); - return result.append("\n"); + if (commandsByName.values().stream().distinct().anyMatch(m -> !isAvailable(m))) { + result.append("Commands marked with (*) are currently unavailable.\nType `help ` to learn more.\n\n"); + } + + return result; } - private Comparator>> sortByFirstElement() { - return Comparator.comparing(e -> e.getValue().iterator().next()); + private Comparator>> sortByFirstCommandName() { + return Comparator.comparing(e -> e.getValue().first()); } - private boolean isAvailable(Map.Entry> entry) { - String commandName = entry.getValue().iterator().next(); - return commandRegistry.listCommands().get(commandName).getAvailability().isAvailable(); + private boolean isAvailable(MethodTarget methodTarget) { + return methodTarget.getAvailability().isAvailable(); } private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDescription description) { diff --git a/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java b/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java index 260dc9b9..5cf9594e 100644 --- a/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java +++ b/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java @@ -125,6 +125,12 @@ public class HelpTest { methodTarget = MethodTarget.of("thirdCommand", commands(), "The last command."); result.put("third-command", methodTarget); + methodTarget = MethodTarget.of("firstCommandInGroup", commands(), "The first command in a separate group.", "Example Group"); + result.put("first-group-command", methodTarget); + + methodTarget = MethodTarget.of("secondCommandInGroup", commands(), "The second command in a separate group.", "Example Group"); + result.put("second-group-command", methodTarget); + return result; }; } @@ -169,5 +175,15 @@ public class HelpTest { } + @ShellMethod + public void firstCommandInGroup() { + + } + + @ShellMethod + public void secondCommandInGroup() { + + } + } } diff --git a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt index faad7e9a..c4f6bcae 100644 --- a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt +++ b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt @@ -1,6 +1,11 @@ AVAILABLE COMMANDS& & +Default& 1st-command, first-command: A rather extensive description of some command.& second-command, yet-another-command: The second command. This one is known under several aliases as well.& third-command: The last command.& & +Example Group& + first-group-command: The first command in a separate group.& + second-group-command: The second command in a separate group.& +& diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethod.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethod.java index 143ab9f5..ddc63af1 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethod.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellMethod.java @@ -53,4 +53,11 @@ public @interface ShellMethod { */ String prefix() default "--"; + /** + * The command group which this command belongs to. The command group is used when printing a list of + * commands to group related commands. + * @return name of the command group + */ + String group() default ""; + } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java index 7dd47786..4973f61d 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java @@ -61,9 +61,10 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { if (keys.length == 0) { keys = new String[] { Utils.unCamelify(method.getName()) }; } + String group = shellMapping.group(); for (String key : keys) { Supplier availabilityIndicator = findAvailabilityIndicator(keys, bean, method); - MethodTarget target = new MethodTarget(method, bean, shellMapping.value(), availabilityIndicator); + MethodTarget target = new MethodTarget(method, bean, shellMapping.value(), group, availabilityIndicator); registry.register(key, target); commands.put(key, target); }