diff --git a/src/main/java/picocli/AutoComplete.java b/src/main/java/picocli/AutoComplete.java index 5c6b224dd..3d4bd7fe1 100644 --- a/src/main/java/picocli/AutoComplete.java +++ b/src/main/java/picocli/AutoComplete.java @@ -21,15 +21,10 @@ import java.io.PrintWriter; import java.io.Writer; import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.concurrent.Callable; +import picocli.AutoComplete.TypeCompletionRegistry.CompletionKind; import picocli.CommandLine.*; import picocli.CommandLine.Model.PositionalParamSpec; import picocli.CommandLine.Model.ArgSpec; @@ -149,6 +144,14 @@ private static class App implements Callable { "as the completion script.") boolean writeCommandScript; + @Option(names = {"--pathCompletionTypes"}, split=",", description = "Comma-separated list of fully " + + "qualified custom types for which to delegate to built-in path name completion.") + List pathCompletionTypes = new ArrayList(); + + @Option(names = {"--hostCompletionTypes"}, split=",", description = "Comma-separated list of fully " + + "qualified custom types for which to delegate to built-in host name completion.") + List hostCompletionTypes = new ArrayList(); + @Option(names = {"-f", "--force"}, description = "Overwrite existing script files.") boolean overwriteIfExists; @@ -162,7 +165,7 @@ public Integer call() throws Exception { Class cls = Class.forName(commandLineFQCN); Object instance = factory.create(cls); CommandLine commandLine = new CommandLine(instance, factory); - + TypeCompletionRegistry registry = typeCompletionRegistry(pathCompletionTypes, hostCompletionTypes); if (commandName == null) { commandName = commandLine.getCommandName(); //new CommandLine.Help(commandLine.commandDescriptor).commandName; if (CommandLine.Help.DEFAULT_COMMAND_NAME.equals(commandName)) { @@ -183,10 +186,27 @@ public Integer call() throws Exception { return EXIT_CODE_COMPLETION_SCRIPT_EXISTS; } - AutoComplete.bash(commandName, autoCompleteScript, commandScript, commandLine); + AutoComplete.bash(commandName, autoCompleteScript, commandScript, commandLine, registry); return EXIT_CODE_SUCCESS; } + private static TypeCompletionRegistry typeCompletionRegistry(List pathCompletionTypes, List hostCompletionTypes) + throws ClassNotFoundException { + TypeCompletionRegistry registry = new TypeCompletionRegistry(); + addToRegistry(registry, pathCompletionTypes, CompletionKind.FILE); + addToRegistry(registry, hostCompletionTypes, CompletionKind.HOST); + return registry; + } + + private static void addToRegistry(TypeCompletionRegistry registry, List types, + CompletionKind kind) throws ClassNotFoundException { + for (String type : types) { + // TODO implement error handling if the class is not on the classpath + Class cls = Class.forName(type); + registry.registerType(cls, kind); + } + } + private boolean checkExists(final File file) { if (file.exists()) { PrintWriter err = spec.commandLine().getErr(); @@ -198,6 +218,90 @@ private boolean checkExists(final File file) { } } + /** + * Meta-information about FQCN to {@link CompletionKind} mappings. + */ + public static class TypeCompletionRegistry { + + /** + * The different kinds of supported auto completion mechanisms. + */ + public enum CompletionKind { + /** + * Auto completion resolved against paths on the file system. + */ + FILE, + /** + * Auto completion resolved against known hosts. + */ + HOST, + /** + * No auto-completion. + */ + NONE + } + + private final Map registry = new HashMap(); + + public TypeCompletionRegistry() { + registerDefaultPathCompletionTypes(); + registerDefaultHostCompletionTypes(); + } + + private void registerDefaultPathCompletionTypes() { + registry.put(File.class.getName(), CompletionKind.FILE); + registry.put("java.nio.file.Path", CompletionKind.FILE); + } + + private void registerDefaultHostCompletionTypes() { + registry.put(InetAddress.class.getName(), CompletionKind.HOST); + } + + /** + *

Register the type {@code type} to the given {@link CompletionKind}.

+ *

Built-in supported types to {@link CompletionKind} mappings are: + *

    + *
  • {@link CompletionKind#FILE}: + *
      + *
    • {@link java.io.File}
    • + *
    • {@link java.nio.file.Path}
    • + *
    + *
  • + *
  • {@link CompletionKind#HOST}: + *
      + *
    • {@link java.net.InetAddress}
    • + *
    + *
  • + *
+ *

+ * + * @param type the type to register + * @param kind the kind of completion to apply for this type + * @return this {@link TypeCompletionRegistry} object, to allow method chaining + * @see #forType(Class) + */ + public TypeCompletionRegistry registerType(Class type, CompletionKind kind) { + registry.put(type.getName(), kind); + return this; + } + + /** + * Returns the {@link CompletionKind} for the requested {@code type} or {@link CompletionKind#NONE} if no + * mapping exists. + * @param type the type to retrieve the {@link CompletionKind} for. + * @return the {@link CompletionKind} for the requested {@code type} or {@link CompletionKind#NONE} if no + * mapping exists. + * @see #registerType(Class, CompletionKind) + */ + public CompletionKind forType(Class type) { + CompletionKind kind = registry.get(type.getName()); + if (kind == null) { + return CompletionKind.NONE; + } + return kind; + } + } + /** * Command that generates a Bash/ZSH completion script for its top-level command. *

@@ -288,7 +392,7 @@ private static class CommandDescriptor { final String commandName; final CommandLine commandLine; final CommandLine parent; - + CommandDescriptor(String functionName, String commandName, CommandLine commandLine, CommandLine parent) { this.functionName = functionName; this.commandName = commandName; @@ -433,7 +537,23 @@ private static class CommandDescriptor { * @throws IOException if a problem occurred writing to the specified files */ public static void bash(String scriptName, File out, File command, CommandLine commandLine) throws IOException { - String autoCompleteScript = bash(scriptName, commandLine); + bash(scriptName, out, command, commandLine, new TypeCompletionRegistry()); + } + + /** + * Generates source code for an autocompletion bash script for the specified picocli-based application, + * and writes this script to the specified {@code out} file, and optionally writes an invocation script + * to the specified {@code command} file. + * @param scriptName the name of the command to generate a bash autocompletion script for + * @param commandLine the {@code CommandLine} instance for the command line application + * @param out the file to write the autocompletion bash script source code to + * @param command the file to write a helper script to that invokes the command, or {@code null} if no helper script file should be written + * @param registry the custom types to completions kind registry + * @throws IOException if a problem occurred writing to the specified files + */ + public static void bash(String scriptName, File out, File command, CommandLine commandLine, + TypeCompletionRegistry registry) throws IOException { + String autoCompleteScript = bash(scriptName, commandLine, registry); Writer completionWriter = null; Writer scriptWriter = null; try { @@ -462,6 +582,17 @@ public static void bash(String scriptName, File out, File command, CommandLine c * @return source code for an autocompletion bash script */ public static String bash(String scriptName, CommandLine commandLine) { + return bash(scriptName, commandLine, new TypeCompletionRegistry()); + } + + /** + * Generates and returns the source code for an autocompletion bash script for the specified picocli-based application. + * @param scriptName the name of the command to generate a bash autocompletion script for + * @param commandLine the {@code CommandLine} instance for the command line application + * @param registry the custom types to completions kind registry + * @return source code for an autocompletion bash script + */ + public static String bash(String scriptName, CommandLine commandLine, TypeCompletionRegistry registry) { if (scriptName == null) { throw new NullPointerException("scriptName"); } if (commandLine == null) { throw new NullPointerException("commandLine"); } StringBuilder result = new StringBuilder(); @@ -472,7 +603,8 @@ public static String bash(String scriptName, CommandLine commandLine) { for (CommandDescriptor descriptor : hierarchy) { if (descriptor.commandLine.getCommandSpec().usageMessage().hidden()) { continue; } // #887 skip hidden subcommands - result.append(generateFunctionForCommand(descriptor.functionName, descriptor.commandName, descriptor.commandLine)); + result.append(generateFunctionForCommand(descriptor.functionName, descriptor.commandName, + descriptor.commandLine, registry)); } result.append(format(SCRIPT_FOOTER, scriptName)); return result.toString(); @@ -583,7 +715,8 @@ private static String concat(String infix, List values, T la return sb.append(normalize.apply(lastValue)).toString(); } - private static String generateFunctionForCommand(String functionName, String commandName, CommandLine commandLine) { + private static String generateFunctionForCommand(String functionName, String commandName, CommandLine commandLine, + TypeCompletionRegistry registry) { String FUNCTION_HEADER = "" + "\n" + "# Generates completions for the options and subcommands of the `%s` %scommand.\n" + @@ -651,7 +784,7 @@ private static String generateFunctionForCommand(String functionName, String com // sql.Types? // Now generate the "case" switches for the options whose arguments we can generate completions for - buff.append(generateOptionsSwitch(argOptionFields)); + buff.append(generateOptionsSwitch(registry, argOptionFields)); // Generate completion lists for positional params with a known set of valid values (including java enums) for (PositionalParamSpec f : commandSpec.positionalParameters()) { @@ -660,7 +793,7 @@ private static String generateFunctionForCommand(String functionName, String com } } - String paramsCases = generatePositionalParamsCases(commandSpec.positionalParameters(), "", "${curr_word}"); + String paramsCases = generatePositionalParamsCases(registry, commandSpec.positionalParameters(), "", "${curr_word}"); String posParamsFooter = ""; if (paramsCases.length() > 0) { String POSITIONAL_PARAMS_FOOTER = "" + @@ -696,7 +829,8 @@ private static List extract(Iterable generator) { return result; } - private static String generatePositionalParamsCases(List posParams, String indent, String currWord) { + private static String generatePositionalParamsCases( + TypeCompletionRegistry registry, List posParams, String indent, String currWord) { StringBuilder buff = new StringBuilder(1024); for (PositionalParamSpec param : posParams) { if (param.hidden()) { continue; } // #887 skip hidden params @@ -711,11 +845,11 @@ private static String generatePositionalParamsCases(List po if (param.completionCandidates() != null) { buff.append(format("%s %s (( currIndex >= %d && currIndex <= %d )); then\n", indent, ifOrElif, min, max)); buff.append(format("%s positionals=$( compgen -W \"$%s_pos_param_args\" -- \"%s\" )\n", indent, paramName, currWord)); - } else if (type.equals(File.class) || "java.nio.file.Path".equals(type.getName())) { + } else if (registry.forType(type) == CompletionKind.FILE) { buff.append(format("%s %s (( currIndex >= %d && currIndex <= %d )); then\n", indent, ifOrElif, min, max)); buff.append(format("%s compopt -o filenames\n", indent)); buff.append(format("%s positionals=$( compgen -f -- \"%s\" ) # files\n", indent, currWord)); - } else if (type.equals(InetAddress.class)) { + } else if (registry.forType(type) == CompletionKind.HOST) { buff.append(format("%s %s (( currIndex >= %d && currIndex <= %d )); then\n", indent, ifOrElif, min, max)); buff.append(format("%s compopt -o filenames\n", indent)); buff.append(format("%s positionals=$( compgen -A hostname -- \"%s\" )\n", indent, currWord)); @@ -727,8 +861,8 @@ private static String generatePositionalParamsCases(List po return buff.toString(); } - private static String generateOptionsSwitch(List argOptions) { - String optionsCases = generateOptionsCases(argOptions, "", "${curr_word}"); + private static String generateOptionsSwitch(TypeCompletionRegistry registry, List argOptions) { + String optionsCases = generateOptionsCases(registry, argOptions, "", "${curr_word}"); if (optionsCases.length() == 0) { return ""; @@ -742,7 +876,8 @@ private static String generateOptionsSwitch(List argOptions) { + " esac\n"; } - private static String generateOptionsCases(List argOptionFields, String indent, String currWord) { + private static String generateOptionsCases( + TypeCompletionRegistry registry, List argOptionFields, String indent, String currWord) { StringBuilder buff = new StringBuilder(1024); for (OptionSpec option : argOptionFields) { if (option.hidden()) { continue; } // #887 skip hidden options @@ -755,19 +890,19 @@ private static String generateOptionsCases(List argOptionFields, Str buff.append(format("%s COMPREPLY=( $( compgen -W \"${%s_option_args}\" -- \"%s\" ) )\n", indent, bashify(option.paramLabel()), currWord)); buff.append(format("%s return $?\n", indent)); buff.append(format("%s ;;\n", indent)); - } else if (type.equals(File.class) || "java.nio.file.Path".equals(type.getName())) { + } else if (registry.forType(type) == CompletionKind.FILE) { buff.append(format("%s %s)\n", indent, concat("|", option.names()))); // " -f|--file)\n" buff.append(format("%s compopt -o filenames\n", indent)); buff.append(format("%s COMPREPLY=( $( compgen -f -- \"%s\" ) ) # files\n", indent, currWord)); buff.append(format("%s return $?\n", indent)); buff.append(format("%s ;;\n", indent)); - } else if (type.equals(InetAddress.class)) { + } else if (registry.forType(type) == CompletionKind.HOST) { buff.append(format("%s %s)\n", indent, concat("|", option.names()))); // " -h|--host)\n" buff.append(format("%s compopt -o filenames\n", indent)); buff.append(format("%s COMPREPLY=( $( compgen -A hostname -- \"%s\" ) )\n", indent, currWord)); buff.append(format("%s return $?\n", indent)); buff.append(format("%s ;;\n", indent)); - } else { + } else if (registry.forType(type) == CompletionKind.NONE) { buff.append(format("%s %s)\n", indent, concat("|", option.names()))); // no completions available buff.append(format("%s return\n", indent)); buff.append(format("%s ;;\n", indent)); diff --git a/src/test/java/picocli/AutoCompleteTest.java b/src/test/java/picocli/AutoCompleteTest.java index de5a57b89..4e6e2ebca 100644 --- a/src/test/java/picocli/AutoCompleteTest.java +++ b/src/test/java/picocli/AutoCompleteTest.java @@ -15,6 +15,7 @@ */ package picocli; +import java.util.regex.Pattern; import org.hamcrest.CoreMatchers; import org.junit.Rule; import org.junit.Test; @@ -247,7 +248,11 @@ private static String toString(Object obj) { private static final String AUTO_COMPLETE_APP_USAGE = String.format("" + "Usage: picocli.AutoComplete [-fhVw] [-c=] [-n=]%n" + - " [-o=] [@...]%n" + + " [-o=]%n" + + " [--hostCompletionTypes=[,%n" + + " ...]]...%n" + + " [--pathCompletionTypes=[,%n" + + " ...]]... [@...]%n" + " %n" + "Generates a bash completion script for the specified command class.%n" + " [@...] One or more argument files containing options.%n" + @@ -272,6 +277,14 @@ private static String toString(Object obj) { " the current directory.%n" + " -w, --writeCommandScript Write a '' sample command script to%n" + " the same directory as the completion script.%n" + + " --pathCompletionTypes=[,...]%n" + + " Comma-separated list of fully qualified custom%n" + + " types for which to delegate to built-in path%n" + + " name completion.%n" + + " --hostCompletionTypes=[,...]%n" + + " Comma-separated list of fully qualified custom%n" + + " types for which to delegate to built-in host%n" + + " name completion.%n" + " -f, --force Overwrite existing script files.%n" + " -h, --help Show this help message and exit.%n" + " -V, --version Print version information and exit.%n" + @@ -761,7 +774,7 @@ private String expectedCompletionScriptForAutoCompleteApp() { "\n" + " local commands=\"\"\n" + " local flag_opts=\"-w --writeCommandScript -f --force -h --help -V --version\"\n" + - " local arg_opts=\"-c --factory -n --name -o --completionScript\"\n" + + " local arg_opts=\"-c --factory -n --name -o --completionScript --pathCompletionTypes --hostCompletionTypes\"\n" + "\n" + " compopt +o default\n" + "\n" + @@ -777,6 +790,12 @@ private String expectedCompletionScriptForAutoCompleteApp() { " COMPREPLY=( $( compgen -f -- \"${curr_word}\" ) ) # files\n" + " return $?\n" + " ;;\n" + + " --pathCompletionTypes)\n" + + " return\n" + + " ;;\n" + + " --hostCompletionTypes)\n" + + " return\n" + + " ;;\n" + " esac\n" + "\n" + " if [[ \"${curr_word}\" == -* ]]; then\n" + @@ -1822,5 +1841,146 @@ public void run() { static class NestedLevel1 implements Runnable { public void run() { } + + } + + @Test + public void testCustomCompletionOnCustomTypes() throws IOException { + final String commandName = "bestCommandEver"; + final File completionScript = new File(commandName + "_completion"); + if (completionScript.exists()) {assertTrue(completionScript.delete());} + completionScript.deleteOnExit(); + + AutoComplete.main(String.format("--name=%s", commandName), + String.format("--pathCompletionTypes=%s,%s", + PathCompletionCommand.CustomPath1.class.getName(), + PathCompletionCommand.CustomPath2.class.getName()), + String.format("--hostCompletionTypes=%s,%s", + PathCompletionCommand.CustomHost1.class.getName(), + PathCompletionCommand.CustomHost2.class.getName()), + "picocli.AutoCompleteTest$PathCompletionCommand"); + + byte[] completion = readBytes(completionScript); + assertTrue(completionScript.delete()); + + String expected = expectedCommandCompletion(commandName, + "function _picocli_bestCommandEver() {\n" + + " # Get completion data\n" + + " local curr_word=${COMP_WORDS[COMP_CWORD]}\n" + + " local prev_word=${COMP_WORDS[COMP_CWORD-1]}\n" + + "\n" + + " local commands=\"\"\n" + + " local flag_opts=\"\"\n" + + " local arg_opts=\"--file --custom-path-1 --custom-path-2 --custom-type --host --custom-host-1 --custom-host-2\"\n" + + "\n" + + " compopt +o default\n" + + "\n" + + " case ${prev_word} in\n" + + " --file)\n" + + " compopt -o filenames\n" + + " COMPREPLY=( $( compgen -f -- \"${curr_word}\" ) ) # files\n" + + " return $?\n" + + " ;;\n" + + " --custom-path-1)\n" + + " compopt -o filenames\n" + + " COMPREPLY=( $( compgen -f -- \"${curr_word}\" ) ) # files\n" + + " return $?\n" + + " ;;\n" + + " --custom-path-2)\n" + + " compopt -o filenames\n" + + " COMPREPLY=( $( compgen -f -- \"${curr_word}\" ) ) # files\n" + + " return $?\n" + + " ;;\n" + + " --custom-type)\n" + + " return\n" + + " ;;\n" + + " --host)\n" + + " compopt -o filenames\n" + + " COMPREPLY=( $( compgen -A hostname -- \"${curr_word}\" ) )\n" + + " return $?\n" + + " ;;\n" + + " --custom-host-1)\n" + + " compopt -o filenames\n" + + " COMPREPLY=( $( compgen -A hostname -- \"${curr_word}\" ) )\n" + + " return $?\n" + + " ;;\n" + + " --custom-host-2)\n" + + " compopt -o filenames\n" + + " COMPREPLY=( $( compgen -A hostname -- \"${curr_word}\" ) )\n" + + " return $?\n" + + " ;;\n" + + " esac\n" + + "\n" + + " if [[ \"${curr_word}\" == -* ]]; then\n" + + " COMPREPLY=( $(compgen -W \"${flag_opts} ${arg_opts}\" -- \"${curr_word}\") )\n" + + " else\n" + + " local positionals=\"\"\n" + + " COMPREPLY=( $(compgen -W \"${commands} ${positionals}\" -- \"${curr_word}\") )\n" + + " fi\n" + + "}\n"); + + assertEquals(expected, new String(completion, "UTF8")); + } + + private String expectedCommandCompletion(String commandName, String autoCompleteFunctionContent) { + String expected = expectedCompletionScriptForAutoCompleteApp() + .replaceAll("picocli\\.AutoComplete", commandName); + expected = Pattern.compile("function _picocli_" + commandName + "\\(\\) \\{\n" + + "(.+)\n" + + "}\n" + + "\n" + + "# Define a completion specification \\(a compspec\\) for the", Pattern.DOTALL) + .matcher(expected) + .replaceFirst(autoCompleteFunctionContent.replace("$", "\\$") + + "\n# Define a completion specification (a compspec) for the"); + return expected; + } + + @Command(name = "PathCompletion") + static class PathCompletionCommand implements Runnable { + + @Option(names = "--file") + private File file; + + @Option(names = "--custom-path-1") + private CustomPath1 customPath1; + + @Option(names = "--custom-path-2") + private List customPath2; + + @Option(names = "--custom-type") + private CustomType customType; + + @Option(names = "--host") + private InetAddress host; + + @Option(names = "--custom-host-1") + private CustomHost1 customHost1; + + @Option(names = "--custom-host-2") + private List customHost2; + + public void run() { + } + + static class CustomType { + String value; + } + + static class CustomPath1 { + String value; + } + + static class CustomPath2 { + String value; + } + + static class CustomHost1 { + String value; + } + + static class CustomHost2 { + String value; + } } }