diff --git a/gradle.properties b/gradle.properties index c55bfa7d..2085e75a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ ivyVersion = 2.4.0 jacocoVersion = 0.8.2 jansiVersion = 1.15 jlineVersion = 2.14.6 -jline3Version = 3.9.0 +jline3Version = 3.13.2 junitDepVersion = 4.11 junitVersion = 4.12 springBootVersion = 2.1.6.RELEASE diff --git a/picocli-shell-jline3/build.gradle b/picocli-shell-jline3/build.gradle index 79b0c56e..d5eda01e 100644 --- a/picocli-shell-jline3/build.gradle +++ b/picocli-shell-jline3/build.gradle @@ -8,6 +8,8 @@ plugins { group 'info.picocli' description 'Picocli Shell JLine3 - easily build interactive shell applications with JLine 3 and picocli.' version "$projectVersion" +sourceCompatibility = 1.8 +targetCompatibility = 1.8 dependencies { compile rootProject diff --git a/picocli-shell-jline3/src/main/java/picocli/shell/jline3/PicocliCommands.java b/picocli-shell-jline3/src/main/java/picocli/shell/jline3/PicocliCommands.java new file mode 100644 index 00000000..9118d646 --- /dev/null +++ b/picocli-shell-jline3/src/main/java/picocli/shell/jline3/PicocliCommands.java @@ -0,0 +1,109 @@ +package picocli.shell.jline3; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.jline.builtins.Completers.OptionCompleter; +import org.jline.builtins.Completers.SystemCompleter; +import org.jline.builtins.Options.HelpException; +import org.jline.builtins.Widgets.ArgDesc; +import org.jline.builtins.Widgets.CmdDesc; +import org.jline.reader.impl.completer.ArgumentCompleter; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.utils.AttributedString; + +import picocli.CommandLine; +import picocli.CommandLine.Help; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; + +public class PicocliCommands { + private final Supplier workDir; + private final CommandLine cmd; + private final List commands; + + public PicocliCommands(Path workDir, CommandLine cmd) { + this(() -> workDir, cmd); + } + + public PicocliCommands(Supplier workDir, CommandLine cmd) { + this.workDir = workDir; + this.cmd = cmd; + commands = new ArrayList<>(Arrays.asList(cmd.getCommandSpec().aliases())); + commands.addAll(cmd.getCommandSpec().subcommands().keySet()); + + } + + public boolean hasCommand(String command) { + return commands.contains(command); + } + + public SystemCompleter compileCompleters() { + SystemCompleter out = new SystemCompleter(); + // with original completer... + // out.add(commands, new PicocliJLineCompleter(cmd.getCommandSpec())); + // return out; + for (String s: commands) { + CommandSpec spec = cmd.getSubcommands().get(s).getCommandSpec(); + List options = new ArrayList<>(); + Map> optionValues = new HashMap<>(); + for (OptionSpec o: spec.options()) { + List values = new ArrayList<>(); + if (o.completionCandidates() != null) { + o.completionCandidates().forEach(v -> values.add(v)); + } + if (o.arity().max() == 0) { + options.addAll(Arrays.asList(o.names())); + } else { + for (String n: o.names()) { + optionValues.put(n, values); + } + } + } + // TODO positional parameter completion + // JLine OptionCompleter need to be improved with option descriptions and option value completion, + // now it completes only strings. + if (options.isEmpty() && optionValues.isEmpty()) { + out.add(s, new ArgumentCompleter(new StringsCompleter(s), NullCompleter.INSTANCE)); + } else { + out.add(s, new ArgumentCompleter(new StringsCompleter(s) + , new OptionCompleter(NullCompleter.INSTANCE, optionValues, options, 1))); + } + } + return out; + } + + public CmdDesc commandDescription(String command) { + CommandSpec spec = cmd.getSubcommands().get(command).getCommandSpec(); + Help cmdhelp= new picocli.CommandLine.Help(spec); + List main = new ArrayList<>(); + Map> options = new HashMap<>(); + String synopsis = AttributedString.stripAnsi(spec.usageMessage().sectionMap().get("synopsis").render(cmdhelp).toString()); + main.add(HelpException.highlightSyntax(synopsis.trim(), HelpException.defaultStyle())); + // using JLine help highlight because the statement below does not work well... + // main.add(new AttributedString(spec.usageMessage().sectionMap().get("synopsis").render(cmdhelp).toString())); + for (OptionSpec o: spec.options()) { + String key = Arrays.stream(o.names()).collect(Collectors.joining(" ")); + List val = new ArrayList<>(); + for (String d: o.description()) { + val.add(new AttributedString(d)); + } + if (val.isEmpty()) { + val.add(new AttributedString("")); // in order to avoid IndexOutOfBoundsException + // need to be fixed in JLine + } + if (o.arity().max() > 0 && key.matches(".*[a-zA-Z]{2,}$")) { + key += "=" + o.paramLabel(); + } + options.put(key, val); + } + return new CmdDesc(main, ArgDesc.doArgNames(Arrays.asList("")), options); + } +} diff --git a/picocli-shell-jline3/src/test/java/picocli/shell/jline3/example/Example.java b/picocli-shell-jline3/src/test/java/picocli/shell/jline3/example/Example.java index 4f3f9978..73c975e2 100644 --- a/picocli-shell-jline3/src/test/java/picocli/shell/jline3/example/Example.java +++ b/picocli-shell-jline3/src/test/java/picocli/shell/jline3/example/Example.java @@ -2,30 +2,44 @@ package picocli.shell.jline3.example; import java.io.IOException; import java.io.PrintWriter; +import java.nio.file.Paths; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; -import org.jline.reader.Completer; +import org.jline.builtins.Builtins; +import org.jline.builtins.Completers.SystemCompleter; +import org.jline.builtins.Options.HelpException; +import org.jline.builtins.Widgets.CmdDesc; +import org.jline.builtins.Widgets.CmdLine; +import org.jline.builtins.Widgets.TailTipWidgets; +import org.jline.builtins.Widgets.TailTipWidgets.TipType; +import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; -import org.jline.reader.EndOfFileException; -import org.jline.reader.UserInterruptException; +import org.jline.reader.MaskingCallback; import org.jline.reader.ParsedLine; +import org.jline.reader.Parser; +import org.jline.reader.UserInterruptException; import org.jline.reader.impl.DefaultParser; import org.jline.reader.impl.LineReaderImpl; -import org.jline.terminal.TerminalBuilder; import org.jline.terminal.Terminal; -import org.jline.reader.MaskingCallback; +import org.jline.terminal.TerminalBuilder; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import picocli.CommandLine.ParentCommand; -import picocli.shell.jline3.PicocliJLineCompleter; +import picocli.shell.jline3.PicocliCommands; /** * Example that demonstrates how to build an interactive shell with JLine3 and picocli. - * @since 3.9 + * @since 4.1.2 */ public class Example { @@ -40,8 +54,8 @@ public class Example { PrintWriter out; CliCommands() {} - - public void setReader(LineReader reader){ + + public void setReader(LineReader reader){ this.reader = (LineReaderImpl)reader; out = reader.getTerminal().writer(); } @@ -93,18 +107,59 @@ public class Example { } } + private static class DescriptionGenerator { + Builtins builtins; + PicocliCommands picocli; + + public DescriptionGenerator(Builtins builtins, PicocliCommands picocli) { + this.builtins = builtins; + this.picocli = picocli; + } + + CmdDesc commandDescription(CmdLine line) { + CmdDesc out = null; + switch (line.getDescriptionType()) { + case COMMAND: + String cmd = Parser.getCommand(line.getArgs().get(0)); + if (builtins.hasCommand(cmd)) { + out = builtins.commandDescription(cmd); + } else if (picocli.hasCommand(cmd)) { + out = picocli.commandDescription(cmd); + } + break; + default: + break; + } + return out; + } + } public static void main(String[] args) { try { - // set up the completion + // set up jline built-in commands + Path workDir = Paths.get(""); + Builtins builtins = new Builtins(workDir, null, null); + builtins.rename(org.jline.builtins.Builtins.Command.TTOP, "top"); + builtins.alias("zle", "widget"); + builtins.alias("bindkey", "keymap"); + SystemCompleter systemCompleter = builtins.compileCompleters(); + // set up picocli commands CliCommands commands = new CliCommands(); CommandLine cmd = new CommandLine(commands); + PicocliCommands picocliCommands = new PicocliCommands(workDir, cmd); + systemCompleter.add(picocliCommands.compileCompleters()); + systemCompleter.compile(); Terminal terminal = TerminalBuilder.builder().build(); LineReader reader = LineReaderBuilder.builder() .terminal(terminal) - .completer(new PicocliJLineCompleter(cmd.getCommandSpec())) + .completer(systemCompleter) .parser(new DefaultParser()) + .variable(LineReader.LIST_MAX, 50) // max tab completion candidates .build(); + builtins.setLineReader(reader); commands.setReader(reader); + DescriptionGenerator descriptionGenerator = new DescriptionGenerator(builtins, picocliCommands); + new TailTipWidgets(reader, descriptionGenerator::commandDescription, 5, TipType.COMPLETER); + String prompt = "prompt> "; String rightPrompt = null; @@ -113,14 +168,29 @@ public class Example { while (true) { try { line = reader.readLine(prompt, rightPrompt, (MaskingCallback) null, null); + if (line.matches("^\\s*#.*")) { + continue; + } ParsedLine pl = reader.getParser().parse(line, 0); String[] arguments = pl.words().toArray(new String[0]); - new CommandLine(commands).execute(arguments); + String command = Parser.getCommand(pl.word()); + if (builtins.hasCommand(command)) { + builtins.execute(command, Arrays.copyOfRange(arguments, 1, arguments.length) + , System.in, System.out, System.err); + } else { + new CommandLine(commands).execute(arguments); + } + } catch (HelpException e) { + HelpException.highlight(e.getMessage(), HelpException.defaultStyle()).print(terminal); } catch (UserInterruptException e) { // Ignore } catch (EndOfFileException e) { return; - } + } catch (Exception e) { + AttributedStringBuilder asb = new AttributedStringBuilder(); + asb.append(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)); + asb.toAttributedString().println(terminal); + } } } catch (Throwable t) { t.printStackTrace(); diff --git a/settings.gradle b/settings.gradle index 499b86e8..728bba67 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,12 +2,12 @@ rootProject.name = 'picocli' include 'picocli-groovy' include 'picocli-examples' include 'picocli-shell-jline2' -include 'picocli-shell-jline3' include 'picocli-codegen' if (org.gradle.api.JavaVersion.current().isJava8Compatible()) { include 'picocli-annotation-processing-tests' include 'picocli-spring-boot-starter' + include 'picocli-shell-jline3' } else { println("Excluding module picocli-annotation-processing-tests from the build: they require Java 8 but we have Java version ${org.gradle.api.JavaVersion.current()}") }