From 1a52e7ebde3aa927ff0907f258488580e81e08a6 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 17 Mar 2021 12:08:51 +0100 Subject: [PATCH] feat: Added support for "plugins" Any aliases or commands on the user's PATH that start with "jbang-" will be considered "plugins" and can be run as if they are part of the built-in jbang commands. See #487 --- src/main/java/dev/jbang/Main.java | 43 +++++++++++++-- src/main/java/dev/jbang/cli/JBang.java | 76 +++++++++++++++++++++----- src/main/java/dev/jbang/util/Util.java | 47 ++++++++++++++-- 3 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/main/java/dev/jbang/Main.java b/src/main/java/dev/jbang/Main.java index 086a3eeeb..41e84d4d2 100644 --- a/src/main/java/dev/jbang/Main.java +++ b/src/main/java/dev/jbang/Main.java @@ -3,7 +3,11 @@ import java.util.ArrayList; import java.util.List; +import dev.jbang.catalog.Alias; +import dev.jbang.catalog.Catalog; +import dev.jbang.cli.BaseCommand; import dev.jbang.cli.JBang; +import dev.jbang.util.Util; import picocli.CommandLine; @@ -29,15 +33,46 @@ private static String[] handleDefaultRun(CommandLine.Model.CommandSpec spec, Str leadingOpts.add(arg); } } - // Check if we have a parameter and it's not the same as any of the subcommand - // names - if (!remainingArgs.isEmpty() && !spec.subcommands().containsKey(remainingArgs.get(0)) - || hasRunOpts(leadingOpts)) { + // Check if we have a parameter, and it's not the same as any of the + // subcommand names + if (hasRunOpts(leadingOpts)) { List result = new ArrayList<>(); result.add("run"); result.addAll(leadingOpts); result.addAll(remainingArgs); args = result.toArray(args); + } else if (!remainingArgs.isEmpty()) { + String cmd = remainingArgs.get(0); + if (!spec.subcommands().containsKey(cmd)) { + if (Catalog.isValidName(cmd) && Alias.get("jbang-" + cmd) != null) { + // We found a matching "jbang-xxx" alias + remainingArgs.set(0, "jbang-" + cmd); + } else if (Alias.get(cmd) != null) { + // We found an exactly matching alias + // We do this test because we want aliases to have a higher + // priority than the next case, which is to look up commands + // in the user's PATH which might be slow-ish + } else if (!Util.findCommandsWith(p -> Util.base(p.getFileName().toString()).equals("jbang-" + cmd)) + .isEmpty()) { + // We found a matching "jbang-xxx" command on the user's PATH + List result = new ArrayList<>(); + result.add("jbang-" + cmd); + result.addAll(leadingOpts); + result.add("--"); + result.addAll(remainingArgs.subList(1, remainingArgs.size())); + String cmdLine = String.join(" ", result); + Util.verboseMsg("run plugin: " + cmdLine); + System.out.println(cmdLine); + System.exit(BaseCommand.EXIT_EXECUTE); + } else { + // In all other cases assume it's an implicit "run" (no need to do anything) + } + List result = new ArrayList<>(); + result.add("run"); + result.addAll(leadingOpts); + result.addAll(remainingArgs); + args = result.toArray(args); + } } return args; } diff --git a/src/main/java/dev/jbang/cli/JBang.java b/src/main/java/dev/jbang/cli/JBang.java index 976353a76..74cb4895b 100644 --- a/src/main/java/dev/jbang/cli/JBang.java +++ b/src/main/java/dev/jbang/cli/JBang.java @@ -11,6 +11,7 @@ import static picocli.CommandLine.ScopeType; import java.io.PrintWriter; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; @@ -21,6 +22,7 @@ import java.util.Objects; import java.util.ResourceBundle; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.Future; import dev.jbang.Configuration; @@ -239,15 +241,46 @@ public static CommandGroupRenderer getCommandRenderer() { sections.put("Caching", asList("cache", "export", "jdk")); sections.put("Configuration", asList("config", "trust", "alias", "template", "catalog", "app")); sections.put("Other", asList("completion", "info", "version", "wrapper")); - CommandGroupRenderer renderer = new CommandGroupRenderer(sections); + Map cmds = findExternalCommands(); + if (!cmds.isEmpty()) { + sections.put("External", asList()); + } + CommandGroupRenderer renderer = new CommandGroupRenderer(sections, cmds); return renderer; } + private static Map findExternalCommands() { + Map result = new TreeMap<>(); + // Add any aliases whose names start with "jbang-" to the result + try { + dev.jbang.catalog.Catalog cat = dev.jbang.catalog.Catalog.getMerged(false); + for (String name : cat.aliases.keySet()) { + if (name.startsWith("jbang-")) { + result.put(name.substring(6), cat.aliases.get(name).description); + } + } + } catch (Exception ex) { + Util.verboseMsg("Error trying to list aliases", ex); + } + // Now add any commands found on the PATH whose names start with "jbang-" + try { + List cmds = Util.findCommandsWith(p -> p.getFileName().toString().startsWith("jbang-")); + for (Path p : cmds) { + result.put(Util.base(p.getFileName().toString()).substring(6), null); + } + } catch (Exception ex) { + Util.verboseMsg("Error trying to list jbang-commands", ex); + } + return result; + } + public static class CommandGroupRenderer implements CommandLine.IHelpSectionRenderer { private final Map> sections; + private final Map externals; - public CommandGroupRenderer(Map> sections) { + public CommandGroupRenderer(Map> sections, Map externals) { this.sections = sections; + this.externals = externals; } /** @@ -293,23 +326,26 @@ public String render(CommandLine.Help help) { private String renderSection(String sectionHeading, List cmdNames, CommandLine.Help help) { TextTable textTable = createTextTable(help); - for (String name : cmdNames) { - CommandSpec sub = help.commandSpec().subcommands().get(name).getCommandSpec(); - - // create comma-separated list of command name and aliases - String names = sub.names().toString(); - names = names.substring(1, names.length() - 1); // remove leading '[' and trailing ']' + if (!sectionHeading.equals("External")) { + for (String name : cmdNames) { + CommandSpec sub = help.commandSpec().subcommands().get(name).getCommandSpec(); - // description may contain line separators; use Text::splitLines to handle this - String description = description(sub.usageMessage()); - CommandLine.Help.Ansi.Text[] lines = help.colorScheme().text(description).splitLines(); + // create comma-separated list of command name and aliases + String names = sub.names().toString(); + names = names.substring(1, names.length() - 1); // remove leading '[' and trailing ']' - for (int i = 0; i < lines.length; i++) { - CommandLine.Help.Ansi.Text cmdNamesText = help.colorScheme().commandText(i == 0 ? names : ""); - textTable.addRowValues(cmdNamesText, lines[i]); + // description may contain line separators; use Text::splitLines to handle this + String description = description(sub.usageMessage()); + addCommand(textTable, names, description, help); + } + } else { + for (String name : externals.keySet()) { + String description = externals.get(name); + addCommand(textTable, name, description, help); } } - return help.createHeading("%n" + sectionHeading + ":%n") + textTable.toString(); + + return help.createHeading("%n" + sectionHeading + ":%n") + textTable; } private TextTable createTextTable(CommandLine.Help help) { @@ -343,5 +379,15 @@ private String description(UsageMessageSpec usageMessage) { } return ""; } + + private void addCommand(TextTable textTable, String name, String description, CommandLine.Help help) { + CommandLine.Help.Ansi.Text[] lines = help .colorScheme() + .text(description != null ? description : "") + .splitLines(); + for (int i = 0; i < lines.length; i++) { + CommandLine.Help.Ansi.Text cmdNamesText = help.colorScheme().commandText(i == 0 ? name : ""); + textTable.addRowValues(cmdNamesText, lines[i]); + } + } } } diff --git a/src/main/java/dev/jbang/util/Util.java b/src/main/java/dev/jbang/util/Util.java index 63de0a29b..6f818e362 100644 --- a/src/main/java/dev/jbang/util/Util.java +++ b/src/main/java/dev/jbang/util/Util.java @@ -41,10 +41,13 @@ import java.util.*; import java.util.List; import java.util.Map.Entry; +import java.util.Optional; +import java.util.Scanner; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -238,7 +241,7 @@ public static String getBaseName(String fileName) { /** * Returns the name without extension. Will return the name itself if it has no * extension - * + * * @param name A file name * @return A name without extension */ @@ -268,7 +271,7 @@ public static String sourceBase(String name) { * Returns the extension of the given file name. The extension will not include * the dot as part of the result. Returns an empty string if the name has no * extension. - * + * * @param name A file name * @return An extension or an empty string */ @@ -1486,7 +1489,7 @@ public static Optional getSourcePackage(String content) { } /** - * Searches the locations defined by PATH for the given executable + * Searches the locations defined by `PATH` for the given executable * * @param cmd The name of the executable to look for * @return A Path to the executable, if found, null otherwise @@ -1494,21 +1497,34 @@ public static Optional getSourcePackage(String content) { public static Path searchPath(String cmd) { String envPath = System.getenv("PATH"); envPath = envPath != null ? envPath : ""; - return searchPath(cmd, envPath); + return searchPath(cmd, envPath, p -> true); } /** - * Searches the locations defined by `paths` for the given executable + * Searches the given `paths` for the given executable * * @param cmd The name of the executable to look for * @param paths A string containing the paths to search * @return A Path to the executable, if found, null otherwise */ public static Path searchPath(String cmd, String paths) { + return searchPath(cmd, paths, p -> true); + } + + /** + * Searches the locations defined by `paths` for the given executable + * + * @param cmd The name of the executable to look for + * @param paths A string containing the paths to search + * @param pathFilter User filter for the executables found + * @return A Path to the executable, if found, null otherwise + */ + public static Path searchPath(String cmd, String paths, Predicate pathFilter) { return Arrays .stream(paths.split(File.pathSeparator)) .map(dir -> Paths.get(dir).resolve(cmd)) .flatMap(Util::executables) .filter(Util::isExecutable) + .filter(pathFilter) .findFirst() .orElse(null); } @@ -1754,4 +1770,25 @@ public static List join(Collection... lists) { public static Entry entry(K k, V v) { return new AbstractMap.SimpleEntry(k, v); } + + public static List findCommandsWith(Predicate accept) { + String[] elems = System.getenv().getOrDefault("PATH", "").split(File.pathSeparator); + return Stream + .of(elems) + .map(elem -> Util.getCwd().resolve(elem)) + .flatMap(dir -> listFiles(dir).filter(p -> isExecutable(p)).filter(accept)) + .collect(Collectors.toList()); + } + + private static Stream listFiles(Path dir) { + if (Files.isDirectory(dir)) { + try { + return Files.list(dir); + } catch (IOException e) { + throw new RuntimeException(e.getMessage(), e); + } + } else { + return Stream.empty(); + } + } }