From f0e5c45ee9129e20e72cbab3a06cfec657a928d3 Mon Sep 17 00:00:00 2001 From: Eric Bottard Date: Wed, 27 Sep 2017 10:49:12 +0200 Subject: [PATCH] Add automatic command grouping Fixes #163 Introduce Command and Command.Help --- .../org/springframework/shell/Command.java | 77 +++++++++++++++++++ .../springframework/shell/MethodTarget.java | 39 ++++------ .../ConfigurableCommandRegistryTest.java | 7 +- .../org/springframework/shell/ShellTest.java | 16 ++-- .../src/main/asciidoc/using-spring-shell.adoc | 51 ++++++++++++ .../LegacyMethodTargetRegistrarTest.java | 3 +- .../shell/standard/commands/package-info.java | 3 + .../shell/standard/commands/HelpTest.java | 13 ++-- .../shell/standard/ShellCommandGroup.java | 50 ++++++++++++ .../shell/standard/ShellMethod.java | 12 ++- .../StandardMethodTargetRegistrar.java | 33 +++++++- .../StandardMethodTargetRegistrarTest.java | 26 ++++++- .../standard/test1/GroupOneCommands.java | 35 +++++++++ .../shell/standard/test1/package-info.java | 20 +++++ .../standard/test2/GroupThreeCommands.java | 37 +++++++++ .../standard/test2/GroupTwoCommands.java | 35 +++++++++ 16 files changed, 404 insertions(+), 53 deletions(-) create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/Command.java create mode 100644 spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellCommandGroup.java create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/GroupOneCommands.java create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/package-info.java create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupThreeCommands.java create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupTwoCommands.java diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Command.java b/spring-shell-core/src/main/java/org/springframework/shell/Command.java new file mode 100644 index 00000000..3f823315 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/Command.java @@ -0,0 +1,77 @@ +/* + * 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 + * + * http://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.Objects; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +public interface Command { + + /** + * Encapsulates help metadata about a shell command. + * + * @author Eric Bottard + */ + class Help { + + /** + * Optional group to gather related commands together. + */ + private final String group; + + /** + * A required, short one sentence description of the command. Should start with a capital and end with a dot + * for consistency. + */ + private final String description; + + public Help(String description) { + this(description, null); + } + + public Help(String description, String group) { + Assert.isTrue(StringUtils.hasText(description), "Command description cannot be null or empty"); + this.description = description; + this.group = StringUtils.hasText(group) ? group : ""; + } + + public String getDescription() { + return description; + } + + public String getGroup() { + return group; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Help help = (Help) o; + return Objects.equals(group, help.group) && + Objects.equals(description, help.description); + } + + @Override + public int hashCode() { + return Objects.hash(group, description); + } + } + +} 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 3b2521f2..3b3be910 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 @@ -29,15 +29,13 @@ import org.springframework.util.ReflectionUtils; * * @author Eric Bottard */ -public class MethodTarget { +public class MethodTarget implements Command { private final Method method; private final Object bean; - private final String help; - - private final String group; + private final Help help; /** * If not null, returns whether or not the command is currently available. Implementations must be idempotent. @@ -45,22 +43,21 @@ public class MethodTarget { private final Supplier availabilityIndicator; public MethodTarget(Method method, Object bean, String help) { - this(method, bean, help, null, null); + this(method, bean, new Help(help, null), null); } public MethodTarget(Method method, Object bean, String help, Supplier availabilityIndicator) { - this(method, bean, help, null, availabilityIndicator); + this(method, bean, new Help(help, null), availabilityIndicator); } - public MethodTarget(Method method, Object bean, String help, String group, Supplier availabilityIndicator) { + public MethodTarget(Method method, Object bean, Help help, 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)); + 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.group = group != null ? group : ""; this.availabilityIndicator = availabilityIndicator != null ? availabilityIndicator : () -> Availability.available(); } @@ -68,30 +65,22 @@ public class MethodTarget { * 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) { - return of(name, bean, help, null, null); + 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, 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) { + public static MethodTarget of(String name, Object bean, Help help, 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, group, availabilityIndicator); + return new MethodTarget(found.iterator().next(), bean, help, availabilityIndicator); } public Method getMethod() { @@ -103,11 +92,11 @@ public class MethodTarget { } public String getHelp() { - return help; + return help.getDescription(); } public String getGroup() { - return group; + return help.getGroup(); } public Availability getAvailability() { @@ -123,7 +112,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; + if (!help.equals(that.help)) return false; return help.equals(that.help); } @@ -133,7 +122,7 @@ public class MethodTarget { int result = method.hashCode(); result = 31 * result + bean.hashCode(); result = 31 * result + help.hashCode(); - result = 31 * result + group.hashCode(); + result = 31 * result + help.hashCode(); return result; } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java b/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java index 2732fb66..b122481f 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java @@ -20,7 +20,6 @@ import static org.hamcrest.collection.IsMapContaining.hasEntry; import static org.hamcrest.collection.IsMapContaining.hasKey; import static org.junit.Assert.*; -import org.hamcrest.collection.IsMapContaining; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -38,7 +37,7 @@ public class ConfigurableCommandRegistryTest { @Test public void testRegistration() { ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(); - registry.register("foo", MethodTarget.of("toString", this, "some command")); + registry.register("foo", MethodTarget.of("toString", this, new Command.Help("some command"))); assertThat(registry.listCommands(), hasKey("foo")); } @@ -46,14 +45,14 @@ public class ConfigurableCommandRegistryTest { @Test public void testDoubleRegistration() { ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(); - registry.register("foo", MethodTarget.of("toString", this, "some command")); + registry.register("foo", MethodTarget.of("toString", this, new Command.Help("some command"))); thrown.expect(IllegalArgumentException.class); thrown.expectMessage("foo"); thrown.expectMessage("toString"); thrown.expectMessage("hashCode"); - registry.register("foo", MethodTarget.of("hashCode", this, "some command")); + registry.register("foo", MethodTarget.of("hashCode", this, new Command.Help("some command"))); } } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java b/spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java index 755782bc..e4724123 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java @@ -83,7 +83,7 @@ public class ShellTest { when(parameterResolver.resolve(any(), any())).thenReturn(valueResult); doThrow(new Exit()).when(resultHandler).handleResult(any()); - shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, "Say hello")); + shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))); try { shell.run(inputProvider); @@ -101,7 +101,7 @@ public class ShellTest { when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?", null); doThrow(new Exit()).when(resultHandler).handleResult(isA(CommandNotFound.class)); - shell.methodTargets = Collections.singletonMap("bonjour", MethodTarget.of("helloWorld", this, "Say hello")); + shell.methodTargets = Collections.singletonMap("bonjour", MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))); try { shell.run(inputProvider); @@ -118,7 +118,7 @@ public class ShellTest { when(inputProvider.readInput()).thenReturn(() -> "helloworld how are you doing ?", null); doThrow(new Exit()).when(resultHandler).handleResult(isA(CommandNotFound.class)); - shell.methodTargets = Collections.singletonMap("hello", MethodTarget.of("helloWorld", this, "Say hello")); + shell.methodTargets = Collections.singletonMap("hello", MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))); try { shell.run(inputProvider); @@ -137,7 +137,7 @@ public class ShellTest { when(parameterResolver.resolve(any(), any())).thenReturn(valueResult); doThrow(new Exit()).when(resultHandler).handleResult(any()); - shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, "Say hello")); + shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, new Command.Help("Say hello"))); try { shell.run(inputProvider); @@ -156,7 +156,7 @@ public class ShellTest { when(inputProvider.readInput()).thenReturn(() -> "fail", null); doThrow(new Exit()).when(resultHandler).handleResult(isA(SomeException.class)); - shell.methodTargets = Collections.singletonMap("fail", MethodTarget.of("failing", this, "Will throw an exception")); + shell.methodTargets = Collections.singletonMap("fail", MethodTarget.of("failing", this, new Command.Help("Will throw an exception"))); try { shell.run(inputProvider); @@ -185,7 +185,7 @@ public class ShellTest { shell.applicationContext = mock(ApplicationContext.class); when(shell.applicationContext.getBeansOfType(MethodTargetRegistrar.class)) .thenReturn(Collections.singletonMap("foo", r -> { - r.register("hw", MethodTarget.of("helloWorld", this, "hellow world")); + r.register("hw", MethodTarget.of("helloWorld", this, new Command.Help("hellow world"))); })); thrown.expect(ParameterResolverMissingException.class); @@ -198,8 +198,8 @@ public class ShellTest { when(parameterResolver.supports(any())).thenReturn(true); when(shell.applicationContext.getBeansOfType(MethodTargetRegistrar.class)) .thenReturn(Collections.singletonMap("foo", r -> { - r.register("hello world", MethodTarget.of("helloWorld", this, "hellow world")); - r.register("another command", MethodTarget.of("helloWorld", this, "another command")); + r.register("hello world", MethodTarget.of("helloWorld", this, new Command.Help("hellow world"))); + r.register("another command", MethodTarget.of("helloWorld", this, new Command.Help("another command"))); })); shell.gatherMethodTargets(); diff --git a/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc b/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc index 74df71d2..067cd7af 100644 --- a/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-spring-shell.adoc @@ -596,6 +596,57 @@ But it's often good practice to put related commands in the same class, and the can benefit from that. ==== +[[organizing-commands]] +=== Organizing Commands +When your shell starts to provide a lot of functionality, you may en up +with a lot of commands, which could be confusing for your users. Typing `help` +they would see a daunting list of commands, organized by alphabetical order, +which may not always make sense. + +To alleviate this, Spring Shell provides the ability to group commands together, +with reasonable defaults. Related commands would then end up in the same _group_ (_e.g._ `User Management Commands`) +and be displayed together in the help screen and other places. + +By default, commands will be grouped according to the class they are implemented in, +turning the camel case class name into separate words (so `URLRelatedCommands` becomes `URL Related Commands`). +This is a very sensible default, as related commands are often already in the class anyway, +for they need to use the same collaborating objects. + +If however, this behavior does not suit you, you can override the group for a +command in the following ways, in order of priority: + +* specifying a `group()` in the `@ShellMethod` annotation +* placing a `@ShellCommandGroup` on the class the command is defined in. This will apply +the group for all commands defined in that class (unless overridden as above) +* placing a `@ShellCommandGroup` on the package (_via_ `package-info.java`) +the command is defined in. This will apply to all commands defined in the +package (unless overridden at the method or class level as explained above) + +Here is a short example: +[source,java] +---- +public class UserCommands { + @ShellCommand(value = "This command ends up in the 'User Commands' group") + public void foo() {} + + @ShellCommand(value = "This command ends up in the 'Other Commands' group", + group = "Other Commands") + public void bar() {} +} + +... + +@ShellCommandGroup("Other Commands") +public class SomeCommands { + @ShellMethod(value = "This one is in 'Other Commands'") + public void wizz() {} + + @ShellMethod(value = "And this one is 'Yet Another Group'", + group = "Yet Another Group") + public void last() {} +} +---- + [[built-in-commands]] === Built-In Commands Any application built using the `{starter-artifactId}` artifact diff --git a/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java b/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java index 5799da5b..391de8ac 100644 --- a/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java +++ b/spring-shell-shell1-adapter/src/test/java/org/springframework/shell/legacy/LegacyMethodTargetRegistrarTest.java @@ -27,6 +27,7 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.shell.Command; import org.springframework.shell.ConfigurableCommandRegistry; import org.springframework.shell.MethodTarget; import org.springframework.shell.MethodTargetRegistrar; @@ -56,7 +57,7 @@ public class LegacyMethodTargetRegistrarTest { assertThat(targets).contains(entry( "register module", - MethodTarget.of("register", legacyCommands, "Register a new module") + MethodTarget.of("register", legacyCommands, new Command.Help("Register a new module")) )); } diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/package-info.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/package-info.java index bce8234f..5cf82d87 100644 --- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/package-info.java +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/package-info.java @@ -19,4 +19,7 @@ * * @author Eric Bottard */ +@ShellCommandGroup("Built-In Commands") package org.springframework.shell.standard.commands; + +import org.springframework.shell.standard.ShellCommandGroup; 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 5cf9594e..90177cca 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 @@ -19,7 +19,6 @@ 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; @@ -38,6 +37,7 @@ 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.standard.StandardParameterResolver; import org.springframework.shell.MethodTarget; import org.springframework.shell.ParameterResolver; @@ -48,7 +48,6 @@ import org.springframework.shell.standard.ShellOption; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.util.FileCopyUtils; -import org.springframework.util.ReflectionUtils; import javax.validation.constraints.Max; @@ -114,21 +113,21 @@ public class HelpTest { public CommandRegistry shell() { return () -> { Map result = new HashMap<>(); - MethodTarget methodTarget = MethodTarget.of("firstCommand", commands(), "A rather extensive description of some command."); + 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(), "The second command. This one is known under several aliases as well."); + 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(), "The last command."); + methodTarget = MethodTarget.of("thirdCommand", commands(), new Command.Help("The last command.")); result.put("third-command", methodTarget); - methodTarget = MethodTarget.of("firstCommandInGroup", commands(), "The first command in a separate group.", "Example Group"); + 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(), "The second command in a separate group.", "Example Group"); + 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; diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellCommandGroup.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellCommandGroup.java new file mode 100644 index 00000000..b8c43fc2 --- /dev/null +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellCommandGroup.java @@ -0,0 +1,50 @@ +/* + * 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 + * + * http://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.standard; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Used to indicate the default group of shell commands, either at the package or class level. + * + * @author Eric Bottard + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PACKAGE, ElementType.TYPE}) +@Documented +public @interface ShellCommandGroup { + + /** + * The default value for the group label, which when set
    + *
  • on a class, will mean to look at the package level
  • + *
  • on a package, to go back at the class level and infer a name from the class name.
  • + *
