Skip to content

Commit

Permalink
feat: Added support for "plugins"
Browse files Browse the repository at this point in the history
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
  • Loading branch information
quintesse committed Feb 14, 2023
1 parent 4aa562a commit 1a52e7e
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 24 deletions.
43 changes: 39 additions & 4 deletions src/main/java/dev/jbang/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> 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<String> 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<String> result = new ArrayList<>();
result.add("run");
result.addAll(leadingOpts);
result.addAll(remainingArgs);
args = result.toArray(args);
}
}
return args;
}
Expand Down
76 changes: 61 additions & 15 deletions src/main/java/dev/jbang/cli/JBang.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, String> cmds = findExternalCommands();
if (!cmds.isEmpty()) {
sections.put("External", asList());
}
CommandGroupRenderer renderer = new CommandGroupRenderer(sections, cmds);
return renderer;
}

private static Map<String, String> findExternalCommands() {
Map<String, String> 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<Path> 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<String, List<String>> sections;
private final Map<String, String> externals;

public CommandGroupRenderer(Map<String, List<String>> sections) {
public CommandGroupRenderer(Map<String, List<String>> sections, Map<String, String> externals) {
this.sections = sections;
this.externals = externals;
}

/**
Expand Down Expand Up @@ -293,23 +326,26 @@ public String render(CommandLine.Help help) {
private String renderSection(String sectionHeading, List<String> 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) {
Expand Down Expand Up @@ -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]);
}
}
}
}
47 changes: 42 additions & 5 deletions src/main/java/dev/jbang/util/Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -1486,29 +1489,42 @@ public static Optional<String> 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
*/
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<Path> 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);
}
Expand Down Expand Up @@ -1754,4 +1770,25 @@ public static <T> List<T> join(Collection<T>... lists) {
public static <K, V> Entry<K, V> entry(K k, V v) {
return new AbstractMap.SimpleEntry<K, V>(k, v);
}

public static List<Path> findCommandsWith(Predicate<Path> 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<Path> 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();
}
}
}

0 comments on commit 1a52e7e

Please sign in to comment.