From 8c031ba90edfae3d4c24e95699309b51124595bd Mon Sep 17 00:00:00 2001 From: Remko Popma Date: Mon, 11 Jun 2018 23:44:34 +0900 Subject: [PATCH] [#288] New feature: add support for command aliases Closes #288 --- RELEASE-NOTES.md | 29 +++++- src/main/java/picocli/CommandLine.java | 96 ++++++++++++++++--- src/test/java/picocli/CommandLineTest.java | 91 ++++++++++++++++++ src/test/java/picocli/HelpSubCommandTest.java | 57 +++++++++++ 4 files changed, 258 insertions(+), 15 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4ce1a99bc..19140d444 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -3,7 +3,11 @@ # Picocli 3.1.0 (UNRELEASED) The picocli community is pleased to announce picocli 3.1.0. -This release contains bugfixes and enhancements. +This release contains bugfixes and support for command aliases. + +Picocli has a new logo! Many thanks to [Reallinfo](https://github.com/reallinfo) for the design! + + This is the thirty-second public release. Picocli follows [semantic versioning](http://semver.org/). @@ -16,6 +20,28 @@ Picocli follows [semantic versioning](http://semver.org/). * [Potential breaking changes](#3.1.0-breaking-changes) ## New and Noteworthy +### Command Aliases +This release adds support for command aliases. + +```java +@Command(name = "top", subcommands = {SubCommand.class}, + description = "top level command") +static class TopLevelCommand { } + +@Command(name = "sub", aliases = {"s", "sb"}, + description = "I'm a subcommand") +static class SubCommand {} + +new CommandLine(new TopLevelCommand()).usage(System.out); +``` +The above would print the following usage help message: + +```text +Usage: top [COMMAND] +top level command +Commands: + sub, s, sb I'm a subcommand +``` ## Promoted Features Promoted features are features that were incubating in previous versions of picocli but are now supported and subject to backwards compatibility. @@ -23,6 +49,7 @@ Promoted features are features that were incubating in previous versions of pico No features have been promoted in this picocli release. ## Fixed issues +- [#288] New feature: add support for command aliases. - [#383] Enhancement: [Reallinfo](https://github.com/reallinfo) designed the new picocli logo. Amazing work, many thanks! - [#388] Bugfix: Prevent AnnotationFormatError "Duplicate annotation for class" with @PicocliScript when the script contains classes. Thanks to [Bradford Powell](https://github.com/bpow) for the bug report. diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index 2843d88c6..145524d02 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -229,7 +229,23 @@ public Map getMixins() { * @see Command#subcommands() */ public CommandLine addSubcommand(String name, Object command) { + return addSubcommand(name, command, new String[0]); + } + + /** Registers a subcommand with the specified name and all specified aliases. See also {@link #addSubcommand(String, Object)}. + * + * + * @param name the string to recognize on the command line as a subcommand + * @param command the object to initialize with command line arguments following the subcommand name. + * This may be a {@code CommandLine} instance with its own (nested) subcommands + * @param aliases zero or more alias names that are also recognized on the command line as this subcommand + * @return this CommandLine object, to allow method chaining + * @since 3.1 + * @see #addSubcommand(String, Object) + */ + public CommandLine addSubcommand(String name, Object command, String... aliases) { CommandLine subcommandLine = toCommandLine(command, factory); + subcommandLine.getCommandSpec().aliases(aliases); getCommandSpec().addSubcommand(name, subcommandLine); CommandLine.Model.CommandReflection.initParentCommand(subcommandLine.getCommandSpec().userObject(), getCommandSpec().userObject()); return this; @@ -2156,6 +2172,11 @@ public CommandLine setExpandAtFiles(boolean expandAtFiles) { * @see Help#commandName() */ String name() default "
"; + /** Alternative command names by which this subcommand is recognized on the command line. + * @return one or more alternative command names + * @since 3.1 */ + String[] aliases() default {}; + /** A list of classes to instantiate and register as subcommands. When registering subcommands declaratively * like this, you don't need to call the {@link CommandLine#addSubcommand(String, Object)} method. For example, this: *
@@ -2674,6 +2695,7 @@ public static class CommandSpec {
             private CommandSpec parent;
     
             private String name;
+            private String[] aliases = {};
             private Boolean isHelpCommand;
             private IVersionProvider versionProvider;
             private String[] version;
@@ -2789,12 +2811,18 @@ public CommandSpec addSubcommand(String name, CommandSpec subcommand) {
     
             /** Adds the specified subcommand with the specified name.
              * @param name subcommand name - when this String is encountered in the command line arguments the subcommand is invoked
-             * @param commandLine the subcommand to envoke when the name is encountered on the command line
+             * @param subCommandLine the subcommand to envoke when the name is encountered on the command line
              * @return this {@code CommandSpec} object for method chaining */
-            public CommandSpec addSubcommand(String name, CommandLine commandLine) {
-                commands.put(name, commandLine);
-                if (commandLine.getCommandSpec().name == null) { commandLine.getCommandSpec().name(name); }
-                commandLine.getCommandSpec().parent(this);
+            public CommandSpec addSubcommand(String name, CommandLine subCommandLine) {
+                CommandLine previous = commands.put(name, subCommandLine);
+                if (previous != null && previous != subCommandLine) { throw new InitializationException("Another subcommand named '" + name + "' already exists for command '" + this.name() + "'"); }
+                CommandSpec subSpec = subCommandLine.getCommandSpec();
+                if (subSpec.name == null) { subSpec.name(name); }
+                subSpec.parent(this);
+                for (String alias : subSpec.aliases()) {
+                    previous = commands.put(alias, subCommandLine);
+                    if (previous != null && previous != subCommandLine) { throw new InitializationException("Alias '" + alias + "' for subcommand '" + name + "' is already used by another subcommand of '" + this.name() + "'"); }
+                }
                 return this;
             }
     
@@ -2910,6 +2938,10 @@ public CommandSpec addMixin(String name, CommandSpec mixin) {
              * @see #qualifiedName() */
             public String name() { return (name == null) ? DEFAULT_COMMAND_NAME : name; }
 
+            /** Returns the alias command names of this subcommand.
+             * @since 3.1 */
+            public String[] aliases() { return aliases.clone(); }
+
             /** Returns the String to use as the program name in the synopsis line of the help message:
              * this command's {@link #name() name}, preceded by the qualified name of the parent command, if any.
              * {@link #DEFAULT_COMMAND_NAME} by default, initialized from {@link Command#name()} if defined.
@@ -2951,12 +2983,16 @@ public String[] version() {
 
             /** Returns a string representation of this command, used in error messages and trace messages. */
             public String toString() { return toString; }
-    
-    
+
             /** Sets the String to use as the program name in the synopsis line of the help message.
              * @return this CommandSpec for method chaining */
             public CommandSpec name(String name) { this.name = name; return this; }
 
+            /** Sets the alternative names by which this subcommand is recognized on the command line.
+             * @return this CommandSpec for method chaining
+             * @since 3.1 */
+            public CommandSpec aliases(String... aliases) { this.aliases = aliases == null ? new String[0] : aliases.clone(); return this; }
+
             /** Sets version information literals for this command, to print to the console when the user specifies an
              * {@linkplain OptionSpec#versionHelp() option} to request version help. Only used if no {@link #versionProvider() versionProvider} is set.
              * @return this CommandSpec for method chaining */
@@ -2999,7 +3035,7 @@ public CommandSpec mixinStandardHelpOptions(boolean newValue) {
     
             void initName(String value)                 { if (initializable(name, value, DEFAULT_COMMAND_NAME))                           {name = value;} }
             void initHelpCommand(boolean value)         { if (initializable(isHelpCommand, value, DEFAULT_IS_HELP_COMMAND))               {isHelpCommand = value;} }
-            void initVersion(String[] value)            { if (initializable(version, value, UsageMessageSpec.DEFAULT_MULTI_LINE))                          {version = value.clone();} }
+            void initVersion(String[] value)            { if (initializable(version, value, UsageMessageSpec.DEFAULT_MULTI_LINE))         {version = value.clone();} }
             void initVersionProvider(IVersionProvider value) { if (versionProvider == null) { versionProvider = value; } }
             void initVersionProvider(Class value, IFactory factory) {
                 if (initializable(versionProvider, value, NoVersionProvider.class)) { versionProvider = (DefaultFactory.createVersionProvider(factory, value)); }
@@ -4129,6 +4165,7 @@ private static boolean updateCommandAttributes(Class cls, CommandSpec command
                 if (!cls.isAnnotationPresent(Command.class)) { return false; }
 
                 Command cmd = cls.getAnnotation(Command.class);
+                commandSpec.aliases(cmd.aliases());
                 initSubcommands(cmd, commandSpec, factory);
 
                 commandSpec.parser().initSeparator(cmd.separator());
@@ -5949,6 +5986,7 @@ public static class Help {
         private final CommandSpec commandSpec;
         private final ColorScheme colorScheme;
         private final Map commands = new LinkedHashMap();
+        private List aliases = Collections.emptyList();
 
         private IParamLabelRenderer parameterLabelRenderer;
 
@@ -5980,11 +6018,16 @@ public Help(Object command, Ansi ansi) {
          * @param colorScheme the color scheme to use */
         public Help(CommandSpec commandSpec, ColorScheme colorScheme) {
             this.commandSpec = Assert.notNull(commandSpec, "commandSpec");
-            this.addAllSubcommands(commandSpec.subcommands());
+            this.aliases = new ArrayList(Arrays.asList(commandSpec.aliases()));
+            this.aliases.add(0, commandSpec.name());
             this.colorScheme = Assert.notNull(colorScheme, "colorScheme").applySystemProperties();
             parameterLabelRenderer = createDefaultParamLabelRenderer(); // uses help separator
+
+            this.addAllSubcommands(commandSpec.subcommands());
         }
 
+        Help withCommandNames(List aliases) { this.aliases = aliases; return this; }
+
         /** Returns the {@code CommandSpec} model that this Help was constructed with.
          * @since 3.0 */
         CommandSpec commandSpec() { return commandSpec; }
@@ -6006,11 +6049,28 @@ public Help(CommandSpec commandSpec, ColorScheme colorScheme) {
          */
         public Help addAllSubcommands(Map commands) {
             if (commands != null) {
+                // first collect aliases
+                Map> done = new IdentityHashMap>();
+                for (CommandLine cmd : commands.values()) {
+                    if (!done.containsKey(cmd)) {
+                        done.put(cmd, new ArrayList(Arrays.asList(cmd.commandSpec.aliases())));
+                    }
+                }
+                // then loop over all names that the command was registered with and add this name to the front of the list (if it isn't already in the list)
+                for (Map.Entry entry : commands.entrySet()) {
+                    List aliases = done.get(entry.getValue());
+                    if (!aliases.contains(entry.getKey())) { aliases.add(0, entry.getKey()); }
+                }
+                // The aliases list for each command now has at least one entry, with the main name at the front.
+                // Now we loop over the commands in the order that they were registered on their parent command.
                 for (Map.Entry entry : commands.entrySet()) {
                     // not registering hidden commands is easier than suppressing display in Help.commandList():
                     // if all subcommands are hidden, help should not show command list header
                     if (!entry.getValue().getCommandSpec().usageMessage().hidden()) {
-                        addSubcommand(entry.getKey(), entry.getValue());
+                        List aliases = done.remove(entry.getValue());
+                        if (aliases != null) { // otherwise we already processed this command by another alias
+                            addSubcommand(aliases, entry.getValue());
+                        }
                     }
                 }
             }
@@ -6018,11 +6078,12 @@ public Help addAllSubcommands(Map commands) {
         }
 
         /** Registers the specified subcommand with this Help.
-         * @param commandName the name of the subcommand to display in the usage message
+         * @param commandNames the name and aliases of the subcommand to display in the usage message
          * @param commandLine the {@code CommandLine} object to get more information from
          * @return this Help instance (for method chaining) */
-        Help addSubcommand(String commandName, CommandLine commandLine) {
-            commands.put(commandName, new Help(commandLine.commandSpec));
+        Help addSubcommand(List commandNames, CommandLine commandLine) {
+            String all = commandNames.toString();
+            commands.put(all.substring(1, all.length() - 1), new Help(commandLine.commandSpec, colorScheme).withCommandNames(commandNames));
             return this;
         }
 
@@ -6403,7 +6464,7 @@ public String commandList() {
                 CommandSpec command = help.commandSpec;
                 String header = command.usageMessage().header() != null && command.usageMessage().header().length > 0 ? command.usageMessage().header()[0]
                         : (command.usageMessage().description() != null && command.usageMessage().description().length > 0 ? command.usageMessage().description()[0] : "");
-                textTable.addRowValues(colorScheme.commandText(entry.getKey()), ansi().new Text(header));
+                textTable.addRowValues(help.commandNamesText(), ansi().new Text(header));
             }
             return textTable.toString();
         }
@@ -6412,6 +6473,13 @@ private static int maxLength(Collection any) {
             Collections.sort(strings, Collections.reverseOrder(Help.shortestFirst()));
             return strings.get(0).length();
         }
+        private Text commandNamesText() {
+            Text result = colorScheme.commandText(aliases.get(0));
+            for (int i = 1; i < aliases.size(); i++) {
+                result = result.concat(", ").concat(colorScheme.commandText(aliases.get(i)));
+            }
+            return result;
+        }
         private static String join(String[] names, int offset, int length, String separator) {
             if (names == null) { return ""; }
             StringBuilder result = new StringBuilder();
diff --git a/src/test/java/picocli/CommandLineTest.java b/src/test/java/picocli/CommandLineTest.java
index 04d8a38c1..50dad2ad9 100644
--- a/src/test/java/picocli/CommandLineTest.java
+++ b/src/test/java/picocli/CommandLineTest.java
@@ -2087,6 +2087,21 @@ private static CommandLine createNestedCommand() {
         return commandLine;
     }
 
+    private static CommandLine createNestedCommandWithAliases() {
+        CommandLine commandLine = new CommandLine(new MainCommand());
+        commandLine
+                .addSubcommand("cmd1", new CommandLine(new ChildCommand1())
+                        .addSubcommand("sub11", new GrandChild1Command1(), "sub11alias1", "sub11alias2")
+                        .addSubcommand("sub12", new GrandChild1Command2(), "sub12alias1", "sub12alias2")
+                        , "cmd1alias1", "cmd1alias2")
+                .addSubcommand("cmd2", new CommandLine(new ChildCommand2())
+                        .addSubcommand("sub21", new GrandChild2Command1(), "sub21alias1", "sub21alias2")
+                        .addSubcommand("sub22", new CommandLine(new GrandChild2Command2())
+                                .addSubcommand("sub22sub1", new GreatGrandChild2Command2_1(), "sub22sub1alias1", "sub22sub1alias2"), "sub22alias1", "sub22alias2")
+                        , "cmd2alias1", "cmd2alias2");
+        return commandLine;
+    }
+
     @Test
     public void testCommandListReturnsOnlyCommandsRegisteredOnInstance() {
         CommandLine commandLine = createNestedCommand();
@@ -2097,6 +2112,35 @@ public void testCommandListReturnsOnlyCommandsRegisteredOnInstance() {
         assertTrue("cmd2", commandMap.get("cmd2").getCommand() instanceof ChildCommand2);
     }
 
+    @Test
+    public void testCommandListReturnsAliases() {
+        CommandLine commandLine = createNestedCommandWithAliases();
+
+        Map commandMap = commandLine.getSubcommands();
+        assertEquals(6, commandMap.size());
+        assertEquals(setOf("cmd1", "cmd1alias1", "cmd1alias2", "cmd2", "cmd2alias1", "cmd2alias2"), commandMap.keySet());
+        assertTrue("cmd1", commandMap.get("cmd1").getCommand() instanceof ChildCommand1);
+        assertSame(commandMap.get("cmd1"), commandMap.get("cmd1alias1"));
+        assertSame(commandMap.get("cmd1"), commandMap.get("cmd1alias2"));
+
+        assertTrue("cmd2", commandMap.get("cmd2").getCommand() instanceof ChildCommand2);
+        assertSame(commandMap.get("cmd2"), commandMap.get("cmd2alias1"));
+        assertSame(commandMap.get("cmd2"), commandMap.get("cmd2alias2"));
+
+        CommandLine cmd2 = commandMap.get("cmd2");
+        Map subMap = cmd2.getSubcommands();
+
+        assertTrue("cmd2", subMap.get("sub21").getCommand() instanceof GrandChild2Command1);
+        assertSame(subMap.get("sub21"), subMap.get("sub21alias1"));
+        assertSame(subMap.get("sub21"), subMap.get("sub21alias2"));
+    }
+
+    public static  Set setOf(T... elements) {
+        Set result = new HashSet();
+        for (T t : elements) { result.add(t); }
+        return result;
+    }
+
     @Test
     public void testParseNestedSubCommands() {
         // valid
@@ -2182,6 +2226,53 @@ public void testParseNestedSubCommands() {
         }
     }
 
+    @Test
+    public void testParseNestedSubCommandsWithAliases() {
+        // valid
+        List main = createNestedCommandWithAliases().parse("cmd1alias1");
+        assertEquals(2, main.size());
+        assertFalse(((MainCommand)   main.get(0).getCommand()).a);
+        assertFalse(((ChildCommand1) main.get(1).getCommand()).b);
+
+        List mainWithOptions = createNestedCommandWithAliases().parse("-a", "cmd1alias2", "-b");
+        assertEquals(2, mainWithOptions.size());
+        assertTrue(((MainCommand)   mainWithOptions.get(0).getCommand()).a);
+        assertTrue(((ChildCommand1) mainWithOptions.get(1).getCommand()).b);
+
+        List sub1 = createNestedCommandWithAliases().parse("cmd1", "sub11");
+        assertEquals(3, sub1.size());
+        assertFalse(((MainCommand)         sub1.get(0).getCommand()).a);
+        assertFalse(((ChildCommand1)       sub1.get(1).getCommand()).b);
+        assertFalse(((GrandChild1Command1) sub1.get(2).getCommand()).d);
+
+        List sub1WithOptions = createNestedCommandWithAliases().parse("-a", "cmd1alias1", "-b", "sub11alias2", "-d");
+        assertEquals(3, sub1WithOptions.size());
+        assertTrue(((MainCommand)         sub1WithOptions.get(0).getCommand()).a);
+        assertTrue(((ChildCommand1)       sub1WithOptions.get(1).getCommand()).b);
+        assertTrue(((GrandChild1Command1) sub1WithOptions.get(2).getCommand()).d);
+
+        // sub12 is not nested under sub11 so is not recognized
+        try {
+            createNestedCommandWithAliases().parse("cmd1alias1", "sub11alias1", "sub12alias1");
+            fail("Expected exception for sub12alias1");
+        } catch (UnmatchedArgumentException ex) {
+            assertEquals("Unmatched argument [sub12alias1]", ex.getMessage());
+        }
+        List sub22sub1 = createNestedCommandWithAliases().parse("cmd2alias1", "sub22alias2", "sub22sub1alias1");
+        assertEquals(4, sub22sub1.size());
+        assertFalse(((MainCommand)                sub22sub1.get(0).getCommand()).a);
+        assertFalse(((ChildCommand2)              sub22sub1.get(1).getCommand()).c);
+        assertFalse(((GrandChild2Command2)        sub22sub1.get(2).getCommand()).g);
+        assertFalse(((GreatGrandChild2Command2_1) sub22sub1.get(3).getCommand()).h);
+
+        List sub22sub1WithOptions = createNestedCommandWithAliases().parse("-a", "cmd2alias1", "-c", "sub22alias1", "-g", "sub22sub1alias2", "-h");
+        assertEquals(4, sub22sub1WithOptions.size());
+        assertTrue(((MainCommand)                sub22sub1WithOptions.get(0).getCommand()).a);
+        assertTrue(((ChildCommand2)              sub22sub1WithOptions.get(1).getCommand()).c);
+        assertTrue(((GrandChild2Command2)        sub22sub1WithOptions.get(2).getCommand()).g);
+        assertTrue(((GreatGrandChild2Command2_1) sub22sub1WithOptions.get(3).getCommand()).h);
+    }
+
     @Test
     public void testParseNestedSubCommandsAllowingUnmatchedArguments() {
         setTraceLevel("OFF");
diff --git a/src/test/java/picocli/HelpSubCommandTest.java b/src/test/java/picocli/HelpSubCommandTest.java
index 32d35e04e..90b66f0af 100644
--- a/src/test/java/picocli/HelpSubCommandTest.java
+++ b/src/test/java/picocli/HelpSubCommandTest.java
@@ -2,6 +2,9 @@
 
 import org.junit.Test;
 
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+
 import static org.junit.Assert.assertEquals;
 import static picocli.CommandLine.*;
 import static picocli.CommandLine.Model.*;
@@ -51,4 +54,58 @@ public void testShowAbbreviatedSynopsisUsageWithCommandOption() {
         assertEquals(expected, actual);
     }
 
+    @Command(name = "top", aliases = {"t", "tp"}, subcommands = {SubCommand1.class, SubCommand2.class}, description = "top level command")
+    static class TopLevelCommand { }
+
+    @Command(name = "sub", aliases = {"s", "sb"}, subcommands = {SubSubCommand.class}, description = "I'm subcommand No. 1!")
+    static class SubCommand1 {}
+
+    @Command(name = "sub2", aliases = {"s2", "sb2"}, subcommands = {SubSubCommand.class}, description = "I'm subcommand 2 but pretty good still")
+    static class SubCommand2 {}
+
+    @Command(name = "subsub", aliases = {"ss", "sbsb"}, description = "I'm like a 3rd rate command but great bang for your buck")
+    static class SubSubCommand {}
+
+    @Test
+    public void testCommandAliasRegistrationByAnnotation() {
+        CommandLine commandLine = new CommandLine(new TopLevelCommand());
+        assertEquals(CommandLineTest.setOf("sub", "s", "sb", "sub2", "s2", "sb2"), commandLine.getSubcommands().keySet());
+
+        CommandLine sub1 = commandLine.getSubcommands().get("sub");
+        assertEquals(CommandLineTest.setOf("subsub", "ss", "sbsb"), sub1.getSubcommands().keySet());
+
+        CommandLine sub2 = commandLine.getSubcommands().get("sub2");
+        assertEquals(CommandLineTest.setOf("subsub", "ss", "sbsb"), sub2.getSubcommands().keySet());
+    }
+
+    @Test
+    public void testCommandAliasAnnotationUsageHelp() {
+        CommandLine commandLine = new CommandLine(new TopLevelCommand());
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        commandLine.usage(new PrintStream(baos), CommandLine.Help.defaultColorScheme(Help.Ansi.ON).commands(Help.Ansi.Style.underline)); // add underline
+        
+        String expected = Help.Ansi.ON.new Text(String.format("" +
+                "Usage: @|bold,underline top|@ [COMMAND]%n" +
+                "top level command%n" +
+                "Commands:%n" +
+                "  @|bold,underline sub|@, @|bold,underline s|@, @|bold,underline sb|@     I'm subcommand No. 1!%n" +
+                "  @|bold,underline sub2|@, @|bold,underline s2|@, @|bold,underline sb2|@  I'm subcommand 2 but pretty good still%n")).toString();
+        assertEquals(expected, baos.toString());
+    }
+
+    @Test
+    public void testCommandAliasAnnotationSubcommandUsageHelp() {
+        CommandLine commandLine = new CommandLine(new TopLevelCommand());
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        commandLine.getSubcommands().get("sub").usage(new PrintStream(baos), CommandLine.Help.defaultColorScheme(Help.Ansi.ON).commands(Help.Ansi.Style.underline)); // add underline
+
+        String expected = Help.Ansi.ON.new Text(String.format("" +
+                "Usage: @|bold,underline top sub|@ [COMMAND]%n" +
+                "I'm subcommand No. 1!%n" +
+                "Commands:%n" +
+                "  @|bold,underline subsub|@, @|bold,underline ss|@, @|bold,underline sbsb|@  I'm like a 3rd rate command but great bang for your buck%n")).toString();
+        assertEquals(expected, baos.toString());
+    }
 }
\ No newline at end of file