+ */ + String INHERIT_AND_INFER = ""; + + /** + * @return + * An explicit value for the group, which will apply to all commands in the owning class or package, depending + * on where this annotation is set. + */ + String value() default INHERIT_AND_INFER; + +} 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 ddc63af1..321e0308 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 @@ -33,6 +33,13 @@ import java.lang.annotation.Target; @Documented public @interface ShellMethod { + /** + * The default value for {@link #group()}, meaning that the group will be inherited from the explicit value set + * on the containing element (class then package) or ultimately inferred. + * @see ShellCommandGroup + */ + String INHERITED = ""; + /** * The name(s) by which this method can be invoked via Spring Shell. If not specified, the actual method name * will be used (turning camelCase humps into "-"). @@ -55,9 +62,10 @@ public @interface ShellMethod { /** * The command group which this command belongs to. The command group is used when printing a list of - * commands to group related commands. + * commands to group related commands. By default, group is first looked up from owning class then package, + * and if not explicitly set, is inferred from class name. * @return name of the command group */ - String group() default ""; + String group() default INHERITED; } 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 4973f61d..34e837d7 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 @@ -21,14 +21,15 @@ import static org.springframework.util.StringUtils.collectionToDelimitedString; import java.lang.reflect.Method; import java.util.*; import java.util.function.Supplier; -import java.util.stream.Collector; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotationUtils; import org.springframework.shell.*; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; /** * The standard implementation of {@link MethodTargetRegistrar} for new shell @@ -61,10 +62,10 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { if (keys.length == 0) { keys = new String[] { Utils.unCamelify(method.getName()) }; } - String group = shellMapping.group(); + String group = getOrInferGroup(method); for (String key : keys) { Supplier availabilityIndicator = findAvailabilityIndicator(keys, bean, method); - MethodTarget target = new MethodTarget(method, bean, shellMapping.value(), group, availabilityIndicator); + MethodTarget target = new MethodTarget(method, bean, new Command.Help(shellMapping.value(), group), availabilityIndicator); registry.register(key, target); commands.put(key, target); } @@ -72,6 +73,32 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar { } } + /** + * Gets the group from the following places, in order:
    + *
  • explicit annotation at the method level
  • + *
  • explicit annotation at the class level
  • + *
  • explicit annotation at the package level
  • + *
  • implicit from the class name
  • + *
+ */ + private String getOrInferGroup(Method method) { + ShellMethod methodAnn = AnnotationUtils.getAnnotation(method, ShellMethod.class); + if (!methodAnn.group().equals(ShellMethod.INHERITED)) { + return methodAnn.group(); + } + Class clazz = method.getDeclaringClass(); + ShellCommandGroup classAnn = AnnotationUtils.getAnnotation(clazz, ShellCommandGroup.class); + if (classAnn != null && !classAnn.value().equals(ShellCommandGroup.INHERIT_AND_INFER)) { + return classAnn.value(); + } + ShellCommandGroup packageAnn = AnnotationUtils.getAnnotation(clazz.getPackage(), ShellCommandGroup.class); + if (packageAnn != null && !packageAnn.value().equals(ShellCommandGroup.INHERIT_AND_INFER)) { + return packageAnn.value(); + } + // Shameful copy/paste from https://stackoverflow.com/questions/7593969/regex-to-split-camelcase-or-titlecase-advanced + return StringUtils.arrayToDelimitedString(clazz.getSimpleName().split("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"), " "); + } + /** * Tries to locate an availability indicator (a no-arg method that returns * {@link Availability}) for the given command method. The following are tried in order diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java index b5cc2198..66bd9f79 100644 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java @@ -16,8 +16,7 @@ package org.springframework.shell.standard; -import org.hamcrest.Matchers; -import org.junit.Assert; +import org.assertj.core.api.Assertions; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -26,6 +25,9 @@ import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.shell.Availability; import org.springframework.shell.ConfigurableCommandRegistry; import org.springframework.shell.MethodTarget; +import org.springframework.shell.standard.test1.GroupOneCommands; +import org.springframework.shell.standard.test2.GroupTwoCommands; +import org.springframework.shell.standard.test2.GroupThreeCommands; import org.springframework.util.ReflectionUtils; import static org.hamcrest.Matchers.hasEntry; @@ -33,6 +35,8 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertThat; +import java.util.Map; + /** * Unit tests for {@link StandardMethodTargetRegistrar}. * @@ -242,4 +246,20 @@ public class StandardMethodTargetRegistrarTest { } } -} \ No newline at end of file + @Test + public void testGrouping() { + ApplicationContext context = new AnnotationConfigApplicationContext(GroupOneCommands.class, + GroupTwoCommands.class, GroupThreeCommands.class); + registrar.setApplicationContext(context); + registrar.register(registry); + + Map commands = registry.listCommands(); + Assertions.assertThat(commands.get("explicit1").getGroup()).isEqualTo("Explicit Group Method Level 1"); + Assertions.assertThat(commands.get("explicit2").getGroup()).isEqualTo("Explicit Group Method Level 2"); + Assertions.assertThat(commands.get("explicit3").getGroup()).isEqualTo("Explicit Group Method Level 3"); + Assertions.assertThat(commands.get("implicit1").getGroup()).isEqualTo("Implicit Group Package Level 1"); + Assertions.assertThat(commands.get("implicit2").getGroup()).isEqualTo("Group Two Commands"); + Assertions.assertThat(commands.get("implicit3").getGroup()).isEqualTo("Explicit Group 3 Class Level"); + } + +} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/GroupOneCommands.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/GroupOneCommands.java new file mode 100644 index 00000000..4cd22a7b --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/GroupOneCommands.java @@ -0,0 +1,35 @@ +/* + * 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 + * + * http://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.standard.test1; + +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; + +@ShellComponent +public class GroupOneCommands { + + @ShellMethod(value = "Do Something.", group = "Explicit Group Method Level 1") + public void explicit1() { + + } + + @ShellMethod(value = "Do Something Else") + public void implicit1() { + + } + +} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/package-info.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/package-info.java new file mode 100644 index 00000000..e1977186 --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test1/package-info.java @@ -0,0 +1,20 @@ +/* + * 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 + * + * http://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. + */ + +@ShellCommandGroup("Implicit Group Package Level 1") +package org.springframework.shell.standard.test1; + +import org.springframework.shell.standard.ShellCommandGroup; diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupThreeCommands.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupThreeCommands.java new file mode 100644 index 00000000..6f39752f --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupThreeCommands.java @@ -0,0 +1,37 @@ +/* + * 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 + * + * http://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.standard.test2; + +import org.springframework.shell.standard.ShellCommandGroup; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; + +@ShellComponent +@ShellCommandGroup("Explicit Group 3 Class Level") +public class GroupThreeCommands { + + @ShellMethod(value = "Do Something.", group = "Explicit Group Method Level 3") + public void explicit3() { + + } + + @ShellMethod(value = "Do Something Else") + public void implicit3() { + + } + +} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupTwoCommands.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupTwoCommands.java new file mode 100644 index 00000000..fd5b06ac --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/test2/GroupTwoCommands.java @@ -0,0 +1,35 @@ +/* + * 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 + * + * http://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.standard.test2; + +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; + +@ShellComponent +public class GroupTwoCommands { + + @ShellMethod(value = "Do Something.", group = "Explicit Group Method Level 2") + public void explicit2() { + + } + + @ShellMethod(value = "Do Something Else") + public void implicit2() { + + } + +}