You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/09/14 06:26:54 UTC
(groovy) branch master updated (4165c68bbf -> a24cdf7796)
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
from 4165c68bbf GROOVY-12408: a switch that is the last statement of a body is a statement
new 7946d4c16c GROOVY-12403: Restore groovysh embedding extension points
new 9aed6196a8 GROOVY-12403: review follow-ups for the groovysh embedding API
new 00bed69ff9 GROOVY-12403: narrow the groovysh deprecation suppressions
new a24cdf7796 GROOVY-12403: let an embedder contribute completion
The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
subprojects/groovy-groovysh/ARCHITECTURE.md | 63 +++
subprojects/groovy-groovysh/build.gradle | 1 +
.../groovy/groovysh/ExtraConsoleCommands.groovy | 289 ++++++++++++
.../apache/groovy/groovysh/GroovyshOptions.java | 500 +++++++++++++++++++++
.../groovy/org/apache/groovy/groovysh/Main.groovy | 361 ++++-----------
.../apache/groovy/groovysh/jline/GroovyEngine.java | 163 ++++++-
.../groovy/org/apache/groovy/groovysh/package.html | 4 +
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 122 +++++
.../src/spec/test/EmbeddingSpecTest.groovy | 95 ++++
.../groovysh/ExtraConsoleCommandsTest.groovy | 218 +++++++++
.../groovy/groovysh/GroovyshEmbeddingTest.groovy | 345 ++++++++++++++
.../groovy/groovysh/GroovyshOptionsTest.groovy | 175 ++++++++
.../groovy/groovysh/jline/GroovyEngineTest.groovy | 181 ++++++++
13 files changed, 2248 insertions(+), 269 deletions(-)
create mode 100644 subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy
create mode 100644 subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
create mode 100644 subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy
create mode 100644 subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy
create mode 100644 subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
create mode 100644 subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy
(groovy) 01/04: GROOVY-12403: Restore groovysh embedding extension points
Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 7946d4c16c8476937da311d05f39e891be1a9091
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sun Sep 13 10:31:41 2026 +0900
GROOVY-12403: Restore groovysh embedding extension points
---
subprojects/groovy-groovysh/ARCHITECTURE.md | 25 ++
.../groovy/groovysh/ExtraConsoleCommands.groovy | 261 +++++++++++++++
.../apache/groovy/groovysh/GroovyshOptions.java | 359 +++++++++++++++++++++
.../groovy/org/apache/groovy/groovysh/Main.groovy | 349 ++++++--------------
.../apache/groovy/groovysh/jline/GroovyEngine.java | 135 +++++++-
.../groovy/org/apache/groovy/groovysh/package.html | 4 +
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 76 +++++
.../src/spec/test/EmbeddingSpecTest.groovy | 62 ++++
.../groovysh/ExtraConsoleCommandsTest.groovy | 199 ++++++++++++
.../groovy/groovysh/GroovyshEmbeddingTest.groovy | 266 +++++++++++++++
.../groovy/groovysh/GroovyshOptionsTest.groovy | 173 ++++++++++
.../groovy/groovysh/jline/GroovyEngineTest.groovy | 182 +++++++++++
12 files changed, 1826 insertions(+), 265 deletions(-)
diff --git a/subprojects/groovy-groovysh/ARCHITECTURE.md b/subprojects/groovy-groovysh/ARCHITECTURE.md
index a4f04fe581..ed2a40309f 100644
--- a/subprojects/groovy-groovysh/ARCHITECTURE.md
+++ b/subprojects/groovy-groovysh/ARCHITECTURE.md
@@ -220,6 +220,31 @@ particularly exposed to them.
up substantive upstream fixes; skip cosmetic noise.
5. Update this document if any finding above changed.
+## Embedding API
+
+Hosts that embed groovysh (domain-specific consoles, IDEs) should use
+the public seams rather than copying `Main`:
+
+- `GroovyEngine(CompilerConfiguration)` and
+ `protected GroovyShell createShell(...)` — compile-time customizers
+ (`ImportCustomizer`, `ThreadInterrupt`) without snippet-prepending
+ every import on each evaluation.
+- `GroovyEngine.getClassLoader()` — public accessor for the engine
+ class loader (the field is `protected`).
+- `GroovyshOptions` + `Main.start(GroovyshOptions, String[])` —
+ extra `CommandRegistry` instances, prompt, result/error handlers,
+ banner toggle, optional `Terminal`. Binding variables stay in
+ `GroovyshOptions.bindings`; they are not mixed with embedding hooks.
+- `ExtraConsoleCommands` — public top-level class for `/clear`,
+ `/pwd`, `/cd`, the POSIX-style commands, and `/!`.
+ `Main.ExtraConsoleCommands` is a deprecated subclass.
+
+The user-facing description is
+[`src/spec/doc/groovysh.adoc`](src/spec/doc/groovysh.adoc)
+"Embedding groovysh". Tests live in `GroovyEngineTest`,
+`GroovyshOptionsTest`, `GroovyshEmbeddingTest`, and
+`ExtraConsoleCommandsTest`.
+
## Where to read next
- [Root `ARCHITECTURE.md`](../../ARCHITECTURE.md) — project-wide
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy
new file mode 100644
index 0000000000..6aaf57765f
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy
@@ -0,0 +1,261 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.groovysh
+
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.apache.groovy.groovysh.jline.GroovyPosixCommands
+import org.apache.groovy.groovysh.jline.GroovyPosixContext
+import org.jline.builtins.Completers
+import org.jline.builtins.Options
+import org.jline.builtins.PosixCommands
+import org.jline.builtins.PosixCommandsRegistry
+import org.jline.console.CommandInput
+import org.jline.console.CommandMethods
+import org.jline.console.CommandRegistry
+import org.jline.console.impl.JlineCommandRegistry
+import org.jline.reader.Completer
+import org.jline.reader.LineReader
+import org.jline.reader.impl.completer.ArgumentCompleter
+import org.jline.reader.impl.completer.NullCompleter
+import org.jline.terminal.Terminal
+import org.jline.utils.InfoCmp.Capability
+import org.jline.utils.OSUtils
+
+import java.nio.file.Path
+import java.util.function.Function
+
+/**
+ * Console commands that groovysh registers in addition to the Groovy and
+ * JLine builtin registries: {@code /clear}, {@code /pwd}, {@code /cd},
+ * {@code /ls}, {@code /cat}, {@code /grep}, {@code /head}, {@code /tail},
+ * {@code /wc}, {@code /sort}, {@code /date}, {@code /echo} and {@code /!}.
+ * <p>
+ * Embedders that want the same command set without copying {@link Main}
+ * can construct this class and pass the instance to
+ * {@link GroovyshOptions.Builder#extraCommandRegistry(CommandRegistry)}
+ * or to {@code SystemRegistry.setCommandRegistries}. {@link Main} still
+ * registers it by default.
+ *
+ * @since 7.0.0
+ */
+@SuppressWarnings('resource')
+class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegistry {
+
+ /**
+ * POSIX-style commands implemented by {@link GroovyPosixCommands}.
+ */
+ static final List<String> POSIX_COMMANDS = ['/ls', '/wc', '/sort', '/head', '/tail', '/cat', '/grep'].asImmutable()
+
+ private final LineReader reader
+ private final GroovyEngine scriptEngine
+ private PosixCommandsRegistry posix
+
+ /**
+ * Creates the auxiliary console command registry used by the shell.
+ *
+ * @param workDir initial working directory
+ * @param scriptEngine script engine backing the shell session
+ * @param reader active line reader
+ */
+ ExtraConsoleCommands(Path workDir, GroovyEngine scriptEngine, LineReader reader) {
+ super()
+ this.scriptEngine = scriptEngine
+ this.reader = reader
+ def terminal = reader.terminal
+ def context = new GroovyPosixContext(
+ terminal.input(),
+ new PrintStream(terminal.output()),
+ new PrintStream(terminal.output()),
+ workDir,
+ terminal,
+ scriptEngine::get
+ )
+ posix = new PosixCommandsRegistry(context)
+ def cmds = [
+ '/clear': new CommandMethods((Function) this::clear, this::defaultCompleter),
+ '/pwd' : new CommandMethods((Function) this::pwd, this::defaultCompleter),
+ '/cd' : new CommandMethods((Function) this::cd, this::optDirCompleter),
+ '/date' : new CommandMethods((Function) this::date, this::defaultCompleter),
+ '/echo' : new CommandMethods((Function) this::echo, this::defaultCompleter),
+ "/!" : new CommandMethods((Function) this::shell, this::defaultCompleter)
+ ]
+ POSIX_COMMANDS.each { String cmd ->
+ String base = cmd[1..-1]
+ posix.register(cmd, PosixCommands::"$base")
+ cmds.put(cmd, new CommandMethods((Function) this::posixCommand, this::optFileCompleter))
+ }
+ posix.register('cd', PosixCommands::cd)
+ posix.register('/cd', PosixCommands::cd)
+ posix.register('/pwd', PosixCommands::pwd)
+ posix.register('/date', PosixCommands::date)
+ posix.register('/echo', PosixCommands::echo)
+ registerCommands(cmds)
+ }
+
+ /**
+ * Returns the current working directory tracked by the POSIX command context.
+ *
+ * @return the active working directory
+ */
+ Path currentDir() {
+ posix.context.currentDir
+ }
+
+ private String[] adjustUsage(String from, String to) {
+ try {
+ posix.execute(from, [from, '--help'] as String[])
+ } catch (Options.HelpException e) {
+ e.message.readLines()*.replaceAll("$from ", "$to ") as String[]
+ }
+ }
+
+ /**
+ * Returns the help-group name used for the extra console commands.
+ *
+ * @return the console command group name
+ */
+ @Override
+ String name() {
+ 'Console Commands'
+ }
+
+ private Terminal terminal() {
+ return reader?.terminal
+ }
+
+ private List<Completer> optFileCompleter(String command) {
+ [new ArgumentCompleter(NullCompleter.INSTANCE, new Completers.OptionCompleter(new Completers.FilesCompleter(this::currentDir), this::commandOptions, 1))]
+ }
+
+ private List<Completer> optDirCompleter(String command) {
+ [new ArgumentCompleter(NullCompleter.INSTANCE, new Completers.OptionCompleter(new Completers.DirectoriesCompleter(this::currentDir), this::commandOptions, 1))]
+ }
+
+ private void pwd(CommandInput input) {
+ posix(adjustUsage('pwd', '/pwd'), input)
+ }
+
+ private void cd(CommandInput input) {
+ try {
+ parseOptions(adjustUsage('cd', '/cd'), input.args())
+ PosixCommands.cd(context(input), ['/cd', *input.args()] as String[], { Path newPath ->
+ posix.context.currentDir = newPath
+ scriptEngine.put('PWD', newPath)
+ })
+ } catch (Exception e) {
+ saveException(e)
+ }
+ }
+
+ private void posixCommand(CommandInput input) {
+ try {
+ String cmd = input.command()
+ String name = cmd[1..-1]
+ GroovyPosixCommands."$name"(context(input), [cmd, *input.xargs()] as Object[])
+ } catch (Exception e) {
+ saveException(e)
+ }
+ }
+
+ private GroovyPosixContext context(CommandInput input) {
+ GroovyPosixContext ctx = new GroovyPosixContext(input.in(), input.out(), input.err(),
+ posix.context.currentDir(), input.terminal(), scriptEngine::get)
+ ctx
+ }
+
+ private void date(CommandInput input) {
+ posix(adjustUsage('date', '/date'), input)
+ }
+
+ private void posix(String[] usage, CommandInput input) {
+ try {
+ parseOptions(usage, input.args())
+ PosixCommands."${input.command()[1..-1]}"(context(input), [input.command(), *input.args()] as String[])
+ } catch (Exception e) {
+ saveException(e)
+ }
+ }
+
+ private void clear(CommandInput input) {
+ final String[] usage = [
+ "/clear - clear terminal",
+ "Usage: /clear",
+ " -? --help Displays command help"
+ ]
+ try {
+ parseOptions(usage, input.args())
+ terminal().puts(Capability.clear_screen)
+ terminal().flush()
+ } catch (Exception e) {
+ saveException(e)
+ }
+ }
+
+ private void echo(CommandInput input) {
+ posix(adjustUsage('echo', '/echo'), input)
+ }
+
+ private static void executeCommand(List<String> args) throws Exception {
+ def sout = new StringBuilder(), serr = new StringBuilder()
+ def command = OSUtils.IS_WINDOWS ? ['cmd.exe', '/c'] : ['sh', '-c']
+ def proc = new ProcessBuilder().command(command + args.join(' ')).start()
+ proc.consumeProcessOutput(sout, serr)
+ int exitCode = proc.waitFor()
+ if (sout.size()) print sout
+ if (exitCode != 0) {
+ if (serr.size()) print serr
+ throw new Exception("Error occurred in shell!")
+ }
+ }
+
+ private void shell(CommandInput input) {
+ final String[] usage = [
+ "/!<command> - execute shell command",
+ "Usage: /!<command>",
+ " -? --help Displays command help"
+ ]
+ if (input.args().length == 1 && (input.args()[0].equals("-?") || input.args()[0].equals("--help"))) {
+ try {
+ parseOptions(usage, input.args())
+ } catch (Exception e) {
+ saveException(e)
+ }
+ } else {
+ List<String> argv = input.args().toList()
+ if (!argv.isEmpty()) {
+ try {
+ executeCommand(argv)
+ } catch (Exception e) {
+ saveException(e)
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns summary text for the specified command.
+ *
+ * @param command command name to describe
+ * @return help lines for the command
+ */
+ @Override
+ List<String> commandInfo(String command) {
+ posix.commandNames.toList()
+ }
+}
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
new file mode 100644
index 0000000000..699911e34f
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.groovysh;
+
+import org.apache.groovy.groovysh.jline.GroovyEngine;
+import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.jline.console.CommandRegistry;
+import org.jline.console.ConsoleEngine;
+import org.jline.console.SystemRegistry;
+import org.jline.terminal.Terminal;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+/**
+ * Configuration for embedding groovysh programmatically via
+ * {@link Main#start(GroovyshOptions, String[])}.
+ * <p>
+ * Binding variables stay in {@link #getBindings()}; they are not mixed
+ * with embedding hooks. Construct with {@link #builder()}.
+ *
+ * @since 7.0.0
+ */
+@Incubating
+@SuppressWarnings("deprecation")
+public final class GroovyshOptions {
+
+ /**
+ * Handles a value produced by the REPL loop.
+ */
+ @FunctionalInterface
+ public interface ResultHandler {
+ /**
+ * Renders {@code result} after a line has been executed.
+ *
+ * @param console the console engine used by the session
+ * @param result the value returned by {@code SystemRegistry.execute}, which may be {@code null}
+ * @throws Exception if rendering fails; groovysh then routes it to the error handler
+ */
+ void handle(ConsoleEngine console, Object result) throws Exception;
+ }
+
+ /**
+ * Handles an error thrown by the REPL loop.
+ */
+ @FunctionalInterface
+ public interface ErrorHandler {
+ /**
+ * Handles an error that escaped command execution.
+ * {@code UserInterruptException} and {@code EndOfFileException} are
+ * not delivered here; {@link Main} treats those as control flow.
+ *
+ * @param registry the system registry for the session
+ * @param error the thrown error or exception
+ */
+ void handle(SystemRegistry registry, Throwable error);
+ }
+
+ private final CompilerConfiguration compilerConfiguration;
+ private final GroovyEngine engine;
+ private final Map<String, Object> bindings;
+ private final List<CommandRegistry> extraCommandRegistries;
+ private final Supplier<String> prompt;
+ private final ResultHandler resultHandler;
+ private final ErrorHandler errorHandler;
+ private final boolean showBanner;
+ private final Terminal terminal;
+
+ private GroovyshOptions(Builder builder) {
+ this.compilerConfiguration = builder.compilerConfiguration;
+ this.engine = builder.engine;
+ this.bindings = Collections.unmodifiableMap(new LinkedHashMap<>(builder.bindings));
+ this.extraCommandRegistries = Collections.unmodifiableList(new ArrayList<>(builder.extraCommandRegistries));
+ this.prompt = builder.prompt;
+ this.resultHandler = builder.resultHandler;
+ this.errorHandler = builder.errorHandler;
+ this.showBanner = builder.showBanner;
+ this.terminal = builder.terminal;
+ }
+
+ /**
+ * Creates a builder for embedding options.
+ *
+ * @return a new builder with default values
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Compiler configuration used when this object does not already hold an
+ * {@link #getEngine() engine}. {@code null} means {@link CompilerConfiguration#DEFAULT}.
+ *
+ * @return the compiler configuration, or {@code null}
+ */
+ public CompilerConfiguration getCompilerConfiguration() {
+ return compilerConfiguration;
+ }
+
+ /**
+ * Pre-built engine. When non-{@code null}, {@link #getCompilerConfiguration()}
+ * is ignored because the engine already carries its configuration.
+ *
+ * @return the engine, or {@code null} to let {@link Main} construct one
+ */
+ public GroovyEngine getEngine() {
+ return engine;
+ }
+
+ /**
+ * Binding variables installed on the engine before the REPL starts.
+ *
+ * @return an unmodifiable map, never {@code null}
+ */
+ public Map<String, Object> getBindings() {
+ return bindings;
+ }
+
+ /**
+ * Extra command registries appended after groovysh's own registries.
+ *
+ * @return an unmodifiable list, never {@code null}
+ */
+ public List<CommandRegistry> getExtraCommandRegistries() {
+ return extraCommandRegistries;
+ }
+
+ /**
+ * Prompt supplier invoked on each REPL iteration. {@code null} means {@code "groovy> "}.
+ *
+ * @return the prompt supplier, or {@code null}
+ */
+ public Supplier<String> getPrompt() {
+ return prompt;
+ }
+
+ /**
+ * Result renderer. {@code null} means print {@code result?.toString()} via the console engine.
+ *
+ * @return the result handler, or {@code null}
+ */
+ public ResultHandler getResultHandler() {
+ return resultHandler;
+ }
+
+ /**
+ * Error renderer. {@code null} means {@code SystemRegistry.trace(error)}.
+ *
+ * @return the error handler, or {@code null}
+ */
+ public ErrorHandler getErrorHandler() {
+ return errorHandler;
+ }
+
+ /**
+ * Whether {@link Main} should print the groovysh banner (or the quiet version line).
+ *
+ * @return {@code true} to print the banner
+ */
+ public boolean isShowBanner() {
+ return showBanner;
+ }
+
+ /**
+ * Terminal to use instead of the one {@link Main} would build from CLI flags.
+ *
+ * @return the terminal, or {@code null} to build the default
+ */
+ public Terminal getTerminal() {
+ return terminal;
+ }
+
+ /**
+ * Builder for {@link GroovyshOptions}.
+ *
+ * @since 7.0.0
+ */
+ @Incubating
+ public static final class Builder {
+ private CompilerConfiguration compilerConfiguration;
+ private GroovyEngine engine;
+ private final Map<String, Object> bindings = new LinkedHashMap<>();
+ private final List<CommandRegistry> extraCommandRegistries = new ArrayList<>();
+ private Supplier<String> prompt;
+ private ResultHandler resultHandler;
+ private ErrorHandler errorHandler;
+ private boolean showBanner = true;
+ private Terminal terminal;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the compiler configuration used when no {@link #engine(GroovyEngine)} is supplied.
+ *
+ * @param compilerConfiguration the configuration, or {@code null} for the default
+ * @return this builder
+ */
+ public Builder compilerConfiguration(CompilerConfiguration compilerConfiguration) {
+ this.compilerConfiguration = compilerConfiguration;
+ return this;
+ }
+
+ /**
+ * Supplies a pre-built engine. When set, {@link #compilerConfiguration(CompilerConfiguration)}
+ * is not used to construct a second engine.
+ *
+ * @param engine the engine
+ * @return this builder
+ */
+ public Builder engine(GroovyEngine engine) {
+ this.engine = engine;
+ return this;
+ }
+
+ /**
+ * Replaces the binding variables that will be installed on the engine.
+ *
+ * @param bindings variables to install; {@code null} is treated as empty
+ * @return this builder
+ */
+ public Builder bindings(Map<String, ?> bindings) {
+ this.bindings.clear();
+ if (bindings != null) {
+ for (Map.Entry<String, ?> entry : bindings.entrySet()) {
+ if (entry.getKey() != null) {
+ this.bindings.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Adds a single binding variable.
+ *
+ * @param name variable name, must not be {@code null}
+ * @param value variable value
+ * @return this builder
+ */
+ public Builder binding(String name, Object value) {
+ Objects.requireNonNull(name, "name");
+ this.bindings.put(name, value);
+ return this;
+ }
+
+ /**
+ * Appends an extra command registry.
+ *
+ * @param registry the registry to append, must not be {@code null}
+ * @return this builder
+ */
+ public Builder extraCommandRegistry(CommandRegistry registry) {
+ this.extraCommandRegistries.add(Objects.requireNonNull(registry, "registry"));
+ return this;
+ }
+
+ /**
+ * Appends extra command registries.
+ *
+ * @param registries registries to append; {@code null} is ignored
+ * @return this builder
+ */
+ public Builder extraCommandRegistries(Iterable<? extends CommandRegistry> registries) {
+ if (registries != null) {
+ for (CommandRegistry registry : registries) {
+ extraCommandRegistry(registry);
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Sets the prompt supplier. Called once per REPL iteration.
+ *
+ * @param prompt the supplier, or {@code null} for {@code "groovy> "}
+ * @return this builder
+ */
+ public Builder prompt(Supplier<String> prompt) {
+ this.prompt = prompt;
+ return this;
+ }
+
+ /**
+ * Sets the result handler.
+ *
+ * @param resultHandler the handler, or {@code null} for the default printer
+ * @return this builder
+ */
+ public Builder resultHandler(ResultHandler resultHandler) {
+ this.resultHandler = resultHandler;
+ return this;
+ }
+
+ /**
+ * Sets the error handler.
+ *
+ * @param errorHandler the handler, or {@code null} for {@code SystemRegistry.trace}
+ * @return this builder
+ */
+ public Builder errorHandler(ErrorHandler errorHandler) {
+ this.errorHandler = errorHandler;
+ return this;
+ }
+
+ /**
+ * Sets whether the groovysh banner is printed.
+ *
+ * @param showBanner {@code false} to suppress the banner
+ * @return this builder
+ */
+ public Builder showBanner(boolean showBanner) {
+ this.showBanner = showBanner;
+ return this;
+ }
+
+ /**
+ * Supplies a terminal instead of letting {@link Main} build one from CLI flags.
+ *
+ * @param terminal the terminal, or {@code null} to build the default
+ * @return this builder
+ */
+ public Builder terminal(Terminal terminal) {
+ this.terminal = terminal;
+ return this;
+ }
+
+ /**
+ * Builds an immutable options object.
+ *
+ * @return the options
+ */
+ public GroovyshOptions build() {
+ return new GroovyshOptions(this);
+ }
+ }
+}
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index 1a630d2f1d..8597da4868 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -18,35 +18,28 @@
*/
package org.apache.groovy.groovysh
+import org.apache.groovy.groovysh.ExtraConsoleCommands as DefaultExtraConsoleCommands
+
import groovy.cli.internal.CliBuilderInternal
import groovy.cli.internal.OptionAccessor
import org.apache.groovy.groovysh.jline.GroovyBuiltins
+import org.apache.groovy.lang.annotation.Incubating
import org.apache.groovy.groovysh.jline.GroovyCommands
import org.apache.groovy.groovysh.jline.GroovyConsoleEngine
import org.apache.groovy.groovysh.jline.GroovyEngine
-import org.apache.groovy.groovysh.jline.GroovyPosixCommands
-import org.apache.groovy.groovysh.jline.GroovyPosixContext
import org.apache.groovy.groovysh.jline.GroovyPrinter
import org.apache.groovy.groovysh.jline.GroovySystemRegistry
import org.apache.groovy.groovysh.util.DocFinder
import org.codehaus.groovy.tools.shell.util.MessageSource
import org.jline.builtins.ClasspathResourceUtil
-import org.jline.builtins.Completers
import org.jline.builtins.ConfigurationPath
-import org.jline.builtins.Options
-import org.jline.builtins.PosixCommands
-import org.jline.builtins.PosixCommandsRegistry
import org.jline.builtins.SyntaxHighlighter
-import org.jline.console.CommandInput
-import org.jline.console.CommandMethods
import org.jline.console.CommandRegistry
import org.jline.console.ConsoleEngine
import org.jline.console.Printer
-import org.jline.console.impl.JlineCommandRegistry
import org.jline.console.impl.SystemHighlighter
import org.jline.keymap.KeyMap
import org.jline.reader.Binding
-import org.jline.reader.Completer
import org.jline.reader.EndOfFileException
import org.jline.reader.LineReader
import org.jline.reader.LineReader.Option
@@ -55,13 +48,10 @@ import org.jline.reader.Reference
import org.jline.reader.UserInterruptException
import org.jline.reader.impl.DefaultParser
import org.jline.reader.impl.DefaultParser.Bracket
-import org.jline.reader.impl.completer.ArgumentCompleter
-import org.jline.reader.impl.completer.NullCompleter
import org.jline.terminal.Size
import org.jline.terminal.Terminal
import org.jline.terminal.Terminal.Signal
import org.jline.terminal.TerminalBuilder
-import org.jline.utils.InfoCmp.Capability
import org.jline.utils.OSUtils
import org.jline.widget.AutosuggestionWidgets
import org.jline.widget.TailTipWidgets
@@ -74,235 +64,31 @@ import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
-import java.util.function.Function
import java.util.function.Supplier
import static org.jline.jansi.AnsiRenderer.render
/**
* Boots and runs the interactive {@code groovysh} console.
+ * <p>
+ * Applications that embed groovysh should call {@link #start(GroovyshOptions, String[])}
+ * (or the binding-only {@link #start(Map, String[])} overload) rather than
+ * {@link #main(String[])}, which calls {@link System#exit(int)}.
*/
@SuppressWarnings('deprecation')
class Main {
private static final MessageSource messages = new MessageSource(Main)
/** Preference key that controls whether groovysh starts in interpreter mode. */
public static final String INTERPRETER_MODE_PREFERENCE_KEY = 'interpreterMode'
-// private static POSIX_CMDS = []
- private static GROOVY_POSIX_CMDS = ['/ls', '/wc', '/sort', '/head', '/tail', '/cat', '/grep']
+ private static final List<String> GROOVY_POSIX_CMDS = DefaultExtraConsoleCommands.POSIX_COMMANDS
/**
- * Registers the extra console commands that augment the interactive shell.
+ * @deprecated since 7.0.0, use {@link DefaultExtraConsoleCommands}
*/
- @SuppressWarnings("resource")
- protected static class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegistry {
- private final LineReader reader
- private final GroovyEngine scriptEngine
- private PosixCommandsRegistry posix
- private final Map<String, String[]> usage = [:]
-
- /**
- * Creates the auxiliary console command registry used by the shell.
- *
- * @param workDir initial working directory
- * @param scriptEngine script engine backing the shell session
- * @param reader active line reader
- */
+ @Deprecated(since = '7.0.0')
+ protected static class ExtraConsoleCommands extends DefaultExtraConsoleCommands {
ExtraConsoleCommands(Path workDir, GroovyEngine scriptEngine, LineReader reader) {
- super()
- this.scriptEngine = scriptEngine
- this.reader = reader
- def terminal = reader.terminal
- def context = new GroovyPosixContext(
- terminal.input(),
- new PrintStream(terminal.output()),
- new PrintStream(terminal.output()),
- workDir,
- terminal,
- scriptEngine::get
- )
- posix = new PosixCommandsRegistry(context)
- def cmds = [
- '/clear': new CommandMethods((Function) this::clear, this::defaultCompleter),
- '/pwd' : new CommandMethods((Function) this::pwd, this::defaultCompleter),
- '/cd' : new CommandMethods((Function) this::cd, this::optDirCompleter),
- '/date' : new CommandMethods((Function) this::date, this::defaultCompleter),
- '/echo' : new CommandMethods((Function) this::echo, this::defaultCompleter),
- "/!" : new CommandMethods((Function) this::shell, this::defaultCompleter)
- ]
- GROOVY_POSIX_CMDS.each { String cmd ->
- String base = cmd[1..-1]
- usage[cmd] = adjustUsage(base, cmd)
- posix.register(cmd, PosixCommands::"$base")
- cmds.put(cmd, new CommandMethods((Function) this::posixCommand, this::optFileCompleter))
- }
-// POSIX_CMDS.each { String cmd ->
-// String orig = cmd[1..-1]
-// usage[cmd] = adjustUsage(orig, cmd)
-// posix.register(cmd, PosixCommands::"$orig")
-// cmds.put(cmd, new CommandMethods((Function) this::posix, this::optFileCompleter))
-// }
- posix.register('cd', PosixCommands::cd)
- posix.register('/cd', PosixCommands::cd)
- posix.register('/pwd', PosixCommands::pwd)
- posix.register('/date', PosixCommands::date)
- posix.register('/echo', PosixCommands::echo)
- registerCommands(cmds)
- }
-
- /**
- * Returns the current working directory tracked by the POSIX command context.
- *
- * @return the active working directory
- */
- Path currentDir() {
- posix.context.currentDir
- }
-
- private String[] adjustUsage(String from, String to) {
- try {
- posix.execute(from, [from, '--help'] as String[])
- } catch (Options.HelpException e) {
- e.message.readLines()*.replaceAll("$from ", "$to ") as String[]
- }
- }
-
- /**
- * Returns the help-group name used for the extra console commands.
- *
- * @return the console command group name
- */
- @Override
- String name() {
- 'Console Commands'
- }
-
- private Terminal terminal() {
- return reader?.terminal
- }
-
- private List<Completer> optFileCompleter(String command) {
- [new ArgumentCompleter(NullCompleter.INSTANCE, new Completers.OptionCompleter(new Completers.FilesCompleter(this::currentDir), this::commandOptions, 1))]
- }
-
- private List<Completer> optDirCompleter(String command) {
- [new ArgumentCompleter(NullCompleter.INSTANCE, new Completers.OptionCompleter(new Completers.DirectoriesCompleter(this::currentDir), this::commandOptions, 1))]
- }
-
- private void pwd(CommandInput input) {
- posix(adjustUsage('pwd', '/pwd'), input)
- }
-
- private void cd(CommandInput input) {
- try {
- parseOptions(adjustUsage('cd', '/cd'), input.args())
- PosixCommands.cd(context(input), ['/cd', *input.args()] as String[], { Path newPath ->
- posix.context.currentDir = newPath
- scriptEngine.put('PWD', newPath)
- })
- } catch (Exception e) {
- saveException(e)
- }
- }
-
- private void posixCommand(CommandInput input) {
- try {
- String cmd = input.command()
- String name = cmd[1..-1]
- GroovyPosixCommands."$name"(context(input), [cmd, *input.xargs()] as Object[])
- } catch (Exception e) {
- saveException(e)
- }
- }
-
- private GroovyPosixContext context(CommandInput input) {
- GroovyPosixContext ctx = new GroovyPosixContext(input.in(), input.out(), input.err(),
- posix.context.currentDir(), input.terminal(), scriptEngine::get)
- ctx
- }
-
- private void date(CommandInput input) {
- posix(adjustUsage('date', '/date'), input)
- }
-
- private void posix(CommandInput input) {
- posix(usage[input.command()], input)
- }
-
- private void posix(String[] usage, CommandInput input) {
- try {
- parseOptions(usage, input.args())
- PosixCommands."${input.command()[1..-1]}"(context(input), [input.command(), *input.args()] as String[])
-// posix.execute(input.command(), [input.command(), *input.args()] as String[])
- } catch (Exception e) {
- saveException(e)
- }
- }
-
- private void clear(CommandInput input) {
- final String[] usage = [
- "/clear - clear terminal",
- "Usage: /clear",
- " -? --help Displays command help"
- ]
- try {
- parseOptions(usage, input.args())
- terminal().puts(Capability.clear_screen)
- terminal().flush()
- } catch (Exception e) {
- saveException(e)
- }
- }
-
- private void echo(CommandInput input) {
- posix(adjustUsage('echo', '/echo'), input)
- }
-
- private static void executeCommand(List<String> args) throws Exception {
- def sout = new StringBuilder(), serr = new StringBuilder()
- def command = OSUtils.IS_WINDOWS ? ['cmd.exe', '/c'] : ['sh', '-c']
- def proc = new ProcessBuilder().command(command + args.join(' ')).start()
- proc.consumeProcessOutput(sout, serr)
- int exitCode = proc.waitFor()
- if (sout.size()) print sout
- if (exitCode != 0) {
- if (serr.size()) print serr
- throw new Exception("Error occurred in shell!")
- }
- }
-
- private void shell(CommandInput input) {
- final String[] usage = [
- "/!<command> - execute shell command",
- "Usage: /!<command>",
- " -? --help Displays command help"
- ]
- if (input.args().length == 1 && (input.args()[0].equals("-?") || input.args()[0].equals("--help"))) {
- try {
- parseOptions(usage, input.args())
- } catch (Exception e) {
- saveException(e)
- }
- } else {
- List<String> argv = input.args().toList()
- if (!argv.isEmpty()) {
- try {
- executeCommand(argv)
- } catch (Exception e) {
- saveException(e)
- }
- }
- }
- }
-
- /**
- * Returns summary text for the specified command.
- *
- * @param command command name to describe
- * @return help lines for the command
- */
- @Override
- List<String> commandInfo(String command) {
- posix.commandNames.toList()
+ super(workDir, scriptEngine, reader)
}
}
@@ -425,6 +211,28 @@ class Main {
* @since 6.0.0
*/
static int start(Map<String, ?> initialBindings = Collections.emptyMap(), String[] args = new String[0]) {
+ def builder = GroovyshOptions.builder()
+ if (initialBindings) {
+ builder.bindings(initialBindings)
+ }
+ start(builder.build(), args)
+ }
+
+ /**
+ * Programmatic entry point for embedding groovysh with compiler
+ * configuration, extra command registries, prompt, result/error
+ * handlers, and related hooks. Binding variables belong in
+ * {@link GroovyshOptions#getBindings()}, not as ad-hoc map keys.
+ *
+ * @param groovyshOptions embedding options, must not be {@code null}
+ * @param args CLI-like arguments (same as {@link #main(String[])})
+ * @return process exit code (0 for success)
+ *
+ * @since 7.0.0
+ */
+ @Incubating
+ static int start(GroovyshOptions groovyshOptions, String[] args = new String[0]) {
+ Objects.requireNonNull(groovyshOptions, 'groovyshOptions')
def cli = new CliBuilderInternal(usage: 'groovysh [options] [...]', stopAtNonOption: false,
header: messages['cli.option.header'])
cli.with {
@@ -469,18 +277,21 @@ class Main {
parser.blockCommentDelims(new DefaultParser.BlockCommentDelims('/*', '*/'))
.lineCommentDelims(new String[]{'//'})
.setEofOnUnclosedBracket(Bracket.CURLY, Bracket.ROUND, Bracket.SQUARE)
- Terminal terminal = TerminalBuilder.builder().tap{
- if (options.T) {
- type(options.T)
- }
- if (options.c) {
- encoding(options.c)
- }
- if (options.C) {
- color(options.C as boolean)
- }
- name('groovysh')
- }.build()
+ Terminal terminal = groovyshOptions.terminal
+ if (terminal == null) {
+ terminal = TerminalBuilder.builder().tap{
+ if (options.T) {
+ type(options.T)
+ }
+ if (options.c) {
+ encoding(options.c)
+ }
+ if (options.C) {
+ color(options.C as boolean)
+ }
+ name('groovysh')
+ }.build()
+ }
if (terminal.columns == 0 || terminal.rows == 0) {
terminal.size = new Size(120, 40) // hard-coded terminal size when redirecting
}
@@ -492,13 +303,11 @@ class Main {
ConfigurationPath configPath = new ConfigurationPath(root, userStateDirectory)
// ScriptEngine and command registries
- GroovyEngine scriptEngine = new GroovyEngine()
+ GroovyEngine scriptEngine = groovyshOptions.engine ?: new GroovyEngine(groovyshOptions.compilerConfiguration)
- if (initialBindings) {
- initialBindings.each { k, v ->
- if (k != null) {
- scriptEngine.put(k, v)
- }
+ groovyshOptions.bindings.each { k, v ->
+ if (k != null) {
+ scriptEngine.put(k, v)
}
}
@@ -538,7 +347,7 @@ class Main {
LineReader.BLINK_MATCHING_PAREN, 0) // if enabled cursor remains in begin parenthesis (gitbash)
}
- def extra = new ExtraConsoleCommands(Paths.get(System.getProperty('user.dir')), scriptEngine, reader)
+ def extra = new DefaultExtraConsoleCommands(Paths.get(System.getProperty('user.dir')), scriptEngine, reader)
Supplier<Path> workDir = extra::currentDir
scriptEngine.put('PWD', workDir.get())
@@ -554,7 +363,9 @@ class Main {
GroovySystemRegistry systemRegistry = new GroovySystemRegistry(parser, terminal, workDir, configPath).tap {
groupCommandsInHelp(false)
- setCommandRegistries(extra, consoleEngine, builtins, groovy)
+ def registries = [extra, consoleEngine, builtins, groovy]
+ registries.addAll(groovyshOptions.extraCommandRegistries)
+ setCommandRegistries(registries.toArray(new CommandRegistry[0]))
addCompleter(scriptEngine.scriptCompleter)
setScriptDescription(scriptEngine::scriptDescription)
renameLocal 'exit', '/exit'
@@ -589,14 +400,18 @@ class Main {
systemRegistry.initialize(init.toFile())
}
- if (options.q) {
- println render(messages.format('cli.info.version', GroovySystem.version))
- } else {
- println render(messages.format('startup_banner.0', GroovySystem.version, System.properties['java.version'], terminal.type))
- println render(messages['startup_banner.1'])
- println render(messages['startup_banner.2'])
+ Supplier<String> prompt = groovyshOptions.prompt ?: ({ 'groovy> ' } as Supplier<String>)
+
+ if (groovyshOptions.showBanner) {
+ if (options.q) {
+ println render(messages.format('cli.info.version', GroovySystem.version))
+ } else {
+ println render(messages.format('startup_banner.0', GroovySystem.version, System.properties['java.version'], terminal.type))
+ println render(messages['startup_banner.1'])
+ println render(messages['startup_banner.2'])
+ }
+ println '-' * (terminal.columns - 1)
}
- println '-' * (terminal.columns - 1)
// for debugging
// def index = 0
// def lines = ['/slurp /Users/paulk/Projects/groovy/subprojects/groovy-json/src/test/resources/groovy9802.json',
@@ -615,7 +430,7 @@ class Main {
} else {
// for debugging
// line = lines[index++]
- line = reader.readLine("groovy> ")
+ line = reader.readLine(prompt.get())
}
line = line.readLines().collect{ s ->
// remove Groovy continuation character for repl not Groovy's sake
@@ -629,7 +444,7 @@ class Main {
}
}
Object result = systemRegistry.execute(line)
- consoleEngine.println(result?.toString())
+ emitResult(consoleEngine, result, groovyshOptions.resultHandler)
// consoleEngine.println([(Printer.OBJECT_TO_STRING): [(Object) : { o -> o.toString() }] ], result)
} catch (UserInterruptException e) {
// Ignore
@@ -637,14 +452,14 @@ class Main {
String pl = e.getPartialLine()
if (pl != null) { // execute last line from redirected file (required for Windows)
try {
- consoleEngine.println(systemRegistry.execute(pl))
+ emitResult(consoleEngine, systemRegistry.execute(pl), groovyshOptions.resultHandler)
} catch (Exception e2) {
- systemRegistry.trace(e2)
+ emitError(systemRegistry, e2, groovyshOptions.errorHandler)
}
}
break
} catch (Exception | Error e) {
- systemRegistry.trace(e) // print exception and save it to console variable
+ emitError(systemRegistry, e, groovyshOptions.errorHandler)
}
}
systemRegistry.close() // persist pipeline completer names etc
@@ -669,7 +484,23 @@ class Main {
* @since 6.0.0
*/
static int start(String[] args) {
- start([:], args)
+ start(GroovyshOptions.builder().build(), args)
+ }
+
+ private static void emitResult(ConsoleEngine console, Object result, GroovyshOptions.ResultHandler handler) throws Exception {
+ if (handler != null) {
+ handler.handle(console, result)
+ } else {
+ console.println(result?.toString())
+ }
+ }
+
+ private static void emitError(GroovySystemRegistry registry, Throwable error, GroovyshOptions.ErrorHandler handler) {
+ if (handler != null) {
+ handler.handle(registry, error)
+ } else {
+ registry.trace(error)
+ }
}
/**
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
index 9d0b5cf82d..cceb00c408 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
@@ -22,6 +22,7 @@ import groovy.lang.MissingPropertyException;
import groovy.lang.Script;
import org.apache.groovy.ast.tools.ImmutablePropertyUtils;
import org.apache.groovy.groovysh.Main;
+import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.ModuleImportHelper;
import org.codehaus.groovy.control.MultipleCompilationErrorsException;
import org.codehaus.groovy.control.messages.SyntaxErrorMessage;
@@ -165,6 +166,7 @@ public class GroovyEngine implements ScriptEngine {
"java.math.BigDecimal");
private final Map<String, Class<?>> defaultNameClass = new TreeMap<>();
private final GroovyShell shell;
+ private final CompilerConfiguration compilerConfiguration;
/** Shared binding that stores variables visible to interactive scripts. */
protected Binding sharedData;
private final List<Snippet> snippets = new ArrayList<>();
@@ -207,17 +209,105 @@ public class GroovyEngine implements ScriptEngine {
* Constructs a new GroovyEngine with default configuration.
*/
public GroovyEngine() {
- sharedData = new Binding();
-// for debugging
-// sharedData.setVariable("engine", this);
- classLoader = new EngineClassLoader();
- shell = new GroovyShell(classLoader, sharedData);
+ this(null, null, null);
+ }
+
+ /**
+ * Constructs a GroovyEngine that evaluates with the given compiler configuration.
+ * <p>
+ * Embedders that previously installed an {@code ImportCustomizer} or
+ * {@code ASTTransformationCustomizer} (for example {@code ThreadInterrupt})
+ * on groovysh's {@link CompilerConfiguration} should pass that configuration
+ * here. {@link CompilerConfiguration#DEFAULT} is immutable; start from
+ * {@code new CompilerConfiguration()} when adding customizers.
+ *
+ * @param configuration compiler configuration, or {@code null} for
+ * {@link CompilerConfiguration#DEFAULT}
+ * @since 7.0.0
+ */
+ public GroovyEngine(CompilerConfiguration configuration) {
+ this(null, null, configuration);
+ }
+
+ /**
+ * Constructs a GroovyEngine with an explicit parent class loader, binding,
+ * and compiler configuration.
+ *
+ * @param parent parent class loader, or {@code null} to use the thread
+ * context class loader
+ * @param binding shared binding, or {@code null} to create a new one
+ * @param configuration compiler configuration, or {@code null} for
+ * {@link CompilerConfiguration#DEFAULT}
+ * @since 7.0.0
+ */
+ public GroovyEngine(ClassLoader parent, Binding binding, CompilerConfiguration configuration) {
+ sharedData = binding != null ? binding : new Binding();
+ compilerConfiguration = configuration != null ? configuration : CompilerConfiguration.DEFAULT;
+ classLoader = resolveClassLoader(parent, compilerConfiguration);
+ GroovyShell created = createShell(classLoader, sharedData, compilerConfiguration);
+ if (created.getClassLoader() != classLoader) {
+ throw new IllegalStateException(
+ "createShell must return a GroovyShell whose class loader is the engine class loader");
+ }
+ shell = created;
for (String s : DEFAULT_IMPORTS) {
addToNameClass(s, defaultNameClass);
}
nameClass = new HashMap<>(defaultNameClass);
}
+ private static EngineClassLoader resolveClassLoader(ClassLoader parent, CompilerConfiguration configuration) {
+ if (parent instanceof EngineClassLoader
+ && ((EngineClassLoader) parent).hasCompatibleConfiguration(configuration)) {
+ return (EngineClassLoader) parent;
+ }
+ if (parent != null) {
+ return new EngineClassLoader(parent, configuration);
+ }
+ return new EngineClassLoader(configuration);
+ }
+
+ /**
+ * Creates the {@link GroovyShell} used to evaluate statements.
+ * <p>
+ * Called from the constructor, so overrides must not rely on subclass fields
+ * that are assigned after {@code super(...)} returns. Primitive fields and
+ * static state are safe; instance initializers of the subclass have not
+ * run yet. The returned shell must use {@code classLoader} as its loader
+ * (the default implementation does this via
+ * {@link GroovyClassLoader#hasCompatibleConfiguration(CompilerConfiguration)}),
+ * otherwise engine internals that cast to {@link EngineClassLoader} will fail.
+ *
+ * @param classLoader the engine class loader
+ * @param binding the shared binding
+ * @param configuration the compiler configuration, never {@code null}
+ * @return a shell that uses {@code classLoader} as its loader
+ * @since 7.0.0
+ */
+ protected GroovyShell createShell(ClassLoader classLoader, Binding binding, CompilerConfiguration configuration) {
+ return new GroovyShell(classLoader, binding, configuration);
+ }
+
+ /**
+ * Returns the class loader used to parse and load scripts for this engine.
+ *
+ * @return the engine class loader
+ * @since 7.0.0
+ */
+ public EngineClassLoader getClassLoader() {
+ return classLoader;
+ }
+
+ /**
+ * Returns the compiler configuration this engine was constructed with.
+ *
+ * @return the compiler configuration, never {@code null}
+ * @since 7.0.0
+ */
+ public CompilerConfiguration getCompilerConfiguration() {
+ return compilerConfiguration;
+ }
+
/**
* Returns all defined types mapped to their source definitions.
*
@@ -1150,6 +1240,39 @@ public class GroovyEngine implements ScriptEngine {
super();
}
+ /**
+ * Constructs an EngineClassLoader with the given compiler configuration
+ * and the current thread's context class loader as parent.
+ *
+ * @param configuration compiler configuration, or {@code null} for the default
+ * @since 7.0.0
+ */
+ public EngineClassLoader(CompilerConfiguration configuration) {
+ super(Thread.currentThread().getContextClassLoader(), configuration);
+ }
+
+ /**
+ * Constructs an EngineClassLoader with the given parent class loader.
+ *
+ * @param parent the parent class loader
+ * @since 7.0.0
+ */
+ public EngineClassLoader(ClassLoader parent) {
+ super(parent);
+ }
+
+ /**
+ * Constructs an EngineClassLoader with the given parent class loader
+ * and compiler configuration.
+ *
+ * @param parent the parent class loader
+ * @param configuration compiler configuration, or {@code null} for the default
+ * @since 7.0.0
+ */
+ public EngineClassLoader(ClassLoader parent, CompilerConfiguration configuration) {
+ super(parent, configuration);
+ }
+
/**
* Returns all packages loaded by this class loader.
*
@@ -2177,7 +2300,7 @@ public class GroovyEngine implements ScriptEngine {
sharedData.setVariable(entry.getKey(), obj);
}
groovyEngine.getObjectCloner().purgeCache();
- shell = new GroovyShell(groovyEngine.shell.getClassLoader(), sharedData);
+ shell = new GroovyShell(groovyEngine.shell.getClassLoader(), sharedData, groovyEngine.compilerConfiguration);
try {
File file = OSUtils.IS_WINDOWS ? new File("NUL") : new File("/dev/null");
OutputStream outputStream = new FileOutputStream(file);
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/package.html b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/package.html
index c5e2d1f40c..ecef33a9b1 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/package.html
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/package.html
@@ -23,6 +23,10 @@
<body>
<p>
Provides support for the Groovy Shell (aka. <tt>groovysh</tt>).
+ Applications that embed the shell should use
+ {@link org.apache.groovy.groovysh.Main#start(org.apache.groovy.groovysh.GroovyshOptions, String[])}
+ together with {@link org.apache.groovy.groovysh.GroovyshOptions}
+ and {@link org.apache.groovy.groovysh.jline.GroovyEngine}.
</p>
</body>
</html>
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index a115e96e98..4e851c953f 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -1439,6 +1439,82 @@ image:{reldir_groovysh}/assets/img/widget2.png[Tailtip,width=80%]
You can accept the entire suggestion or a word at a time. Both widgets can be enabled.
+[[GroovyShell-Embedding]]
+=== Embedding groovysh
+
+Applications that host groovysh — a domain-specific console, for
+example — should not copy `Main`. The embedding API is
+`Main.start(GroovyshOptions, String[])`, plus constructors on
+`GroovyEngine` for compile-time customizers.
+
+Do not put embedding hooks into the binding `Map` accepted by
+`Main.start(Map, String[])`. That map is only for variables visible to
+evaluated scripts.
+
+==== Compiler configuration
+
+`GroovyEngine` always evaluates with an explicit
+`CompilerConfiguration` (`CompilerConfiguration.DEFAULT` when you pass
+`null`). Use a *mutable copy* (`new CompilerConfiguration()`), not
+`CompilerConfiguration.DEFAULT`, when adding customizers. Typical embedder customizers are
+`ImportCustomizer` (star and static-star imports that would otherwise
+be prepended as snippets on every evaluation) and
+`ASTTransformationCustomizer(ThreadInterrupt)` (so Ctrl+C can stop a
+tight loop after `Main` interrupts the execute thread).
+
+include::../test/EmbeddingSpecTest.groovy[tags=embed_engine_config,indent=0]
+
+Subclasses that need a different `GroovyShell` can override
+`protected GroovyShell createShell(ClassLoader, Binding, CompilerConfiguration)`.
+That method is called from the constructor: do not read subclass fields
+that are assigned after `super(...)`.
+
+`GroovyEngine.getClassLoader()` is the public accessor for the engine
+class loader (the field is `protected`; Java callers cannot read it
+without a getter).
+
+include::../test/EmbeddingSpecTest.groovy[tags=embed_classloader,indent=0]
+
+==== `GroovyshOptions`
+
+`Main.start(GroovyshOptions, String[])` is the programmatic entry point
+that does not call `System.exit`. The options object is built with
+`GroovyshOptions.builder()` and is incubating in 7.0.
+
+Recognized hooks:
+
+* `compilerConfiguration` / `engine` — compile-time customizers, or a
+ pre-built `GroovyEngine`. If both are set, the engine wins.
+* `bindings` / `binding(name, value)` — variables installed on the
+ engine before the REPL starts.
+* `extraCommandRegistry` — additional JLine `CommandRegistry`
+ instances, appended after groovysh's own registries.
+* `prompt` — `Supplier<String>` invoked on each REPL iteration
+ (default `{ 'groovy> ' }`).
+* `resultHandler` — how values are rendered (default:
+ `console.println(result?.toString())`).
+* `errorHandler` — how execution failures are rendered (default:
+ `SystemRegistry.trace`). `UserInterruptException` and
+ `EndOfFileException` stay in `Main` as control flow.
+* `showBanner` — set `false` to suppress groovysh's banner so the host
+ can print its own.
+* `terminal` — optional JLine `Terminal`; when omitted, `Main` builds
+ one from the CLI flags.
+
+include::../test/EmbeddingSpecTest.groovy[tags=embed_options_help,indent=0]
+
+`Main.start(Map, String[])` remains the binding-only overload and
+delegates to `GroovyshOptions`.
+
+==== Extra console commands
+
+`/clear`, `/pwd`, `/cd`, `/ls`, `/cat`, `/grep`, `/head`, `/tail`,
+`/wc`, `/sort`, `/date`, `/echo` and `/!` live in the public
+`ExtraConsoleCommands` class. `Main` still registers it by default.
+Embedders that build their own `SystemRegistry` can construct it
+directly. The former `Main.ExtraConsoleCommands` nested class remains
+as a deprecated subclass for Groovy callers that compiled against it.
+
[[GroovyShell-Advanced]]
=== Advanced Topics
diff --git a/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy b/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy
new file mode 100644
index 0000000000..28abbe86ee
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.groovysh.spec
+
+import org.apache.groovy.groovysh.GroovyshOptions
+import org.apache.groovy.groovysh.Main
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.junit.jupiter.api.Test
+
+/**
+ * Executable examples for embedding groovysh. Included from
+ * {@code groovysh.adoc}.
+ */
+class EmbeddingSpecTest {
+
+ @Test
+ void compilerConfigurationOnTheEngine() {
+ // tag::embed_engine_config[]
+ def imports = new ImportCustomizer()
+ imports.addStarImports('java.util.concurrent.atomic')
+ def config = new CompilerConfiguration()
+ config.addCompilationCustomizers(imports)
+
+ def engine = new GroovyEngine(config)
+ assert engine.execute('new AtomicInteger(7).get()') == 7
+ // end::embed_engine_config[]
+ }
+
+ @Test
+ void optionsBuilderHelpPath() {
+ // tag::embed_options_help[]
+ int rc = Main.start(GroovyshOptions.builder().showBanner(false).build(), '--help')
+ assert rc == 0
+ // end::embed_options_help[]
+ }
+
+ @Test
+ void classLoaderAccessor() {
+ // tag::embed_classloader[]
+ def engine = new GroovyEngine()
+ assert engine.classLoader instanceof GroovyEngine.EngineClassLoader
+ // end::embed_classloader[]
+ }
+}
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy
new file mode 100644
index 0000000000..ae63d52da6
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.groovysh
+
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.apache.groovy.groovysh.jline.GroovySystemRegistry
+import org.jline.builtins.ClasspathResourceUtil
+import org.jline.builtins.ConfigurationPath
+import org.jline.reader.LineReader
+import org.jline.reader.LineReaderBuilder
+import org.jline.reader.impl.DefaultParser
+import org.jline.terminal.Size
+import org.jline.terminal.Terminal
+import org.jline.terminal.impl.DumbTerminal
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Tests for the public {@link ExtraConsoleCommands} registry. Commands that
+ * write through the POSIX context are asserted via the dumb terminal capture;
+ * help paths are the portable way to exercise option parsing.
+ */
+class ExtraConsoleCommandsTest {
+
+ @TempDir
+ Path workDir
+
+ private GroovyEngine engine
+ private Terminal terminal
+ private ByteArrayOutputStream terminalBytes
+ private LineReader reader
+ private ExtraConsoleCommands extra
+ private GroovySystemRegistry system
+
+ @BeforeEach
+ void setUp() {
+ engine = new GroovyEngine()
+ terminalBytes = new ByteArrayOutputStream()
+ terminal = new DumbTerminal(
+ 'groovysh-test', 'dumb',
+ new ByteArrayInputStream(new byte[0]),
+ terminalBytes,
+ StandardCharsets.UTF_8)
+ terminal.size = new Size(80, 40)
+ reader = LineReaderBuilder.builder()
+ .terminal(terminal)
+ .parser(new DefaultParser(regexCommand: /\/?[a-zA-Z!]+\S*/))
+ .build()
+ extra = new ExtraConsoleCommands(workDir, engine, reader)
+ def root = ClasspathResourceUtil.getResourcePath(Main.getResource('/nanorc'))
+ def configPath = new ConfigurationPath(root, workDir)
+ system = new GroovySystemRegistry(reader.parser, terminal, extra::currentDir, configPath).tap {
+ setCommandRegistries(extra)
+ renameLocal 'exit', '/exit'
+ renameLocal 'help', '/help'
+ }
+ }
+
+ @AfterEach
+ void tearDown() {
+ system?.close()
+ terminal?.close()
+ }
+
+ @Test
+ void registersTheDocumentedCommandSet() {
+ def names = extra.commandNames() as Set
+ assert names.containsAll([
+ '/clear', '/pwd', '/cd', '/date', '/echo', '/!',
+ '/ls', '/wc', '/sort', '/head', '/tail', '/cat', '/grep'
+ ])
+ assert extra.name() == 'Console Commands'
+ assert extra.currentDir() == workDir
+ assert ExtraConsoleCommands.POSIX_COMMANDS == ['/ls', '/wc', '/sort', '/head', '/tail', '/cat', '/grep']
+ }
+
+ @Test
+ void pwdPrintsTheWorkingDirectory() {
+ system.execute('/pwd')
+ assert terminalOutput().contains(workDir.toString())
+ }
+
+ @Test
+ void echoWritesItsArguments() {
+ system.execute('/echo groovysh-extra')
+ assert terminalOutput().contains('groovysh-extra')
+ }
+
+ @Test
+ void dateWritesSomething() {
+ system.execute('/date')
+ assert !terminalOutput().trim().isEmpty()
+ }
+
+ @Test
+ void cdChangesCurrentDirAndPwdBinding() {
+ def child = Files.createDirectory(workDir.resolve('child'))
+ system.execute("/cd ${forwardSlashes(child)}")
+ assert extra.currentDir() == child
+ assert engine.get('PWD') == child
+ }
+
+ @Test
+ void cdToMissingDirectoryLeavesTheWorkingDirectoryUnchanged() {
+ try {
+ system.execute('/cd /this/path/should/not/exist-groovysh')
+ } catch (IOException ignored) {
+ // JLine rethrows the exception saved by the command after it returns
+ }
+ assert extra.currentDir() == workDir
+ }
+
+ @Test
+ void clearAndClearHelpDoNotThrow() {
+ system.execute('/clear')
+ system.execute('/clear --help')
+ }
+
+ @Test
+ void posixHelpAndListingPaths() {
+ def file = Files.writeString(workDir.resolve('sample.txt'), 'hello groovysh\n')
+ def path = forwardSlashes(file)
+ system.execute("/ls ${forwardSlashes(workDir)}")
+ system.execute("/cat ${path}")
+ system.execute("/grep --color=never hello ${path}")
+ system.execute("/head ${path}")
+ system.execute("/tail ${path}")
+ system.execute("/wc ${path}")
+ system.execute("/sort ${path}")
+ assert terminalOutput().contains('hello groovysh')
+ }
+
+ @Test
+ void bangHelpAndEmptyArgs() {
+ system.execute('/! --help')
+ system.execute('/! -?')
+ system.execute('/!')
+ }
+
+ @Test
+ void bangRunsAPortableEcho() {
+ def buf = new ByteArrayOutputStream()
+ def old = System.out
+ System.out = new PrintStream(buf)
+ try {
+ system.execute('/! echo groovysh-bang')
+ } finally {
+ System.out = old
+ }
+ assert buf.toString().contains('groovysh-bang')
+ }
+
+ @Test
+ void commandInfoReturnsPosixCommandNames() {
+ def info = extra.commandInfo('/pwd')
+ assert info instanceof List
+ assert !info.isEmpty()
+ }
+
+ @Test
+ @SuppressWarnings('deprecation')
+ void deprecatedNestedClassStillConstructs() {
+ def nested = new Main.ExtraConsoleCommands(workDir, engine, reader)
+ assert nested.commandNames().contains('/pwd')
+ assert nested.currentDir() == workDir
+ }
+
+ private String terminalOutput() {
+ terminal.writer().flush()
+ terminal.flush()
+ new String(terminalBytes.toByteArray(), StandardCharsets.UTF_8)
+ }
+
+ private static String forwardSlashes(Path path) {
+ path.toString().replace('\\', '/')
+ }
+}
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
new file mode 100644
index 0000000000..66d56b5854
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.groovysh
+
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.jline.console.CommandInput
+import org.jline.console.CommandMethods
+import org.jline.console.CommandRegistry
+import org.jline.console.impl.JlineCommandRegistry
+import org.jline.terminal.Size
+import org.jline.terminal.Terminal
+import org.jline.terminal.impl.DumbTerminal
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Path
+import java.util.function.Function
+
+/**
+ * End-to-end coverage of {@link Main#start(GroovyshOptions, String[])}
+ * embedding hooks. Each test injects a dumb terminal and uses {@code -e}
+ * (or a one-line input stream) so the REPL loop exits on EOF without a TTY.
+ */
+class GroovyshEmbeddingTest {
+
+ @TempDir
+ Path tempHome
+
+ private String oldHome
+ private Terminal terminal
+ private ByteArrayOutputStream terminalBytes
+
+ @BeforeEach
+ void setUp() {
+ oldHome = System.getProperty('user.home')
+ System.setProperty('user.home', tempHome.toString())
+ terminalBytes = new ByteArrayOutputStream()
+ terminal = new DumbTerminal(
+ 'groovysh-test', 'dumb',
+ new ByteArrayInputStream(new byte[0]),
+ terminalBytes,
+ StandardCharsets.UTF_8)
+ terminal.size = new Size(80, 24)
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (oldHome != null) {
+ System.setProperty('user.home', oldHome)
+ }
+ terminal?.close()
+ }
+
+ @Test
+ void startWithHelpAndVersionStillWork() {
+ assert Main.start('--help') == 0
+ assert Main.start('--version') == 0
+ assert Main.start(foo: 1, '--help') == 0
+ }
+
+ @Test
+ void resultHandlerSeesEvaluatedValue() {
+ def results = []
+ int rc = Main.start(options()
+ .resultHandler { c, r -> results << r }
+ .build(), '-e', '2 + 3')
+ assert rc == 0
+ assert 5 in results
+ }
+
+ @Test
+ void bindingsAreVisibleToEvaluatedCode() {
+ def results = []
+ int rc = Main.start(options()
+ .binding('answer', 42)
+ .resultHandler { c, r -> results << r }
+ .build(), '-e', 'answer')
+ assert rc == 0
+ assert 42 in results
+ }
+
+ @Test
+ void compilerConfigurationIsUsedWhenNoEngineIsSupplied() {
+ def imports = new ImportCustomizer()
+ imports.addImports('java.util.concurrent.atomic.AtomicInteger')
+ def config = new CompilerConfiguration()
+ config.addCompilationCustomizers(imports)
+ def results = []
+ int rc = Main.start(options()
+ .compilerConfiguration(config)
+ .resultHandler { c, r -> results << r }
+ .build(), '-e', 'new AtomicInteger(9).get()')
+ assert rc == 0
+ assert 9 in results
+ }
+
+ @Test
+ void prebuiltEngineWinsOverCompilerConfiguration() {
+ def engine = new GroovyEngine()
+ engine.put('marker', 'from-engine')
+ def results = []
+ int rc = Main.start(options()
+ .engine(engine)
+ .compilerConfiguration(new CompilerConfiguration())
+ .resultHandler { c, r -> results << r }
+ .build(), '-e', 'marker')
+ assert rc == 0
+ assert 'from-engine' in results
+ }
+
+ @Test
+ void extraCommandRegistryIsInvoked() {
+ def ping = new PingRegistry()
+ int rc = Main.start(options()
+ .extraCommandRegistry(ping)
+ .resultHandler { c, r -> }
+ .build(), '-e', '/ping')
+ assert rc == 0
+ assert ping.hits == ['ping']
+ }
+
+ @Test
+ void errorHandlerReceivesThrownFailures() {
+ def errors = []
+ int rc = Main.start(options()
+ .resultHandler { c, r -> }
+ .errorHandler { r, t -> errors << t }
+ .build(), '-e', 'throw new RuntimeException("boom")')
+ assert rc == 0
+ assert errors.any { containsMessage(it, 'boom') }
+ }
+
+ @Test
+ void bannerCanBeSuppressed() {
+ def captured = captureStdout {
+ Main.start(options().resultHandler { c, r -> }.build(), '-e', '1')
+ }
+ assert !captured.contains('Groovy Shell')
+ }
+
+ @Test
+ void bannerIsPrintedWhenEnabled() {
+ def captured = captureStdout {
+ Main.start(options()
+ .showBanner(true)
+ .resultHandler { c, r -> }
+ .build(), '-e', '1')
+ }
+ assert captured.contains('Groovy Shell')
+ }
+
+ @Test
+ void quietBannerStillPrintsVersionWhenBannerEnabled() {
+ def captured = captureStdout {
+ Main.start(options()
+ .showBanner(true)
+ .resultHandler { c, r -> }
+ .build(), '-q', '-e', '1')
+ }
+ assert captured.contains(GroovySystem.version)
+ }
+
+ @Test
+ void customPromptIsUsedWhenReadingALine() {
+ terminal.close()
+ terminalBytes = new ByteArrayOutputStream()
+ terminal = new DumbTerminal(
+ 'groovysh-test', 'dumb',
+ new ByteArrayInputStream('1+1\n'.getBytes(StandardCharsets.UTF_8)),
+ terminalBytes,
+ StandardCharsets.UTF_8)
+ terminal.size = new Size(80, 24)
+ def prompts = []
+ int rc = Main.start(options()
+ .prompt {
+ prompts << 'gremlin> '
+ 'gremlin> '
+ }
+ .resultHandler { c, r -> }
+ .build())
+ assert rc == 0
+ assert prompts.contains('gremlin> ')
+ }
+
+ @Test
+ void startRejectsNullOptions() {
+ def thrown = false
+ try {
+ Main.start((GroovyshOptions) null, new String[0])
+ } catch (NullPointerException ignored) {
+ thrown = true
+ }
+ assert thrown
+ }
+
+ private GroovyshOptions.Builder options() {
+ GroovyshOptions.builder().terminal(terminal).showBanner(false)
+ }
+
+ private static String captureStdout(Closure<?> body) {
+ def buf = new ByteArrayOutputStream()
+ def old = System.out
+ System.out = new PrintStream(buf)
+ try {
+ body.call()
+ } finally {
+ System.out = old
+ }
+ buf.toString()
+ }
+
+ private static boolean containsMessage(Throwable t, String text) {
+ while (t != null) {
+ if (t.message?.contains(text)) {
+ return true
+ }
+ t = t.cause
+ }
+ false
+ }
+
+ private static class PingRegistry extends JlineCommandRegistry implements CommandRegistry {
+ final List<String> hits = []
+
+ PingRegistry() {
+ registerCommands([
+ '/ping': new CommandMethods((Function) this::ping, this::defaultCompleter)
+ ])
+ }
+
+ @Override
+ String name() {
+ 'Ping'
+ }
+
+ @Override
+ List<String> commandInfo(String command) {
+ ['ping the extra registry']
+ }
+
+ private void ping(CommandInput input) {
+ hits << 'ping'
+ }
+ }
+}
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy
new file mode 100644
index 0000000000..806a033012
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.groovysh
+
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.jline.console.CommandRegistry
+import org.jline.console.impl.JlineCommandRegistry
+import org.jline.terminal.impl.DumbTerminal
+import org.junit.jupiter.api.Test
+
+import java.nio.charset.StandardCharsets
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Direct tests for {@link GroovyshOptions} builder defaults, copying, and
+ * null-handling. The REPL wiring of these options is covered by
+ * {@link GroovyshEmbeddingTest}.
+ */
+class GroovyshOptionsTest {
+
+ @Test
+ void defaults() {
+ def options = GroovyshOptions.builder().build()
+ assert options.compilerConfiguration == null
+ assert options.engine == null
+ assert options.bindings.isEmpty()
+ assert options.extraCommandRegistries.isEmpty()
+ assert options.prompt == null
+ assert options.resultHandler == null
+ assert options.errorHandler == null
+ assert options.showBanner
+ assert options.terminal == null
+ }
+
+ @Test
+ void bindingsReplaceThenBindingAdds() {
+ def options = GroovyshOptions.builder()
+ .bindings(a: 1, b: 2)
+ .binding('c', 3)
+ .build()
+ assert options.bindings == [a: 1, b: 2, c: 3]
+ }
+
+ @Test
+ void bindingsNullClears() {
+ def options = GroovyshOptions.builder()
+ .bindings(a: 1)
+ .bindings(null)
+ .build()
+ assert options.bindings.isEmpty()
+ }
+
+ @Test
+ void nullBindingKeysAreDropped() {
+ Map<String, Object> raw = new LinkedHashMap<>()
+ raw.put(null, 1)
+ raw.put('ok', 2)
+ def options = GroovyshOptions.builder().bindings(raw).build()
+ assert options.bindings == [ok: 2]
+ }
+
+ @Test
+ void extraRegistriesAppend() {
+ def first = new DummyRegistry('first')
+ def second = new DummyRegistry('second')
+ def options = GroovyshOptions.builder()
+ .extraCommandRegistry(first)
+ .extraCommandRegistries([second])
+ .build()
+ assert options.extraCommandRegistries == [first, second]
+ }
+
+ @Test
+ void extraCommandRegistriesNullIsIgnored() {
+ def options = GroovyshOptions.builder()
+ .extraCommandRegistries(null)
+ .build()
+ assert options.extraCommandRegistries.isEmpty()
+ }
+
+ @Test
+ void extraCommandRegistryRejectsNull() {
+ shouldFail(NullPointerException) {
+ GroovyshOptions.builder().extraCommandRegistry(null)
+ }
+ }
+
+ @Test
+ void bindingRejectsNullName() {
+ shouldFail(NullPointerException) {
+ GroovyshOptions.builder().binding(null, 1)
+ }
+ }
+
+ @Test
+ void mapsAndListsAreUnmodifiable() {
+ def options = GroovyshOptions.builder()
+ .binding('a', 1)
+ .extraCommandRegistry(new DummyRegistry('d'))
+ .build()
+ shouldFail(UnsupportedOperationException) {
+ options.bindings.put('b', 2)
+ }
+ shouldFail(UnsupportedOperationException) {
+ options.extraCommandRegistries.add(new DummyRegistry('x'))
+ }
+ }
+
+ @Test
+ void remainingSettersRoundTrip() {
+ def config = new CompilerConfiguration()
+ def engine = new GroovyEngine()
+ def prompt = { 'gremlin> ' }
+ GroovyshOptions.ResultHandler results = { c, r -> }
+ GroovyshOptions.ErrorHandler errors = { r, t -> }
+ def terminal = new DumbTerminal(
+ 'groovysh-test', 'dumb',
+ new ByteArrayInputStream(new byte[0]),
+ new ByteArrayOutputStream(),
+ StandardCharsets.UTF_8)
+ try {
+ def options = GroovyshOptions.builder()
+ .compilerConfiguration(config)
+ .engine(engine)
+ .prompt(prompt)
+ .resultHandler(results)
+ .errorHandler(errors)
+ .showBanner(false)
+ .terminal(terminal)
+ .build()
+ assert options.compilerConfiguration.is(config)
+ assert options.engine.is(engine)
+ assert options.prompt.get() == 'gremlin> '
+ assert options.resultHandler.is(results)
+ assert options.errorHandler.is(errors)
+ assert !options.showBanner
+ assert options.terminal.is(terminal)
+ } finally {
+ terminal.close()
+ }
+ }
+
+ private static class DummyRegistry extends JlineCommandRegistry implements CommandRegistry {
+ private final String group
+
+ DummyRegistry(String group) {
+ this.group = group
+ }
+
+ @Override
+ String name() {
+ group
+ }
+ }
+}
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
index 6dda9cb3de..4ebf153bc3 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
@@ -18,8 +18,20 @@
*/
package org.apache.groovy.groovysh.jline
+import groovy.lang.Binding
+import groovy.lang.GroovyClassLoader
+import groovy.lang.GroovyShell
+import groovy.transform.ThreadInterrupt
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.jline.console.CmdLine
import org.junit.jupiter.api.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
/**
* Direct tests for {@link GroovyEngine}. The engine is the foundation of the
* groovysh stack — it holds the binding, runs scripts, and tracks user-defined
@@ -162,4 +174,174 @@ class GroovyEngineTest {
engine.execute('greet = { name -> "hi, $name" }')
assert engine.execute("greet('paul')") == 'hi, paul'
}
+
+ @Test
+ void defaultConstructorUsesDefaultCompilerConfiguration() {
+ def engine = new GroovyEngine()
+ assert engine.compilerConfiguration.is(CompilerConfiguration.DEFAULT)
+ assert engine.classLoader.hasCompatibleConfiguration(CompilerConfiguration.DEFAULT)
+ assert engine.classLoader.is(engine.getClassLoader())
+ }
+
+ @Test
+ void nullCompilerConfigurationUsesDefault() {
+ def engine = new GroovyEngine((CompilerConfiguration) null)
+ assert engine.compilerConfiguration.is(CompilerConfiguration.DEFAULT)
+ assert engine.execute('2 + 2') == 4
+ }
+
+ @Test
+ void importCustomizerMakesTypesAvailableWithoutSnippetImports() {
+ def imports = new ImportCustomizer()
+ imports.addImports('java.util.concurrent.atomic.AtomicInteger')
+ def config = new CompilerConfiguration()
+ config.addCompilationCustomizers(imports)
+ def engine = new GroovyEngine(config)
+
+ assert engine.execute('new AtomicInteger(7).get()') == 7
+ assert engine.imports.isEmpty()
+ }
+
+ @Test
+ void starAndStaticStarImportCustomizers() {
+ def imports = new ImportCustomizer()
+ imports.addStarImports('java.util.concurrent.atomic')
+ imports.addStaticStars('java.lang.Math')
+ def config = new CompilerConfiguration()
+ config.addCompilationCustomizers(imports)
+ def engine = new GroovyEngine(config)
+
+ assert engine.execute('new AtomicInteger(3).get()') == 3
+ assert engine.execute('abs(-4)') == 4
+ }
+
+ @Test
+ void sharedBindingAndParentClassLoaderAreHonoured() {
+ def parent = new URLClassLoader(new URL[0], ClassLoader.systemClassLoader)
+ def binding = new Binding(foo: 11)
+ def config = new CompilerConfiguration()
+ def engine = new GroovyEngine(parent, binding, config)
+
+ assert engine.classLoader.parent.is(parent)
+ assert engine.execute('foo') == 11
+ engine.execute('bar = 22')
+ assert binding.getVariable('bar') == 22
+ assert engine.compilerConfiguration.is(config)
+ }
+
+ @Test
+ void compatibleEngineClassLoaderIsReused() {
+ def config = new CompilerConfiguration()
+ def loader = new GroovyEngine.EngineClassLoader(config)
+ def engine = new GroovyEngine(loader, new Binding(), config)
+ assert engine.classLoader.is(loader)
+ }
+
+ @Test
+ void incompatibleEngineClassLoaderIsWrapped() {
+ def config1 = new CompilerConfiguration()
+ def config2 = new CompilerConfiguration()
+ def loader = new GroovyEngine.EngineClassLoader(config1)
+ def engine = new GroovyEngine(loader, new Binding(), config2)
+ assert !engine.classLoader.is(loader)
+ assert engine.classLoader.parent.is(loader)
+ assert engine.classLoader.hasCompatibleConfiguration(config2)
+ }
+
+ @Test
+ void createShellIsInvokedFromTheConstructor() {
+ def engine = new RecordingEngine()
+ assert engine.createShellCalls == 1
+ assert engine.execute('1 + 1') == 2
+ }
+
+ @Test
+ void createShellMustKeepTheEngineClassLoader() {
+ def thrown = false
+ try {
+ new MismatchedLoaderEngine()
+ } catch (IllegalStateException e) {
+ thrown = e.message.contains('createShell')
+ }
+ assert thrown
+ }
+
+ @Test
+ void threadInterruptCustomizerStopsALoopOnInterrupt() {
+ def config = new CompilerConfiguration()
+ config.addCompilationCustomizers(new ASTTransformationCustomizer(ThreadInterrupt))
+ def engine = new GroovyEngine(config)
+ def thrown = new AtomicReference<Throwable>()
+ def started = new CountDownLatch(1)
+ engine.put('started', started)
+ def thread = Thread.start {
+ try {
+ engine.execute('started.countDown(); def n = 0; while (true) { n++ }')
+ } catch (Throwable t) {
+ thrown.set(t)
+ }
+ }
+ assert started.await(5, TimeUnit.SECONDS)
+ thread.interrupt()
+ thread.join(5000)
+ assert !thread.alive
+ assert thrown.get() != null
+ assert containsInterrupted(thrown.get())
+ }
+
+ @Test
+ @SuppressWarnings('deprecation')
+ void inspectorStillConstructsAgainstACustomConfiguration() {
+ def imports = new ImportCustomizer()
+ imports.addImports('java.util.concurrent.atomic.AtomicInteger')
+ def config = new CompilerConfiguration()
+ config.addCompilationCustomizers(imports)
+ def engine = new GroovyEngine(config)
+ engine.execute('n = new AtomicInteger(1)')
+ // scriptDescription constructs Inspector, which previously built a GroovyShell
+ // with CompilerConfiguration.DEFAULT and would drop the customizer
+ engine.scriptDescription(new CmdLine('n', 'n', '', ['n'], CmdLine.DescriptionType.COMMAND))
+ assert engine.execute('new AtomicInteger(2).get()') == 2
+ }
+
+ @Test
+ void engineClassLoaderConstructors() {
+ def config = new CompilerConfiguration()
+ def parent = ClassLoader.systemClassLoader
+ assert new GroovyEngine.EngineClassLoader() != null
+ assert new GroovyEngine.EngineClassLoader(config).hasCompatibleConfiguration(config)
+ assert new GroovyEngine.EngineClassLoader(parent).parent.is(parent)
+ assert new GroovyEngine.EngineClassLoader(parent, config).parent.is(parent)
+ }
+
+ private static boolean containsInterrupted(Throwable t) {
+ while (t != null) {
+ if (t instanceof InterruptedException) {
+ return true
+ }
+ t = t.cause
+ }
+ false
+ }
+
+ private static class MismatchedLoaderEngine extends GroovyEngine {
+ @Override
+ protected GroovyShell createShell(ClassLoader classLoader, Binding binding, CompilerConfiguration configuration) {
+ return new GroovyShell(new GroovyClassLoader(), binding, configuration)
+ }
+ }
+
+ private static class RecordingEngine extends GroovyEngine {
+ int createShellCalls
+
+ RecordingEngine() {
+ super()
+ }
+
+ @Override
+ protected GroovyShell createShell(ClassLoader classLoader, Binding binding, CompilerConfiguration configuration) {
+ createShellCalls++
+ return super.createShell(classLoader, binding, configuration)
+ }
+ }
}
(groovy) 03/04: GROOVY-12403: narrow the groovysh deprecation suppressions
Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 00bed69ff95bc3f55bca3465fa3ff6d98ff70911
Author: Paul King <pa...@asert.com.au>
AuthorDate: Mon Sep 14 13:58:43 2026 +1000
GROOVY-12403: narrow the groovysh deprecation suppressions
Main and GroovyEngine each carried a class-level
@SuppressWarnings('deprecation'), together silencing every deprecation
across about five thousand lines, so a new one anywhere in either file
would go unnoticed.
Main's suppressed nothing once the embedding API stopped naming
deprecated JLine types, and is removed. GroovyEngine needs it at five
places: the two scriptDescription overloads, methodDescription and
checkSyntax, which speak JLine's deprecated CmdDesc/CmdLine
description API, and the Inspector's SystemRegistry field.
The module compiles with no deprecation warnings, as before, but now
only those five sites are exempt.
---
.../src/main/groovy/org/apache/groovy/groovysh/Main.groovy | 1 -
.../main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java | 6 +++++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index d2441496e4..8300dc6f77 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -76,7 +76,6 @@ import static org.jline.jansi.AnsiRenderer.render
* (or the binding-only {@link #start(Map, String[])} overload) rather than
* {@link #main(String[])}, which calls {@link System#exit(int)}.
*/
-@SuppressWarnings('deprecation')
class Main {
private static final MessageSource messages = new MessageSource(Main)
/** Preference key that controls whether groovysh starts in interpreter mode. */
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
index 0de65e6075..2a8dbeb635 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
@@ -91,7 +91,6 @@ import java.util.stream.Stream;
* You must be very careful when using GroovyEngine in a multithreaded environment. The Binding instance is not
* thread safe, and it is shared by all scripts.
*/
-@SuppressWarnings("deprecation")
public class GroovyEngine implements ScriptEngine {
/**
* Serialization/deserialization format options.
@@ -1070,6 +1069,7 @@ public class GroovyEngine implements ScriptEngine {
* @param line the command line to describe
* @return the command description
*/
+ @SuppressWarnings("deprecation") // org.jline.console command-description API
public CmdDesc scriptDescription(CmdLine line) {
return new Inspector(this).scriptDescription(line);
}
@@ -1920,6 +1920,7 @@ public class GroovyEngine implements ScriptEngine {
private static final List<String> VALUES = Arrays.asList("true", "false");
private static final String REGEX_GET_METHOD = "get[A-Z].*";
private final GroovyEngine groovyEngine;
+ @SuppressWarnings("deprecation") // org.jline.console.SystemRegistry
private final SystemRegistry systemRegistry = SystemRegistry.get();
private Inspector inspector;
private AccessRules access;
@@ -2578,6 +2579,7 @@ public class GroovyEngine implements ScriptEngine {
* @param line the command line to describe
* @return the command description
*/
+ @SuppressWarnings("deprecation") // org.jline.console command-description API
public CmdDesc scriptDescription(CmdLine line) {
CmdDesc out = null;
try {
@@ -2625,6 +2627,7 @@ public class GroovyEngine implements ScriptEngine {
return out;
}
+ @SuppressWarnings("deprecation") // org.jline.console command-description API
private CmdDesc methodDescription(CmdLine line) {
CmdDesc out = new CmdDesc();
List<String> args = line.getArgs();
@@ -2804,6 +2807,7 @@ public class GroovyEngine implements ScriptEngine {
return out;
}
+ @SuppressWarnings("deprecation") // org.jline.console command-description API
private CmdDesc checkSyntax(CmdLine line) {
CmdDesc out = new CmdDesc();
int openingRound = Brackets.indexOfOpeningRound(line.getHead());
(groovy) 02/04: GROOVY-12403: review follow-ups for the groovysh embedding API
Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 9aed6196a8cc0906c12d4bbfd14fbc670f27a7d2
Author: Paul King <pa...@asert.com.au>
AuthorDate: Mon Sep 14 13:42:01 2026 +1000
GROOVY-12403: review follow-ups for the groovysh embedding API
Builds on the previous commit.
Correctness:
- ExtraConsoleCommands.commandInfo(String) ignored its argument and
returned the whole POSIX command-name list, so /help described all
thirteen console commands as "date" and completion candidates showed
the same. It now derives a one-line description per command, lazily
and cached, from the usage text the command itself parses. The test
that asserted the old behaviour is replaced by one that pins the new.
Embedding API:
- GroovyshOptions now mirrors JLine's ShellBuilder wherever an
equivalent exists, so a later move onto org.jline.shell need not
break this API: extraCommandRegistry becomes groups(CommandGroup...),
and prompt(String), rightPrompt, historyFile(Path) and
onReaderReady(Consumer<LineReader>) are added. The compiler
configuration, engine, bindings, banner and result/error handlers
have no ShellBuilder equivalent and keep their own names.
- The published API no longer names JLine types deprecated since
JLine 4.0. Command groups arrive as org.jline.shell.CommandGroup and
are adapted with CommandRegistryAdapter; ResultHandler takes a
Printer; ErrorHandler takes the error plus the default renderer.
groovysh's internals stay on org.jline.console.
- Embedder groups are registered ahead of groovysh's own, so a host
console can override a built-in command name.
- historyFile is wired with an explicit null check: Groovy truth on a
Path is Files.exists, so an elvis would discard a history file that
does not exist yet.
Documentation:
- The three embedding examples were included without a listing block
and rendered as prose. They are wrapped now, and the GroovyshOptions
example shows the hooks rather than --help.
- GroovyEngine(ClassLoader, Binding, CompilerConfiguration) documents
that a compatible EngineClassLoader is adopted rather than wrapped,
and that engines sharing one then share its class cache.
- ARCHITECTURE.md records which JLine API groovysh is on, why it has
not moved to org.jline.shell, and what the migration path is.
Housekeeping:
- @Incubating on the rest of the new surface, not just GroovyshOptions.
- ExtraConsoleCommands suppresses the deprecation warning it inherited
from Main before being extracted; GroovyshOptions drops a suppression
it never needed.
- The ThreadInterrupt test uses a daemon thread and a bounded loop, so
a missed interrupt cannot leave a core spinning for the rest of the
forked JVM.
- Unnecessary groovy.lang imports; import order in Main.
- jline-shell is declared explicitly now that it is referenced.
---
subprojects/groovy-groovysh/ARCHITECTURE.md | 38 +++++
subprojects/groovy-groovysh/build.gradle | 1 +
.../groovy/groovysh/ExtraConsoleCommands.groovy | 60 +++++--
.../apache/groovy/groovysh/GroovyshOptions.java | 173 +++++++++++++++++----
.../groovy/org/apache/groovy/groovysh/Main.groovy | 35 +++--
.../apache/groovy/groovysh/jline/GroovyEngine.java | 28 +++-
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 48 ++++--
.../src/spec/test/EmbeddingSpecTest.groovy | 43 ++++-
.../groovysh/ExtraConsoleCommandsTest.groovy | 27 +++-
.../groovy/groovysh/GroovyshEmbeddingTest.groovy | 121 +++++++++-----
.../groovy/groovysh/GroovyshOptionsTest.groovy | 64 ++++----
.../groovy/groovysh/jline/GroovyEngineTest.groovy | 9 +-
12 files changed, 489 insertions(+), 158 deletions(-)
diff --git a/subprojects/groovy-groovysh/ARCHITECTURE.md b/subprojects/groovy-groovysh/ARCHITECTURE.md
index ed2a40309f..be512c493c 100644
--- a/subprojects/groovy-groovysh/ARCHITECTURE.md
+++ b/subprojects/groovy-groovysh/ARCHITECTURE.md
@@ -239,6 +239,44 @@ the public seams rather than copying `Main`:
`/pwd`, `/cd`, the POSIX-style commands, and `/!`.
`Main.ExtraConsoleCommands` is a deprecated subclass.
+Where a `GroovyshOptions` setting has an equivalent on JLine's
+`ShellBuilder`, it uses the same name and parameter type on purpose.
+
+## JLine: which API we are on, and why
+
+groovysh is built on `org.jline.console` — `SystemRegistryImpl`,
+`ConsoleEngineImpl`, `Builtins`, `JlineCommandRegistry`,
+`DefaultPrinter`, `ScriptEngine`. None of those *classes* is
+deprecated, but five interfaces in that package are, as of JLine 4.0:
+`CommandRegistry`, `ConsoleEngine`, `SystemRegistry`, `CommandMethods`,
+`CommandInput` (plus the `CmdDesc` / `CmdLine` / `ArgDesc` description
+types).
+
+The successor is `org.jline.shell` (`Shell`, `CommandDispatcher`,
+`CommandGroup`, `Command`, `ShellBuilder`), a compile-scope transitive
+dependency of `jline-console` and so already on our classpath. We have
+not moved onto it, for one structural reason: it models a *command*
+shell, not a language REPL. `DefaultCommandDispatcher` throws
+`UnknownCommandException` instead of falling through to a script
+engine, and `Shell.run()` discards the value of `dispatcher.execute`,
+so there is nowhere to print a result. Those semantics are exactly
+what the deprecated console layer supplies. In JLine 4.4.3 the console
+implementations contain no references to `org.jline.shell` either, so
+there is no upstream migration to follow yet.
+
+What we do instead: keep the deprecated types out of the *published*
+embedding API. `GroovyshOptions.groups` takes `org.jline.shell.CommandGroup`
+and wraps it with `CommandRegistryAdapter`; the result and error
+handlers take `Printer` and plain JDK types.
+
+Revisit when JLine rebases the console implementations on
+`org.jline.shell`, or announces a removal release for
+`org.jline.console`. The migration path, when it comes, is a
+`CommandDispatcher` subclass: `DefaultCommandDispatcher.execute(String)`
+is public, non-final and returns `Object`, so it can intercept the
+unknown-command path and delegate to `GroovyEngine` while keeping
+JLine's pipelines, redirection and job control.
+
The user-facing description is
[`src/spec/doc/groovysh.adoc`](src/spec/doc/groovysh.adoc)
"Embedding groovysh". Tests live in `GroovyEngineTest`,
diff --git a/subprojects/groovy-groovysh/build.gradle b/subprojects/groovy-groovysh/build.gradle
index 7abbc203d1..f0b9134065 100644
--- a/subprojects/groovy-groovysh/build.gradle
+++ b/subprojects/groovy-groovysh/build.gradle
@@ -46,6 +46,7 @@ dependencies {
implementation "org.jline:jline-builtins:${versions.jline}"
implementation "org.jline:jline-reader:${versions.jline}"
implementation "org.jline:jline-console:${versions.jline}"
+ implementation "org.jline:jline-shell:${versions.jline}"
implementation "org.jline:jline-terminal-jni:${versions.jline}"
implementation("org.jline:jline-terminal:${versions.jline}") {
// exclude(group: 'junit', module: 'junit')
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy
index 6aaf57765f..f0acc3eead 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/ExtraConsoleCommands.groovy
@@ -21,6 +21,7 @@ package org.apache.groovy.groovysh
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.apache.groovy.groovysh.jline.GroovyPosixCommands
import org.apache.groovy.groovysh.jline.GroovyPosixContext
+import org.apache.groovy.lang.annotation.Incubating
import org.jline.builtins.Completers
import org.jline.builtins.Options
import org.jline.builtins.PosixCommands
@@ -38,6 +39,7 @@ import org.jline.utils.InfoCmp.Capability
import org.jline.utils.OSUtils
import java.nio.file.Path
+import java.util.concurrent.ConcurrentHashMap
import java.util.function.Function
/**
@@ -54,7 +56,8 @@ import java.util.function.Function
*
* @since 7.0.0
*/
-@SuppressWarnings('resource')
+@Incubating
+@SuppressWarnings(['resource', 'deprecation'])
class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegistry {
/**
@@ -62,8 +65,21 @@ class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegist
*/
static final List<String> POSIX_COMMANDS = ['/ls', '/wc', '/sort', '/head', '/tail', '/cat', '/grep'].asImmutable()
+ private static final String[] CLEAR_USAGE = [
+ '/clear - clear terminal',
+ 'Usage: /clear',
+ ' -? --help Displays command help'
+ ]
+
+ private static final String[] SHELL_USAGE = [
+ '/!<command> - execute shell command',
+ 'Usage: /!<command>',
+ ' -? --help Displays command help'
+ ]
+
private final LineReader reader
private final GroovyEngine scriptEngine
+ private final Map<String, List<String>> commandInfos = new ConcurrentHashMap<>()
private PosixCommandsRegistry posix
/**
@@ -193,13 +209,8 @@ class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegist
}
private void clear(CommandInput input) {
- final String[] usage = [
- "/clear - clear terminal",
- "Usage: /clear",
- " -? --help Displays command help"
- ]
try {
- parseOptions(usage, input.args())
+ parseOptions(CLEAR_USAGE, input.args())
terminal().puts(Capability.clear_screen)
terminal().flush()
} catch (Exception e) {
@@ -225,14 +236,9 @@ class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegist
}
private void shell(CommandInput input) {
- final String[] usage = [
- "/!<command> - execute shell command",
- "Usage: /!<command>",
- " -? --help Displays command help"
- ]
if (input.args().length == 1 && (input.args()[0].equals("-?") || input.args()[0].equals("--help"))) {
try {
- parseOptions(usage, input.args())
+ parseOptions(SHELL_USAGE, input.args())
} catch (Exception e) {
saveException(e)
}
@@ -249,13 +255,35 @@ class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegist
}
/**
- * Returns summary text for the specified command.
+ * Returns summary text for the specified command. The first entry is the
+ * one-line description JLine renders beside the command in {@code /help}
+ * and in completion candidates.
*
* @param command command name to describe
- * @return help lines for the command
+ * @return help lines for the command, empty when it is not one of ours
*/
@Override
List<String> commandInfo(String command) {
- posix.commandNames.toList()
+ commandInfos.computeIfAbsent(command) { String cmd ->
+ String[] usage = usageFor(cmd)
+ usage == null || usage.length == 0 ? [] : [summarise(usage[0])].asImmutable()
+ }
+ }
+
+ private String[] usageFor(String command) {
+ switch (command) {
+ case '/clear' -> CLEAR_USAGE
+ case '/!' -> SHELL_USAGE
+ default -> hasCommand(command) ? adjustUsage(command[1..-1], command) : null
+ }
+ }
+
+ /**
+ * Reduces a usage headline such as {@code "/ls - list files"} to the
+ * description alone.
+ */
+ private static String summarise(String headline) {
+ int dash = headline.indexOf(' -')
+ (dash < 0 ? headline : headline.substring(dash + 2)).trim()
}
}
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
index 699911e34f..37122ef5a0 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
@@ -21,30 +21,39 @@ package org.apache.groovy.groovysh;
import org.apache.groovy.groovysh.jline.GroovyEngine;
import org.apache.groovy.lang.annotation.Incubating;
import org.codehaus.groovy.control.CompilerConfiguration;
-import org.jline.console.CommandRegistry;
-import org.jline.console.ConsoleEngine;
-import org.jline.console.SystemRegistry;
+import org.jline.console.Printer;
+import org.jline.reader.LineReader;
+import org.jline.shell.CommandGroup;
+import org.jline.shell.ShellBuilder;
import org.jline.terminal.Terminal;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Configuration for embedding groovysh programmatically via
* {@link Main#start(GroovyshOptions, String[])}.
* <p>
+ * Where a setting has an equivalent on JLine's {@link ShellBuilder},
+ * this class deliberately uses the same name and parameter type, so that a future
+ * move of groovysh onto {@code org.jline.shell} is re-plumbing rather than a break
+ * in Groovy's API. The Groovy-specific settings — compiler configuration, engine,
+ * bindings, banner, and the result/error handlers a language REPL needs and a
+ * command shell does not — have no such equivalent.
+ * <p>
* Binding variables stay in {@link #getBindings()}; they are not mixed
* with embedding hooks. Construct with {@link #builder()}.
*
* @since 7.0.0
*/
@Incubating
-@SuppressWarnings("deprecation")
public final class GroovyshOptions {
/**
@@ -55,11 +64,11 @@ public final class GroovyshOptions {
/**
* Renders {@code result} after a line has been executed.
*
- * @param console the console engine used by the session
- * @param result the value returned by {@code SystemRegistry.execute}, which may be {@code null}
+ * @param printer the printer used by the session
+ * @param result the value returned by the command dispatch, which may be {@code null}
* @throws Exception if rendering fails; groovysh then routes it to the error handler
*/
- void handle(ConsoleEngine console, Object result) throws Exception;
+ void handle(Printer printer, Object result) throws Exception;
}
/**
@@ -72,32 +81,41 @@ public final class GroovyshOptions {
* {@code UserInterruptException} and {@code EndOfFileException} are
* not delivered here; {@link Main} treats those as control flow.
*
- * @param registry the system registry for the session
+ * A handler that throws is not caught again: the error escapes the REPL
+ * loop and {@link Main#start(GroovyshOptions, String[])} returns 1.
+ *
* @param error the thrown error or exception
+ * @param defaultTrace groovysh's own renderer, for a handler that wants to delegate
*/
- void handle(SystemRegistry registry, Throwable error);
+ void handle(Throwable error, Consumer<Throwable> defaultTrace);
}
private final CompilerConfiguration compilerConfiguration;
private final GroovyEngine engine;
private final Map<String, Object> bindings;
- private final List<CommandRegistry> extraCommandRegistries;
+ private final List<CommandGroup> groups;
private final Supplier<String> prompt;
+ private final Supplier<String> rightPrompt;
private final ResultHandler resultHandler;
private final ErrorHandler errorHandler;
private final boolean showBanner;
private final Terminal terminal;
+ private final Path historyFile;
+ private final Consumer<LineReader> onReaderReady;
private GroovyshOptions(Builder builder) {
this.compilerConfiguration = builder.compilerConfiguration;
this.engine = builder.engine;
this.bindings = Collections.unmodifiableMap(new LinkedHashMap<>(builder.bindings));
- this.extraCommandRegistries = Collections.unmodifiableList(new ArrayList<>(builder.extraCommandRegistries));
+ this.groups = Collections.unmodifiableList(new ArrayList<>(builder.groups));
this.prompt = builder.prompt;
+ this.rightPrompt = builder.rightPrompt;
this.resultHandler = builder.resultHandler;
this.errorHandler = builder.errorHandler;
this.showBanner = builder.showBanner;
this.terminal = builder.terminal;
+ this.historyFile = builder.historyFile;
+ this.onReaderReady = builder.onReaderReady;
}
/**
@@ -139,12 +157,14 @@ public final class GroovyshOptions {
}
/**
- * Extra command registries appended after groovysh's own registries.
+ * Command groups contributed by the embedder. They are resolved
+ * <em>before</em> groovysh's own commands, so a group may override a
+ * built-in command name.
*
* @return an unmodifiable list, never {@code null}
*/
- public List<CommandRegistry> getExtraCommandRegistries() {
- return extraCommandRegistries;
+ public List<CommandGroup> getGroups() {
+ return groups;
}
/**
@@ -157,7 +177,16 @@ public final class GroovyshOptions {
}
/**
- * Result renderer. {@code null} means print {@code result?.toString()} via the console engine.
+ * Right-hand prompt supplier invoked on each REPL iteration.
+ *
+ * @return the right prompt supplier, or {@code null} for none
+ */
+ public Supplier<String> getRightPrompt() {
+ return rightPrompt;
+ }
+
+ /**
+ * Result renderer. {@code null} means print {@code result?.toString()} via the printer.
*
* @return the result handler, or {@code null}
*/
@@ -166,7 +195,7 @@ public final class GroovyshOptions {
}
/**
- * Error renderer. {@code null} means {@code SystemRegistry.trace(error)}.
+ * Error renderer. {@code null} means groovysh's own trace.
*
* @return the error handler, or {@code null}
*/
@@ -192,6 +221,27 @@ public final class GroovyshOptions {
return terminal;
}
+ /**
+ * History file for the session. {@code null} means groovysh's own
+ * {@code groovysh_history} in the user state directory.
+ *
+ * @return the history file, or {@code null}
+ */
+ public Path getHistoryFile() {
+ return historyFile;
+ }
+
+ /**
+ * Callback invoked once the {@link LineReader} has been built, for settings
+ * this class does not model — the secondary prompt pattern, key bindings,
+ * reader options.
+ *
+ * @return the callback, or {@code null}
+ */
+ public Consumer<LineReader> getOnReaderReady() {
+ return onReaderReady;
+ }
+
/**
* Builder for {@link GroovyshOptions}.
*
@@ -202,12 +252,15 @@ public final class GroovyshOptions {
private CompilerConfiguration compilerConfiguration;
private GroovyEngine engine;
private final Map<String, Object> bindings = new LinkedHashMap<>();
- private final List<CommandRegistry> extraCommandRegistries = new ArrayList<>();
+ private final List<CommandGroup> groups = new ArrayList<>();
private Supplier<String> prompt;
+ private Supplier<String> rightPrompt;
private ResultHandler resultHandler;
private ErrorHandler errorHandler;
private boolean showBanner = true;
private Terminal terminal;
+ private Path historyFile;
+ private Consumer<LineReader> onReaderReady;
private Builder() {
}
@@ -267,33 +320,49 @@ public final class GroovyshOptions {
}
/**
- * Appends an extra command registry.
+ * Appends command groups. Mirrors {@code ShellBuilder.groups(CommandGroup...)}.
*
- * @param registry the registry to append, must not be {@code null}
+ * @param groups groups to append; {@code null} entries are rejected
* @return this builder
*/
- public Builder extraCommandRegistry(CommandRegistry registry) {
- this.extraCommandRegistries.add(Objects.requireNonNull(registry, "registry"));
+ public Builder groups(CommandGroup... groups) {
+ if (groups != null) {
+ for (CommandGroup group : groups) {
+ this.groups.add(Objects.requireNonNull(group, "group"));
+ }
+ }
return this;
}
/**
- * Appends extra command registries.
+ * Appends command groups.
*
- * @param registries registries to append; {@code null} is ignored
+ * @param groups groups to append; {@code null} is ignored
* @return this builder
*/
- public Builder extraCommandRegistries(Iterable<? extends CommandRegistry> registries) {
- if (registries != null) {
- for (CommandRegistry registry : registries) {
- extraCommandRegistry(registry);
+ public Builder groups(Iterable<? extends CommandGroup> groups) {
+ if (groups != null) {
+ for (CommandGroup group : groups) {
+ this.groups.add(Objects.requireNonNull(group, "group"));
}
}
return this;
}
/**
- * Sets the prompt supplier. Called once per REPL iteration.
+ * Sets a fixed prompt. Mirrors {@code ShellBuilder.prompt(String)}.
+ *
+ * @param prompt the prompt text, or {@code null} for {@code "groovy> "}
+ * @return this builder
+ */
+ public Builder prompt(String prompt) {
+ this.prompt = prompt == null ? null : () -> prompt;
+ return this;
+ }
+
+ /**
+ * Sets the prompt supplier, called once per REPL iteration.
+ * Mirrors {@code ShellBuilder.prompt(Supplier)}.
*
* @param prompt the supplier, or {@code null} for {@code "groovy> "}
* @return this builder
@@ -303,6 +372,28 @@ public final class GroovyshOptions {
return this;
}
+ /**
+ * Sets a fixed right-hand prompt. Mirrors {@code ShellBuilder.rightPrompt(String)}.
+ *
+ * @param rightPrompt the prompt text, or {@code null} for none
+ * @return this builder
+ */
+ public Builder rightPrompt(String rightPrompt) {
+ this.rightPrompt = rightPrompt == null ? null : () -> rightPrompt;
+ return this;
+ }
+
+ /**
+ * Sets the right-hand prompt supplier. Mirrors {@code ShellBuilder.rightPrompt(Supplier)}.
+ *
+ * @param rightPrompt the supplier, or {@code null} for none
+ * @return this builder
+ */
+ public Builder rightPrompt(Supplier<String> rightPrompt) {
+ this.rightPrompt = rightPrompt;
+ return this;
+ }
+
/**
* Sets the result handler.
*
@@ -317,7 +408,7 @@ public final class GroovyshOptions {
/**
* Sets the error handler.
*
- * @param errorHandler the handler, or {@code null} for {@code SystemRegistry.trace}
+ * @param errorHandler the handler, or {@code null} for groovysh's own trace
* @return this builder
*/
public Builder errorHandler(ErrorHandler errorHandler) {
@@ -338,6 +429,7 @@ public final class GroovyshOptions {
/**
* Supplies a terminal instead of letting {@link Main} build one from CLI flags.
+ * Mirrors {@code ShellBuilder.terminal(Terminal)}.
*
* @param terminal the terminal, or {@code null} to build the default
* @return this builder
@@ -347,6 +439,29 @@ public final class GroovyshOptions {
return this;
}
+ /**
+ * Sets the history file. Mirrors {@code ShellBuilder.historyFile(Path)}.
+ *
+ * @param historyFile the file, or {@code null} for groovysh's own
+ * @return this builder
+ */
+ public Builder historyFile(Path historyFile) {
+ this.historyFile = historyFile;
+ return this;
+ }
+
+ /**
+ * Registers a callback invoked once the {@link LineReader} is built.
+ * Mirrors {@code ShellBuilder.onReaderReady(Consumer)}.
+ *
+ * @param onReaderReady the callback, or {@code null} for none
+ * @return this builder
+ */
+ public Builder onReaderReady(Consumer<LineReader> onReaderReady) {
+ this.onReaderReady = onReaderReady;
+ return this;
+ }
+
/**
* Builds an immutable options object.
*
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index 8597da4868..d2441496e4 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -18,23 +18,23 @@
*/
package org.apache.groovy.groovysh
-import org.apache.groovy.groovysh.ExtraConsoleCommands as DefaultExtraConsoleCommands
-
import groovy.cli.internal.CliBuilderInternal
import groovy.cli.internal.OptionAccessor
+import org.apache.groovy.groovysh.ExtraConsoleCommands as DefaultExtraConsoleCommands
import org.apache.groovy.groovysh.jline.GroovyBuiltins
-import org.apache.groovy.lang.annotation.Incubating
import org.apache.groovy.groovysh.jline.GroovyCommands
import org.apache.groovy.groovysh.jline.GroovyConsoleEngine
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.apache.groovy.groovysh.jline.GroovyPrinter
import org.apache.groovy.groovysh.jline.GroovySystemRegistry
import org.apache.groovy.groovysh.util.DocFinder
+import org.apache.groovy.lang.annotation.Incubating
import org.codehaus.groovy.tools.shell.util.MessageSource
import org.jline.builtins.ClasspathResourceUtil
import org.jline.builtins.ConfigurationPath
import org.jline.builtins.SyntaxHighlighter
import org.jline.console.CommandRegistry
+import org.jline.console.impl.CommandRegistryAdapter
import org.jline.console.ConsoleEngine
import org.jline.console.Printer
import org.jline.console.impl.SystemHighlighter
@@ -64,6 +64,7 @@ import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
+import java.util.function.Consumer
import java.util.function.Supplier
import static org.jline.jansi.AnsiRenderer.render
@@ -335,8 +336,9 @@ class Main {
// handles the concurrent-creation race, and falls back on non-POSIX file systems.
// Creating it here first is what used to defeat that and leave it at the umask
// default. Only an already-open file is adjusted.
- .variable(LineReader.HISTORY_FILE,
- tightenStateFile(userStateDirectory.resolve('groovysh_history')))
+ .variable(LineReader.HISTORY_FILE, groovyshOptions.historyFile != null
+ ? groovyshOptions.historyFile
+ : tightenStateFile(userStateDirectory.resolve('groovysh_history')))
.option(Option.INSERT_BRACKET, true)
.option(Option.EMPTY_WORD_OPTIONS, false)
.option(Option.USE_FORWARD_SLASH, true)
@@ -346,6 +348,7 @@ class Main {
reader.setVariable(
LineReader.BLINK_MATCHING_PAREN, 0) // if enabled cursor remains in begin parenthesis (gitbash)
}
+ groovyshOptions.onReaderReady?.accept(reader)
def extra = new DefaultExtraConsoleCommands(Paths.get(System.getProperty('user.dir')), scriptEngine, reader)
Supplier<Path> workDir = extra::currentDir
@@ -363,8 +366,8 @@ class Main {
GroovySystemRegistry systemRegistry = new GroovySystemRegistry(parser, terminal, workDir, configPath).tap {
groupCommandsInHelp(false)
- def registries = [extra, consoleEngine, builtins, groovy]
- registries.addAll(groovyshOptions.extraCommandRegistries)
+ def registries = groovyshOptions.groups.collect { new CommandRegistryAdapter(it) as CommandRegistry }
+ registries.addAll([extra, consoleEngine, builtins, groovy])
setCommandRegistries(registries.toArray(new CommandRegistry[0]))
addCompleter(scriptEngine.scriptCompleter)
setScriptDescription(scriptEngine::scriptDescription)
@@ -401,6 +404,7 @@ class Main {
}
Supplier<String> prompt = groovyshOptions.prompt ?: ({ 'groovy> ' } as Supplier<String>)
+ Supplier<String> rightPrompt = groovyshOptions.rightPrompt
if (groovyshOptions.showBanner) {
if (options.q) {
@@ -430,7 +434,7 @@ class Main {
} else {
// for debugging
// line = lines[index++]
- line = reader.readLine(prompt.get())
+ line = reader.readLine(prompt.get(), rightPrompt?.get(), (Character) null, null)
}
line = line.readLines().collect{ s ->
// remove Groovy continuation character for repl not Groovy's sake
@@ -444,7 +448,7 @@ class Main {
}
}
Object result = systemRegistry.execute(line)
- emitResult(consoleEngine, result, groovyshOptions.resultHandler)
+ emitResult(printer, result, groovyshOptions.resultHandler)
// consoleEngine.println([(Printer.OBJECT_TO_STRING): [(Object) : { o -> o.toString() }] ], result)
} catch (UserInterruptException e) {
// Ignore
@@ -452,7 +456,7 @@ class Main {
String pl = e.getPartialLine()
if (pl != null) { // execute last line from redirected file (required for Windows)
try {
- emitResult(consoleEngine, systemRegistry.execute(pl), groovyshOptions.resultHandler)
+ emitResult(printer, systemRegistry.execute(pl), groovyshOptions.resultHandler)
} catch (Exception e2) {
emitError(systemRegistry, e2, groovyshOptions.errorHandler)
}
@@ -487,19 +491,20 @@ class Main {
start(GroovyshOptions.builder().build(), args)
}
- private static void emitResult(ConsoleEngine console, Object result, GroovyshOptions.ResultHandler handler) throws Exception {
+ private static void emitResult(Printer printer, Object result, GroovyshOptions.ResultHandler handler) throws Exception {
if (handler != null) {
- handler.handle(console, result)
+ handler.handle(printer, result)
} else {
- console.println(result?.toString())
+ printer.println(result?.toString())
}
}
private static void emitError(GroovySystemRegistry registry, Throwable error, GroovyshOptions.ErrorHandler handler) {
+ Consumer<Throwable> defaultTrace = registry::trace
if (handler != null) {
- handler.handle(registry, error)
+ handler.handle(error, defaultTrace)
} else {
- registry.trace(error)
+ defaultTrace.accept(error)
}
}
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
index cceb00c408..0de65e6075 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
@@ -22,6 +22,7 @@ import groovy.lang.MissingPropertyException;
import groovy.lang.Script;
import org.apache.groovy.ast.tools.ImmutablePropertyUtils;
import org.apache.groovy.groovysh.Main;
+import org.apache.groovy.lang.annotation.Incubating;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.ModuleImportHelper;
import org.codehaus.groovy.control.MultipleCompilationErrorsException;
@@ -225,6 +226,7 @@ public class GroovyEngine implements ScriptEngine {
* {@link CompilerConfiguration#DEFAULT}
* @since 7.0.0
*/
+ @Incubating
public GroovyEngine(CompilerConfiguration configuration) {
this(null, null, configuration);
}
@@ -232,14 +234,24 @@ public class GroovyEngine implements ScriptEngine {
/**
* Constructs a GroovyEngine with an explicit parent class loader, binding,
* and compiler configuration.
+ * <p>
+ * As with {@link GroovyShell}, an {@link EngineClassLoader} whose configuration
+ * is the same instance as {@code configuration} becomes the engine class loader
+ * itself rather than its parent; any other loader is wrapped. Two engines sharing
+ * one loader that way also share its class cache, so a cache purge from either —
+ * {@code /reset} and the type-redefinition paths do this — discards the other's
+ * classes, and their script classes occupy one namespace. Pass a plain
+ * {@link ClassLoader}, or a configuration this loader does not already hold, to
+ * get a child loader instead.
*
- * @param parent parent class loader, or {@code null} to use the thread
- * context class loader
+ * @param parent class loader to use as parent, or to adopt as described above;
+ * {@code null} uses the thread context class loader
* @param binding shared binding, or {@code null} to create a new one
* @param configuration compiler configuration, or {@code null} for
* {@link CompilerConfiguration#DEFAULT}
* @since 7.0.0
*/
+ @Incubating
public GroovyEngine(ClassLoader parent, Binding binding, CompilerConfiguration configuration) {
sharedData = binding != null ? binding : new Binding();
compilerConfiguration = configuration != null ? configuration : CompilerConfiguration.DEFAULT;
@@ -284,6 +296,7 @@ public class GroovyEngine implements ScriptEngine {
* @return a shell that uses {@code classLoader} as its loader
* @since 7.0.0
*/
+ @Incubating
protected GroovyShell createShell(ClassLoader classLoader, Binding binding, CompilerConfiguration configuration) {
return new GroovyShell(classLoader, binding, configuration);
}
@@ -294,6 +307,7 @@ public class GroovyEngine implements ScriptEngine {
* @return the engine class loader
* @since 7.0.0
*/
+ @Incubating
public EngineClassLoader getClassLoader() {
return classLoader;
}
@@ -304,6 +318,7 @@ public class GroovyEngine implements ScriptEngine {
* @return the compiler configuration, never {@code null}
* @since 7.0.0
*/
+ @Incubating
public CompilerConfiguration getCompilerConfiguration() {
return compilerConfiguration;
}
@@ -1247,16 +1262,22 @@ public class GroovyEngine implements ScriptEngine {
* @param configuration compiler configuration, or {@code null} for the default
* @since 7.0.0
*/
+ @Incubating
public EngineClassLoader(CompilerConfiguration configuration) {
super(Thread.currentThread().getContextClassLoader(), configuration);
}
/**
- * Constructs an EngineClassLoader with the given parent class loader.
+ * Constructs an EngineClassLoader with the given parent class loader and
+ * {@link CompilerConfiguration#DEFAULT}. Use
+ * {@link #EngineClassLoader(ClassLoader, CompilerConfiguration)} to supply
+ * an engine's configuration, so that the engine adopts this loader rather
+ * than wrapping it.
*
* @param parent the parent class loader
* @since 7.0.0
*/
+ @Incubating
public EngineClassLoader(ClassLoader parent) {
super(parent);
}
@@ -1269,6 +1290,7 @@ public class GroovyEngine implements ScriptEngine {
* @param configuration compiler configuration, or {@code null} for the default
* @since 7.0.0
*/
+ @Incubating
public EngineClassLoader(ClassLoader parent, CompilerConfiguration configuration) {
super(parent, configuration);
}
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index 4e851c953f..235e3294cf 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -1462,7 +1462,11 @@ be prepended as snippets on every evaluation) and
`ASTTransformationCustomizer(ThreadInterrupt)` (so Ctrl+C can stop a
tight loop after `Main` interrupts the execute thread).
+[source,groovy]
+.Compile-time customizers on the engine
+----
include::../test/EmbeddingSpecTest.groovy[tags=embed_engine_config,indent=0]
+----
Subclasses that need a different `GroovyShell` can override
`protected GroovyShell createShell(ClassLoader, Binding, CompilerConfiguration)`.
@@ -1473,7 +1477,11 @@ that are assigned after `super(...)`.
class loader (the field is `protected`; Java callers cannot read it
without a getter).
+[source,groovy]
+.The engine class loader
+----
include::../test/EmbeddingSpecTest.groovy[tags=embed_classloader,indent=0]
+----
==== `GroovyshOptions`
@@ -1481,27 +1489,46 @@ include::../test/EmbeddingSpecTest.groovy[tags=embed_classloader,indent=0]
that does not call `System.exit`. The options object is built with
`GroovyshOptions.builder()` and is incubating in 7.0.
+Where a setting has an equivalent on JLine's `ShellBuilder`, it
+deliberately carries the same name and parameter type, so that a
+future move of groovysh onto `org.jline.shell` does not break this
+API.
+
Recognized hooks:
* `compilerConfiguration` / `engine` — compile-time customizers, or a
pre-built `GroovyEngine`. If both are set, the engine wins.
* `bindings` / `binding(name, value)` — variables installed on the
engine before the REPL starts.
-* `extraCommandRegistry` — additional JLine `CommandRegistry`
- instances, appended after groovysh's own registries.
+* `groups` — additional JLine `CommandGroup` instances
+ (`org.jline.shell`, the undeprecated successor to `CommandRegistry`;
+ groovysh adapts them internally). They are resolved *before*
+ groovysh's own commands, so a group may override a built-in
+ command name.
* `prompt` — `Supplier<String>` invoked on each REPL iteration
(default `{ 'groovy> ' }`).
-* `resultHandler` — how values are rendered (default:
- `console.println(result?.toString())`).
-* `errorHandler` — how execution failures are rendered (default:
- `SystemRegistry.trace`). `UserInterruptException` and
- `EndOfFileException` stay in `Main` as control flow.
+* `resultHandler` — how values are rendered, given the session
+ `Printer` (default: `printer.println(result?.toString())`).
+* `errorHandler` — how execution failures are rendered, given the
+ error and groovysh's own renderer to delegate to (default: the
+ latter). `UserInterruptException` and `EndOfFileException` stay in
+ `Main` as control flow.
* `showBanner` — set `false` to suppress groovysh's banner so the host
can print its own.
+* `rightPrompt` — optional right-hand prompt.
* `terminal` — optional JLine `Terminal`; when omitted, `Main` builds
one from the CLI flags.
+* `historyFile` — where to persist history; when omitted, groovysh's
+ own `groovysh_history`.
+* `onReaderReady` — a `Consumer<LineReader>` called once the reader
+ exists, for settings the builder does not model: the continuation
+ prompt (`SECONDARY_PROMPT_PATTERN`), key bindings, reader options.
-include::../test/EmbeddingSpecTest.groovy[tags=embed_options_help,indent=0]
+[source,groovy]
+.Embedding with `GroovyshOptions`
+----
+include::../test/EmbeddingSpecTest.groovy[tags=embed_options_hooks,indent=0]
+----
`Main.start(Map, String[])` remains the binding-only overload and
delegates to `GroovyshOptions`.
@@ -1510,7 +1537,10 @@ delegates to `GroovyshOptions`.
`/clear`, `/pwd`, `/cd`, `/ls`, `/cat`, `/grep`, `/head`, `/tail`,
`/wc`, `/sort`, `/date`, `/echo` and `/!` live in the public
-`ExtraConsoleCommands` class. `Main` still registers it by default.
+`ExtraConsoleCommands` class. It implements JLine's deprecated
+`org.jline.console.CommandRegistry`, as groovysh's own registries
+still do; prefer `GroovyshOptions.groups` and `org.jline.shell`
+for new embedder commands. `Main` still registers it by default.
Embedders that build their own `SystemRegistry` can construct it
directly. The former `Main.ExtraConsoleCommands` nested class remains
as a deprecated subclass for Groovy callers that compiled against it.
diff --git a/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy b/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy
index 28abbe86ee..a986a0d65e 100644
--- a/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy
+++ b/subprojects/groovy-groovysh/src/spec/test/EmbeddingSpecTest.groovy
@@ -23,8 +23,17 @@ import org.apache.groovy.groovysh.Main
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.jline.reader.LineReader
+import org.jline.shell.CommandSession
+import org.jline.shell.impl.AbstractCommand
+import org.jline.shell.impl.SimpleCommandGroup
+import org.jline.terminal.Size
+import org.jline.terminal.impl.DumbTerminal
import org.junit.jupiter.api.Test
+import java.nio.charset.StandardCharsets
+import java.nio.file.Path
+
/**
* Executable examples for embedding groovysh. Included from
* {@code groovysh.adoc}.
@@ -45,11 +54,35 @@ class EmbeddingSpecTest {
}
@Test
- void optionsBuilderHelpPath() {
- // tag::embed_options_help[]
- int rc = Main.start(GroovyshOptions.builder().showBanner(false).build(), '--help')
- assert rc == 0
- // end::embed_options_help[]
+ void optionsBuilderHooks() {
+ def terminal = new DumbTerminal('embedded', 'dumb',
+ new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), StandardCharsets.UTF_8)
+ terminal.size = new Size(80, 24)
+ def results = []
+ try {
+ // tag::embed_options_hooks[]
+ int rc = Main.start(GroovyshOptions.builder()
+ .terminal(terminal) // host-supplied terminal
+ .showBanner(false) // the host prints its own
+ .prompt { 'gremlin> ' } // primary prompt
+ .onReaderReady { LineReader reader -> // continuation prompt, key bindings
+ reader.setVariable(LineReader.SECONDARY_PROMPT_PATTERN, '.......> ')
+ }
+ .historyFile(Path.of(System.getProperty('user.home'), '.myapp_history'))
+ .binding('answer', 42) // visible to evaluated code
+ .groups(new SimpleCommandGroup('MyApp', new AbstractCommand('/ping') {
+ @Override
+ Object execute(CommandSession session, String[] args) { 'pong' }
+ }))
+ .resultHandler { printer, result -> results << result } // render values your way
+ .errorHandler { error, defaultTrace -> defaultTrace.accept(error) }
+ .build(), '-e', 'answer')
+ assert rc == 0
+ assert 42 in results
+ // end::embed_options_hooks[]
+ } finally {
+ terminal.close()
+ }
}
@Test
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy
index ae63d52da6..1a031d3616 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/ExtraConsoleCommandsTest.groovy
@@ -173,10 +173,29 @@ class ExtraConsoleCommandsTest {
}
@Test
- void commandInfoReturnsPosixCommandNames() {
- def info = extra.commandInfo('/pwd')
- assert info instanceof List
- assert !info.isEmpty()
+ void commandInfoDescribesTheCommandItIsAskedAbout() {
+ assert extra.commandInfo('/pwd') == ['print working directory']
+ assert extra.commandInfo('/clear') == ['clear terminal']
+ assert extra.commandInfo('/!') == ['execute shell command']
+ assert extra.commandInfo('/ls') == ['list files']
+ // every command gets its own description rather than a shared list
+ def described = extra.commandNames().collectEntries { [it, extra.commandInfo(it)] }
+ assert described.every { _, info -> info.size() == 1 && info[0] }
+ assert described.values().flatten().toSet().size() == described.size()
+ }
+
+ @Test
+ void commandInfoIsEmptyForAnUnknownCommand() {
+ assert extra.commandInfo('/nope') == []
+ }
+
+ @Test
+ void helpDescribesEachConsoleCommand() {
+ system.execute('/help')
+ def help = terminalOutput()
+ assert help.contains('clear terminal')
+ assert help.contains('print working directory')
+ assert help.contains('list files')
}
@Test
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
index 66d56b5854..7419e95fbc 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
@@ -21,10 +21,10 @@ package org.apache.groovy.groovysh
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
-import org.jline.console.CommandInput
-import org.jline.console.CommandMethods
-import org.jline.console.CommandRegistry
-import org.jline.console.impl.JlineCommandRegistry
+import org.jline.reader.LineReader
+import org.jline.shell.CommandSession
+import org.jline.shell.impl.AbstractCommand
+import org.jline.shell.impl.SimpleCommandGroup
import org.jline.terminal.Size
import org.jline.terminal.Terminal
import org.jline.terminal.impl.DumbTerminal
@@ -35,7 +35,6 @@ import org.junit.jupiter.api.io.TempDir
import java.nio.charset.StandardCharsets
import java.nio.file.Path
-import java.util.function.Function
/**
* End-to-end coverage of {@link Main#start(GroovyshOptions, String[])}
@@ -83,7 +82,7 @@ class GroovyshEmbeddingTest {
void resultHandlerSeesEvaluatedValue() {
def results = []
int rc = Main.start(options()
- .resultHandler { c, r -> results << r }
+ .resultHandler { p, r -> results << r }
.build(), '-e', '2 + 3')
assert rc == 0
assert 5 in results
@@ -94,7 +93,7 @@ class GroovyshEmbeddingTest {
def results = []
int rc = Main.start(options()
.binding('answer', 42)
- .resultHandler { c, r -> results << r }
+ .resultHandler { p, r -> results << r }
.build(), '-e', 'answer')
assert rc == 0
assert 42 in results
@@ -109,7 +108,7 @@ class GroovyshEmbeddingTest {
def results = []
int rc = Main.start(options()
.compilerConfiguration(config)
- .resultHandler { c, r -> results << r }
+ .resultHandler { p, r -> results << r }
.build(), '-e', 'new AtomicInteger(9).get()')
assert rc == 0
assert 9 in results
@@ -123,29 +122,80 @@ class GroovyshEmbeddingTest {
int rc = Main.start(options()
.engine(engine)
.compilerConfiguration(new CompilerConfiguration())
- .resultHandler { c, r -> results << r }
+ .resultHandler { p, r -> results << r }
.build(), '-e', 'marker')
assert rc == 0
assert 'from-engine' in results
}
@Test
- void extraCommandRegistryIsInvoked() {
- def ping = new PingRegistry()
+ void commandGroupIsInvoked() {
+ def hits = []
int rc = Main.start(options()
- .extraCommandRegistry(ping)
- .resultHandler { c, r -> }
+ .groups(group('/ping', hits))
+ .resultHandler { p, r -> }
.build(), '-e', '/ping')
assert rc == 0
- assert ping.hits == ['ping']
+ assert hits == ['/ping']
+ }
+
+ @Test
+ void aCommandGroupCanOverrideABuiltIn() {
+ def hits = []
+ int rc = Main.start(options()
+ .groups(group('/imports', hits))
+ .resultHandler { p, r -> }
+ .build(), '-e', '/imports')
+ assert rc == 0
+ assert hits == ['/imports'] // groovysh's own /imports did not run
+ }
+
+ @Test
+ void historyFileIsHonoured() {
+ // JLine only writes the file once a line is actually read, and -e short-circuits
+ // readLine, so assert the reader was configured with the embedder's path
+ def history = tempHome.resolve('embedder_history')
+ def configured = null
+ int rc = Main.start(options()
+ .historyFile(history)
+ .onReaderReady { LineReader r -> configured = r.getVariable(LineReader.HISTORY_FILE) }
+ .resultHandler { p, r -> }
+ .build(), '-e', '1 + 1')
+ assert rc == 0
+ assert configured == history
+ }
+
+ @Test
+ void historyFileDefaultsToGroovyshWhenUnset() {
+ def configured = null
+ int rc = Main.start(options()
+ .onReaderReady { LineReader r -> configured = r.getVariable(LineReader.HISTORY_FILE) }
+ .resultHandler { p, r -> }
+ .build(), '-e', '1 + 1')
+ assert rc == 0
+ assert (configured as Path).fileName.toString() == 'groovysh_history'
+ }
+
+ @Test
+ void onReaderReadyCanSetTheSecondaryPrompt() {
+ def seen = null
+ int rc = Main.start(options()
+ .onReaderReady { LineReader r ->
+ r.setVariable(LineReader.SECONDARY_PROMPT_PATTERN, 'gremlin...> ')
+ seen = r.getVariable(LineReader.SECONDARY_PROMPT_PATTERN)
+ }
+ .resultHandler { p, r -> }
+ .build(), '-e', '1')
+ assert rc == 0
+ assert seen == 'gremlin...> '
}
@Test
void errorHandlerReceivesThrownFailures() {
def errors = []
int rc = Main.start(options()
- .resultHandler { c, r -> }
- .errorHandler { r, t -> errors << t }
+ .resultHandler { p, r -> }
+ .errorHandler { t, trace -> errors << t }
.build(), '-e', 'throw new RuntimeException("boom")')
assert rc == 0
assert errors.any { containsMessage(it, 'boom') }
@@ -154,7 +204,7 @@ class GroovyshEmbeddingTest {
@Test
void bannerCanBeSuppressed() {
def captured = captureStdout {
- Main.start(options().resultHandler { c, r -> }.build(), '-e', '1')
+ Main.start(options().resultHandler { p, r -> }.build(), '-e', '1')
}
assert !captured.contains('Groovy Shell')
}
@@ -164,7 +214,7 @@ class GroovyshEmbeddingTest {
def captured = captureStdout {
Main.start(options()
.showBanner(true)
- .resultHandler { c, r -> }
+ .resultHandler { p, r -> }
.build(), '-e', '1')
}
assert captured.contains('Groovy Shell')
@@ -175,7 +225,7 @@ class GroovyshEmbeddingTest {
def captured = captureStdout {
Main.start(options()
.showBanner(true)
- .resultHandler { c, r -> }
+ .resultHandler { p, r -> }
.build(), '-q', '-e', '1')
}
assert captured.contains(GroovySystem.version)
@@ -197,7 +247,7 @@ class GroovyshEmbeddingTest {
prompts << 'gremlin> '
'gremlin> '
}
- .resultHandler { c, r -> }
+ .resultHandler { p, r -> }
.build())
assert rc == 0
assert prompts.contains('gremlin> ')
@@ -240,27 +290,16 @@ class GroovyshEmbeddingTest {
false
}
- private static class PingRegistry extends JlineCommandRegistry implements CommandRegistry {
- final List<String> hits = []
-
- PingRegistry() {
- registerCommands([
- '/ping': new CommandMethods((Function) this::ping, this::defaultCompleter)
- ])
- }
-
- @Override
- String name() {
- 'Ping'
- }
+ private static SimpleCommandGroup group(String name, List<String> hits) {
+ new SimpleCommandGroup('Embedder', new AbstractCommand(name) {
+ @Override
+ String description() { 'a command contributed by the embedder' }
- @Override
- List<String> commandInfo(String command) {
- ['ping the extra registry']
- }
-
- private void ping(CommandInput input) {
- hits << 'ping'
- }
+ @Override
+ Object execute(CommandSession session, String[] args) {
+ hits << name
+ null
+ }
+ })
}
}
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy
index 806a033012..2ea3a6c946 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshOptionsTest.groovy
@@ -20,12 +20,15 @@ package org.apache.groovy.groovysh
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.codehaus.groovy.control.CompilerConfiguration
-import org.jline.console.CommandRegistry
-import org.jline.console.impl.JlineCommandRegistry
+import org.jline.shell.CommandGroup
+import org.jline.shell.CommandSession
+import org.jline.shell.impl.AbstractCommand
+import org.jline.shell.impl.SimpleCommandGroup
import org.jline.terminal.impl.DumbTerminal
import org.junit.jupiter.api.Test
import java.nio.charset.StandardCharsets
+import java.nio.file.Path
import static groovy.test.GroovyAssert.shouldFail
@@ -42,8 +45,11 @@ class GroovyshOptionsTest {
assert options.compilerConfiguration == null
assert options.engine == null
assert options.bindings.isEmpty()
- assert options.extraCommandRegistries.isEmpty()
+ assert options.groups.isEmpty()
assert options.prompt == null
+ assert options.rightPrompt == null
+ assert options.historyFile == null
+ assert options.onReaderReady == null
assert options.resultHandler == null
assert options.errorHandler == null
assert options.showBanner
@@ -78,28 +84,26 @@ class GroovyshOptionsTest {
}
@Test
- void extraRegistriesAppend() {
- def first = new DummyRegistry('first')
- def second = new DummyRegistry('second')
+ void groupsAppend() {
+ def first = group('first')
+ def second = group('second')
def options = GroovyshOptions.builder()
- .extraCommandRegistry(first)
- .extraCommandRegistries([second])
+ .groups(first)
+ .groups([second])
.build()
- assert options.extraCommandRegistries == [first, second]
+ assert options.groups == [first, second]
}
@Test
- void extraCommandRegistriesNullIsIgnored() {
- def options = GroovyshOptions.builder()
- .extraCommandRegistries(null)
- .build()
- assert options.extraCommandRegistries.isEmpty()
+ void groupsNullIsIgnored() {
+ assert GroovyshOptions.builder().groups((Iterable) null).build().groups.isEmpty()
+ assert GroovyshOptions.builder().groups((CommandGroup[]) null).build().groups.isEmpty()
}
@Test
- void extraCommandRegistryRejectsNull() {
+ void groupsRejectNullEntries() {
shouldFail(NullPointerException) {
- GroovyshOptions.builder().extraCommandRegistry(null)
+ GroovyshOptions.builder().groups([null])
}
}
@@ -114,13 +118,13 @@ class GroovyshOptionsTest {
void mapsAndListsAreUnmodifiable() {
def options = GroovyshOptions.builder()
.binding('a', 1)
- .extraCommandRegistry(new DummyRegistry('d'))
+ .groups(group('d'))
.build()
shouldFail(UnsupportedOperationException) {
options.bindings.put('b', 2)
}
shouldFail(UnsupportedOperationException) {
- options.extraCommandRegistries.add(new DummyRegistry('x'))
+ options.groups.add(group('x'))
}
}
@@ -129,8 +133,8 @@ class GroovyshOptionsTest {
def config = new CompilerConfiguration()
def engine = new GroovyEngine()
def prompt = { 'gremlin> ' }
- GroovyshOptions.ResultHandler results = { c, r -> }
- GroovyshOptions.ErrorHandler errors = { r, t -> }
+ GroovyshOptions.ResultHandler results = { p, r -> }
+ GroovyshOptions.ErrorHandler errors = { t, trace -> }
def terminal = new DumbTerminal(
'groovysh-test', 'dumb',
new ByteArrayInputStream(new byte[0]),
@@ -141,6 +145,8 @@ class GroovyshOptionsTest {
.compilerConfiguration(config)
.engine(engine)
.prompt(prompt)
+ .rightPrompt('[rp]')
+ .historyFile(Path.of('history.txt'))
.resultHandler(results)
.errorHandler(errors)
.showBanner(false)
@@ -149,6 +155,8 @@ class GroovyshOptionsTest {
assert options.compilerConfiguration.is(config)
assert options.engine.is(engine)
assert options.prompt.get() == 'gremlin> '
+ assert options.rightPrompt.get() == '[rp]'
+ assert options.historyFile.fileName.toString() == 'history.txt'
assert options.resultHandler.is(results)
assert options.errorHandler.is(errors)
assert !options.showBanner
@@ -158,16 +166,10 @@ class GroovyshOptionsTest {
}
}
- private static class DummyRegistry extends JlineCommandRegistry implements CommandRegistry {
- private final String group
-
- DummyRegistry(String group) {
- this.group = group
- }
-
- @Override
- String name() {
- group
- }
+ private static CommandGroup group(String name) {
+ new SimpleCommandGroup(name, new AbstractCommand("/${name}") {
+ @Override
+ Object execute(CommandSession session, String[] args) { null }
+ })
}
}
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
index 4ebf153bc3..39503e41ff 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
@@ -18,9 +18,6 @@
*/
package org.apache.groovy.groovysh.jline
-import groovy.lang.Binding
-import groovy.lang.GroovyClassLoader
-import groovy.lang.GroovyShell
import groovy.transform.ThreadInterrupt
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
@@ -274,9 +271,11 @@ class GroovyEngineTest {
def thrown = new AtomicReference<Throwable>()
def started = new CountDownLatch(1)
engine.put('started', started)
- def thread = Thread.start {
+ // daemon, and bounded: if the interrupt is ever missed the loop must still end
+ // rather than spin a core for the rest of the forked JVM's life
+ def thread = Thread.startDaemon {
try {
- engine.execute('started.countDown(); def n = 0; while (true) { n++ }')
+ engine.execute('started.countDown(); def n = 0L; while (n < 100_000_000_000L) { n++ }')
} catch (Throwable t) {
thrown.set(t)
}
(groovy) 04/04: GROOVY-12403: let an embedder contribute completion
Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit a24cdf7796b2c72a0953ff9f1ce07438a82af242
Author: Paul King <pa...@asert.com.au>
AuthorDate: Mon Sep 14 14:20:08 2026 +1000
GROOVY-12403: let an embedder contribute completion
groups(CommandGroup) already carries per-command completers into the
registry, but the slot SystemRegistryImpl.completer() reserves for
whole-line completion - what addCompleter feeds, and where groovysh
puts its own identifier and import completer - had no hook. A host
console could complete the arguments of its own commands but not its
domain language.
onReaderReady gains the BiConsumer<LineReader, GroovySystemRegistry>
overload JLine's ShellBuilder uses for the same purpose, and now fires
once the reader and the registry are both wired rather than straight
after the reader is built. Firing late is what makes completion
reachable, since Main assigns reader.completer from the registry, and
it matches ShellBuilder, which holds a single BiConsumer and calls it
last. The Consumer overload wraps into it, as ShellBuilder's does.
An embedder supplements groovysh's completion with
reader.completer = new AggregateCompleter(registry.completer(), mine)
or replaces it by assigning its own. Nothing between the reader being
built and the callback now needs the embedder's reader settings, so
moving the callback later costs nothing.
---
.../apache/groovy/groovysh/GroovyshOptions.java | 42 +++++++++++++++++-----
.../groovy/org/apache/groovy/groovysh/Main.groovy | 4 ++-
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 22 ++++++++++--
.../groovy/groovysh/GroovyshEmbeddingTest.groovy | 40 +++++++++++++++++++++
4 files changed, 96 insertions(+), 12 deletions(-)
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
index 37122ef5a0..aa472321a5 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/GroovyshOptions.java
@@ -19,6 +19,7 @@
package org.apache.groovy.groovysh;
import org.apache.groovy.groovysh.jline.GroovyEngine;
+import org.apache.groovy.groovysh.jline.GroovySystemRegistry;
import org.apache.groovy.lang.annotation.Incubating;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.jline.console.Printer;
@@ -34,6 +35,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Supplier;
@@ -101,7 +103,7 @@ public final class GroovyshOptions {
private final boolean showBanner;
private final Terminal terminal;
private final Path historyFile;
- private final Consumer<LineReader> onReaderReady;
+ private final BiConsumer<LineReader, GroovySystemRegistry> onReaderReady;
private GroovyshOptions(Builder builder) {
this.compilerConfiguration = builder.compilerConfiguration;
@@ -232,13 +234,14 @@ public final class GroovyshOptions {
}
/**
- * Callback invoked once the {@link LineReader} has been built, for settings
- * this class does not model — the secondary prompt pattern, key bindings,
- * reader options.
+ * Callback invoked once the reader and the command registry are both wired,
+ * immediately before the REPL starts, for settings this class does not
+ * model — the secondary prompt pattern, key bindings, reader options, and
+ * the completer.
*
* @return the callback, or {@code null}
*/
- public Consumer<LineReader> getOnReaderReady() {
+ public BiConsumer<LineReader, GroovySystemRegistry> getOnReaderReady() {
return onReaderReady;
}
@@ -260,7 +263,7 @@ public final class GroovyshOptions {
private boolean showBanner = true;
private Terminal terminal;
private Path historyFile;
- private Consumer<LineReader> onReaderReady;
+ private BiConsumer<LineReader, GroovySystemRegistry> onReaderReady;
private Builder() {
}
@@ -451,13 +454,36 @@ public final class GroovyshOptions {
}
/**
- * Registers a callback invoked once the {@link LineReader} is built.
- * Mirrors {@code ShellBuilder.onReaderReady(Consumer)}.
+ * Registers a callback invoked once the reader is wired, immediately
+ * before the REPL starts. Mirrors {@code ShellBuilder.onReaderReady(Consumer)}.
*
* @param onReaderReady the callback, or {@code null} for none
* @return this builder
*/
public Builder onReaderReady(Consumer<LineReader> onReaderReady) {
+ this.onReaderReady = onReaderReady == null ? null
+ : (reader, registry) -> onReaderReady.accept(reader);
+ return this;
+ }
+
+ /**
+ * Registers a callback invoked once the reader and the command registry
+ * are both wired, immediately before the REPL starts. Mirrors
+ * {@code ShellBuilder.onReaderReady(BiConsumer)}.
+ * <p>
+ * This is the hook for completion. groovysh has already assigned
+ * {@code reader.setCompleter(registry.completer())} by the time the
+ * callback runs, so an embedder contributing its own completion wraps
+ * that, for example
+ * {@code reader.setCompleter(new AggregateCompleter(registry.completer(), mine))}.
+ * Candidates from an {@code AggregateCompleter} are merged, so a
+ * completer added this way supplements groovysh's rather than replacing
+ * it; assign a completer of your own to replace it outright.
+ *
+ * @param onReaderReady the callback, or {@code null} for none
+ * @return this builder
+ */
+ public Builder onReaderReady(BiConsumer<LineReader, GroovySystemRegistry> onReaderReady) {
this.onReaderReady = onReaderReady;
return this;
}
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index 8300dc6f77..cc2836b06e 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -347,7 +347,6 @@ class Main {
reader.setVariable(
LineReader.BLINK_MATCHING_PAREN, 0) // if enabled cursor remains in begin parenthesis (gitbash)
}
- groovyshOptions.onReaderReady?.accept(reader)
def extra = new DefaultExtraConsoleCommands(Paths.get(System.getProperty('user.dir')), scriptEngine, reader)
Supplier<Path> workDir = extra::currentDir
@@ -397,6 +396,9 @@ class Main {
KeyMap<Binding> keyMap = reader.keyMaps.get("main")
keyMap.bind(new Reference(Widgets.TAILTIP_TOGGLE), KeyMap.alt("s"))
keyMap.bind(new Reference(Widgets.AUTOSUGGEST_TOGGLE), KeyMap.alt("v"))
+ // last, so an embedder sees the reader fully wired — completer included
+ groovyshOptions.onReaderReady?.accept(reader, systemRegistry)
+
def init = configPath.getUserConfig('groovysh_init.groovy')
if (init) {
systemRegistry.initialize(init.toFile())
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index 235e3294cf..51085b77a0 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -1520,9 +1520,25 @@ Recognized hooks:
one from the CLI flags.
* `historyFile` — where to persist history; when omitted, groovysh's
own `groovysh_history`.
-* `onReaderReady` — a `Consumer<LineReader>` called once the reader
- exists, for settings the builder does not model: the continuation
- prompt (`SECONDARY_PROMPT_PATTERN`), key bindings, reader options.
+* `onReaderReady` — called once the reader and the command registry
+ are both wired, immediately before the REPL starts, for settings the
+ builder does not model: the continuation prompt
+ (`SECONDARY_PROMPT_PATTERN`), key bindings, reader options, and
+ completion. Takes a `Consumer<LineReader>`, or a
+ `BiConsumer<LineReader, GroovySystemRegistry>` when the registry is
+ needed too. groovysh has already assigned
+ `reader.completer = registry.completer()` by then, so an embedder
+ contributing domain completion wraps it:
++
+[source,groovy]
+----
+reader.completer = new AggregateCompleter(registry.completer(), myCompleter)
+----
++
+`AggregateCompleter` merges candidates, so a completer added that way
+supplements groovysh's rather than replacing it. Assigning a completer
+of your own replaces it outright. Per-command completion needs none of
+this — it comes from `Command.completer()` on a contributed `group`.
[source,groovy]
.Embedding with `GroovyshOptions`
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
index 7419e95fbc..1745eff57c 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
@@ -21,7 +21,11 @@ package org.apache.groovy.groovysh
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.jline.reader.Candidate
import org.jline.reader.LineReader
+import org.jline.reader.Parser
+import org.jline.reader.impl.completer.AggregateCompleter
+import org.jline.reader.impl.completer.StringsCompleter
import org.jline.shell.CommandSession
import org.jline.shell.impl.AbstractCommand
import org.jline.shell.impl.SimpleCommandGroup
@@ -253,6 +257,42 @@ class GroovyshEmbeddingTest {
assert prompts.contains('gremlin> ')
}
+ @Test
+ void onReaderReadyCanContributeAGlobalCompleter() {
+ LineReader captured = null
+ int rc = Main.start(options()
+ .onReaderReady { LineReader reader, registry ->
+ captured = reader
+ // groovysh has already assigned its own completer; supplement it
+ reader.completer = new AggregateCompleter(registry.completer(),
+ new StringsCompleter('gremlinStep'))
+ }
+ .resultHandler { p, r -> }
+ .build(), '-e', '1')
+ assert rc == 0
+ assert captured != null
+
+ def candidates = new ArrayList<Candidate>()
+ def line = captured.parser.parse('gremlinSt', 'gremlinSt'.length(), Parser.ParseContext.COMPLETE)
+ captured.completer.complete(captured, line, candidates)
+ assert candidates*.value().contains('gremlinStep')
+ }
+
+ @Test
+ void onReaderReadyRunsAfterTheRegistryIsWired() {
+ def order = []
+ int rc = Main.start(options()
+ .groups(group('/ping', []))
+ .onReaderReady { LineReader reader, registry ->
+ order << (registry.hasCommand('/ping') ? 'registry-ready' : 'registry-incomplete')
+ order << (reader.completer != null ? 'completer-set' : 'completer-missing')
+ }
+ .resultHandler { p, r -> }
+ .build(), '-e', '1')
+ assert rc == 0
+ assert order == ['registry-ready', 'completer-set']
+ }
+
@Test
void startRejectsNullOptions() {
def thrown = false