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
The following commit(s) were added to refs/heads/master by this push:
new 09e912275e GROOVY-12003: Add /img command to groovysh for inline image
and chart display
09e912275e is described below
commit 09e912275e770773ca0eaafbcf170176c05906e9
Author: Paul King <[email protected]>
AuthorDate: Sat May 9 21:30:18 2026 +1000
GROOVY-12003: Add /img command to groovysh for inline image and chart
display
---
.../groovy/org/apache/groovy/groovysh/Main.groovy | 2 +-
.../groovy/groovysh/jline/GroovyCommands.groovy | 249 +++++++++++++++++++++
.../src/main/resources/nanorc/args.nanorc | 2 +-
.../src/main/resources/nanorc/groovy.nanorc | 2 +-
.../spec/doc/assets/img/repl_img_jfreechart.png | Bin 0 -> 109326 bytes
.../src/spec/doc/assets/img/repl_img_orson.png | Bin 0 -> 191999 bytes
.../src/spec/doc/assets/img/repl_img_smile.png | Bin 0 -> 185630 bytes
.../src/spec/doc/assets/img/repl_img_xchart.png | Bin 0 -> 159142 bytes
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 138 ++++++++++++
.../groovy/groovysh/commands/CompletionTest.groovy | 50 +++++
.../groovysh/commands/ConsoleCommandTest.groovy | 49 ++++
.../apache/groovy/groovysh/commands/DocTest.groovy | 53 +++++
.../groovy/groovysh/commands/GrabTest.groovy | 35 +++
.../apache/groovy/groovysh/commands/ImgTest.groovy | 163 ++++++++++++++
.../groovy/groovysh/commands/RoundTripTest.groovy | 63 ++++++
.../groovy/groovysh/jline/GroovyEngineTest.groovy | 56 +++++
16 files changed, 859 insertions(+), 3 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 ac2513bc2d..026921c06f 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
@@ -475,7 +475,7 @@ class Main {
if (!OSUtils.IS_WINDOWS) {
setSpecificHighlighter("/!",
SyntaxHighlighter.build(jnanorc, "SH-REPL"))
}
- addFileHighlight('/nano', '/less', '/slurp', '/load', '/save',
*GROOVY_POSIX_CMDS, '/cd')
+ addFileHighlight('/nano', '/less', '/slurp', '/load', '/save',
'/img', *GROOVY_POSIX_CMDS, '/cd')
addFileHighlight('/classloader', null, ['-a', '--add'])
addExternalHighlighterRefresh(printer::refresh)
addExternalHighlighterRefresh(scriptEngine::refresh)
diff --git
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
index 2a9e633089..1ff0c38d55 100644
---
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
+++
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
@@ -41,10 +41,25 @@ import org.jline.reader.impl.completer.AggregateCompleter
import org.jline.reader.impl.completer.ArgumentCompleter
import org.jline.reader.impl.completer.NullCompleter
import org.jline.reader.impl.completer.StringsCompleter
+import org.jline.terminal.Terminal
+import org.jline.terminal.impl.TerminalGraphics
+import org.jline.terminal.impl.TerminalGraphicsManager
import org.jline.utils.AttributedString
+import javax.imageio.ImageIO
+import javax.swing.ImageIcon
+import javax.swing.JComponent
+import javax.swing.JFrame
+import javax.swing.JLabel
+import javax.swing.SwingUtilities
+import javax.swing.WindowConstants
+import java.awt.Color
import java.awt.Desktop
+import java.awt.Dimension
+import java.awt.Graphics2D
import java.awt.event.ActionListener
+import java.awt.image.BufferedImage
+import java.awt.image.RenderedImage
import java.lang.reflect.Method
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
@@ -70,6 +85,7 @@ class GroovyCommands extends JlineCommandRegistry implements
CommandRegistry {
'/console' : new Tuple4<>(this::console, this::defCompleter,
this::defCmdDesc, ['launch Groovy console']),
'/doc' : new Tuple4<>(this::doc, this::importsCompleter,
this::defCmdDesc, ['display documentation']),
'/grab' : new Tuple4<>(this::grab, this::grabCompleter,
this::grabCmdDesc, ['add maven repository dependencies to classpath']),
+ '/img' : new Tuple4<>(this::img, this::imgCompleter,
this::imgCmdDesc, ['display image inline (Sixel/Kitty/iTerm2 terminals)']),
'/classloader' : new Tuple4<>(this::classLoader,
this::classloaderCompleter, this::classLoaderCmdDesc, ['display/manage Groovy
classLoader data']),
'/imports' : new Tuple4<>(this::importsCommand,
this::importsCompleter, this::nameDeleteCmdDesc, ['show/delete import
statements']),
'/vars' : new Tuple4<>(this::varsCommand, this::varsCompleter,
this::nameDeleteCmdDesc, ['show/delete variable declarations']),
@@ -116,6 +132,12 @@ class GroovyCommands extends JlineCommandRegistry
implements CommandRegistry {
commands.remove('/grab')
}
+ // /img depends on java.desktop (BufferedImage, ImageIO, Swing
fallback).
+ // It's "static transitive" in JLine's module descriptor, so check at
runtime.
+ if (!ClassUtils.lookFor('javax.imageio.ImageIO')) {
+ commands.remove('/img')
+ }
+
def available = commands.collectEntries { name, tuple ->
[name, new CommandMethods((Function)tuple.v1, tuple.v2)]
}
@@ -215,6 +237,214 @@ class GroovyCommands extends JlineCommandRegistry
implements CommandRegistry {
return null
}
+ /**
+ * Displays an image inline using JLine's terminal-graphics support
+ * (Sixel, Kitty, iTerm2). Falls back to a summary line when the
+ * terminal doesn't speak any supported protocol; the {@code --gui}
+ * flag opens a Swing window instead.
+ *
+ * @param input parsed command input
+ * @return always {@code null}
+ */
+ def img(CommandInput input) {
+ // No fixed arg-count cap: a fully-specified invocation
+ // /img --width 64 --height 32 --no-preserve-aspect-ratio --gui $img
+ // is already 7 tokens. The parse loop below validates every flag and
+ // treats the lone non-option token as the positional, which is the
+ // useful constraint.
+ if (maybePrintHelp(input, '/img')) return
+ try {
+ Integer width = null
+ Integer height = null
+ boolean preserveAspect = true
+ boolean gui = false
+ Object positional = null
+ String positionalLabel = null
+ for (int i = 0; i < input.args().length; i++) {
+ String a = input.args()[i]
+ if (a == null) {
+ // JLine puts null into args() when a $var reference
resolves
+ // to null — usually because the variable isn't defined
yet.
+ throw new IllegalArgumentException(
+ '/img: variable reference resolved to null ' +
+ '(undefined or not yet assigned) — define
it first, e.g. ' +
+ "'panel =
ScatterPlot.of(...).canvas().panel()'")
+ }
+ if (a == '-w' || a == '--width') {
+ width = Integer.parseInt(requireArgValue(input, ++i, a))
+ } else if (a.startsWith('--width=')) {
+ width = Integer.parseInt(a.substring('--width='.length()))
+ } else if (a == '--height') {
+ height = Integer.parseInt(requireArgValue(input, ++i, a))
+ } else if (a.startsWith('--height=')) {
+ height =
Integer.parseInt(a.substring('--height='.length()))
+ } else if (a == '-p' || a == '--no-preserve-aspect-ratio') {
+ preserveAspect = false
+ } else if (a == '-g' || a == '--gui') {
+ gui = true
+ } else if (!a.startsWith('-')) {
+ // Use the resolved value from xargs — for "$myImage" this
+ // is the variable's value (e.g. a BufferedImage); for a
+ // plain string ("foo.png") it's the same string.
+ positional = input.xargs()[i]
+ positionalLabel = a
+ }
+ }
+ if (positional == null) {
+ throw new IllegalArgumentException('No image path, URL, or
variable provided')
+ }
+ BufferedImage image = positional instanceof String
+ ? loadImage((String) positional)
+ : coerceToImage(positional, width, height)
+ if (image == null) {
+ throw new IllegalArgumentException("Not a recognised image:
$positionalLabel")
+ }
+ // For raw-pixel inputs (file/URL/BufferedImage/RenderedImage),
--width
+ // and --height are terminal-display dimensions (cells). For inputs
+ // that *generate* the image from those dims (createBufferedImage /
+ // toBufferedImage / JComponent paint), the values are already
+ // consumed as source pixels and must NOT also be passed to the
+ // terminal opts — otherwise e.g. "--width=600" gets reinterpreted
+ // as 600 character cells and the chart renders blank/clipped.
+ boolean dimsConsumedByGeneration = !(positional instanceof String
+ || positional instanceof BufferedImage
+ || positional instanceof RenderedImage)
+ Terminal terminal = input.terminal()
+ if (gui) {
+ showInSwing(image, positionalLabel)
+ } else if (TerminalGraphicsManager.isGraphicsSupported(terminal)) {
+ def opts = new
TerminalGraphics.ImageOptions().preserveAspectRatio(preserveAspect)
+ if (!dimsConsumedByGeneration) {
+ if (width != null) opts.width(width)
+ if (height != null) opts.height(height)
+ }
+ TerminalGraphicsManager.displayImage(terminal, image, opts)
+ // Reset cursor to column 0 of the next line —
Sixel/iTerm2/Kitty
+ // protocols typically leave the cursor at the right edge of
+ // the image, which would indent the next prompt.
+ terminal.writer().println()
+ terminal.writer().flush()
+ } else {
+ // Coerce to String — DefaultPrinter renders unknown Object
+ // (including GString) as a field table; we want a plain line.
+ String summary = "[image: ${image.width}x${image.height},
$positionalLabel] " +
+ "(this terminal doesn't support inline images; try
Kitty/iTerm2/WezTerm, or use --gui)"
+ printer.println(summary)
+ }
+ } catch (Exception e) {
+ saveException(e)
+ }
+ return null
+ }
+
+ private static String requireArgValue(CommandInput input, int idx, String
flag) {
+ // Trailing-flag guard for `--width`/`--height` (and friends): without
+ // this, `/img --width $img` reads past the end of args() and surfaces
+ // an opaque ArrayIndexOutOfBoundsException via saveException.
+ if (idx >= input.args().length) {
+ throw new IllegalArgumentException("/img: missing value for $flag")
+ }
+ input.args()[idx]
+ }
+
+ private BufferedImage loadImage(String pathOrUrl) {
+ if (pathOrUrl.startsWith('http://') ||
pathOrUrl.startsWith('https://')) {
+ return ImageIO.read(URI.create(pathOrUrl).toURL())
+ }
+ Path path = workDir.get().resolve(pathOrUrl)
+ if (!Files.exists(path)) {
+ throw new IllegalArgumentException("File not found: $pathOrUrl")
+ }
+ ImageIO.read(path.toFile())
+ }
+
+ /**
+ * Converts an arbitrary value into a {@link BufferedImage} for /img.
+ * Supports:
+ * <ul>
+ * <li>{@code BufferedImage} — used as-is</li>
+ * <li>{@code RenderedImage} — drawn into a fresh {@code
BufferedImage}</li>
+ * <li>anything with {@code createBufferedImage(int, int)} (e.g.
+ * {@code org.jfree.chart.JFreeChart}) — duck-typed so groovysh
+ * doesn't take a hard dependency on JFreeChart</li>
+ * <li>anything with {@code toBufferedImage(int, int)} (e.g.
+ * {@code smile.plot.swing.Figure}) — duck-typed for Smile's
+ * parallel naming convention</li>
+ * <li>{@code JComponent} — laid out and painted to a {@code
BufferedImage}</li>
+ * </ul>
+ * Other types throw {@link IllegalArgumentException} with a clear message.
+ */
+ private BufferedImage coerceToImage(Object obj, Integer width, Integer
height) {
+ if (obj instanceof BufferedImage) {
+ return (BufferedImage) obj
+ }
+ if (obj instanceof RenderedImage) {
+ return renderedToBuffered((RenderedImage) obj)
+ }
+ // Duck-type: createBufferedImage(int, int) — JFreeChart's signature.
+ try {
+ return (BufferedImage) obj.createBufferedImage(width ?: 800,
height ?: 600)
+ } catch (MissingMethodException ignore) {
+ // Not a JFreeChart-like — fall through.
+ }
+ // Duck-type: toBufferedImage(int, int) — Smile Figure's signature.
+ try {
+ return (BufferedImage) obj.toBufferedImage(width ?: 800, height ?:
600)
+ } catch (MissingMethodException ignore) {
+ // Not a Smile-Figure-like — fall through.
+ }
+ if (obj instanceof JComponent) {
+ return renderJComponent((JComponent) obj, width, height)
+ }
+ throw new IllegalArgumentException(
+ "/img: don't know how to render ${obj.class.name}; supports " +
+ 'BufferedImage, RenderedImage, anything with
createBufferedImage(int,int) ' +
+ 'or toBufferedImage(int,int), or JComponent')
+ }
+
+ private static BufferedImage renderedToBuffered(RenderedImage src) {
+ if (src instanceof BufferedImage) return (BufferedImage) src
+ BufferedImage out = new BufferedImage(src.width, src.height,
BufferedImage.TYPE_INT_ARGB)
+ Graphics2D g = out.createGraphics()
+ try {
+ g.drawRenderedImage(src, new java.awt.geom.AffineTransform())
+ } finally {
+ g.dispose()
+ }
+ out
+ }
+
+ private static BufferedImage renderJComponent(JComponent comp, Integer
width, Integer height) {
+ Dimension preferred = comp.preferredSize
+ int w = width ?: (preferred.width > 0 ? preferred.width : 800)
+ int h = height ?: (preferred.height > 0 ? preferred.height : 600)
+ comp.size = new Dimension(w, h)
+ comp.doLayout()
+ BufferedImage image = new BufferedImage(w, h,
BufferedImage.TYPE_INT_ARGB)
+ Graphics2D g = image.createGraphics()
+ try {
+ // Most plot panels assume a light background; default JComponent
+ // paints transparent pixels which look broken when displayed.
+ g.color = Color.WHITE
+ g.fillRect(0, 0, w, h)
+ comp.paint(g)
+ } finally {
+ g.dispose()
+ }
+ image
+ }
+
+ private static void showInSwing(BufferedImage image, String title) {
+ SwingUtilities.invokeLater {
+ JFrame frame = new JFrame(title)
+ frame.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
+ frame.add(new JLabel(new ImageIcon(image)))
+ frame.pack()
+ frame.locationRelativeTo = null
+ frame.visible = true
+ }
+ }
+
/**
* Clears the current buffer.
*
@@ -874,6 +1104,18 @@ class GroovyCommands extends JlineCommandRegistry
implements CommandRegistry {
])
}
+ private CmdDesc imgCmdDesc(String name) {
+ new CmdDesc([
+ new AttributedString("$name [OPTIONS] (FILE | URL | \$VAR)"),
+ ], [], [
+ '-? --help' : doDescription('Displays
command help'),
+ '-w --width=N' : doDescription('Width: terminal
cells for raw images, source pixels for charts'),
+ ' --height=N' : doDescription('Height:
terminal cells for raw images, source pixels for charts'),
+ '-p --no-preserve-aspect-ratio' : doDescription("Don't preserve
aspect ratio"),
+ '-g --gui' : doDescription('Open in a Swing
window instead of inline')
+ ])
+ }
+
private CmdDesc inspectCmdDesc(String name) {
def optDescs = [
'-? --help' : doDescription('Displays command help'),
@@ -971,6 +1213,13 @@ class GroovyCommands extends JlineCommandRegistry
implements CommandRegistry {
new OptionCompleter([new StringsCompleter((Supplier)() ->
engine.imports.keySet()), NullCompleter.INSTANCE], this::compileOptDescs, 1))]
}
+ private List<Completer> imgCompleter(String command) {
+ // Hint common image extensions; users can still tab-complete other
files.
+ [new ArgumentCompleter(NullCompleter.INSTANCE,
+ new OptionCompleter(new Completers.FilesCompleter(workDir),
+ this::compileOptDescs, 1))]
+ }
+
private List<Completer> inspectCompleter(String command) {
[new ArgumentCompleter(NullCompleter.INSTANCE,
new OptionCompleter([new StringsCompleter((Supplier)
this::variables), NullCompleter.INSTANCE],
diff --git a/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
b/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
index d6267769fe..c8915ad110 100644
--- a/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
+++ b/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
@@ -17,7 +17,7 @@ syntax "ARGS"
NUMBER: "\<[-]?[0-9]*([Ee][+-]?[0-9]+)?\>" "\<[-]?[0](\.[0-9]+)?\>"
STRING: "[a-zA-Z]+[a-zA-Z0-9]*"
-COMMAND:
"\</?(alias|classloader|clear|colors|console|del|doc|echo|exit|grab|help|highlighter|history|imports|inspect|keymap|less|load|methods|nano|pipe|prnt|reset|save|setopt|show|slurp|ttop|types|unalias|unsetopt|vars|widget)\>"
+COMMAND:
"\</?(alias|classloader|clear|colors|console|del|doc|echo|exit|grab|help|highlighter|history|imports|img|inspect|keymap|less|load|methods|nano|pipe|prnt|reset|save|setopt|show|slurp|ttop|types|unalias|unsetopt|vars|widget)\>"
NULL: "\<null\>"
BOOLEAN: "\<(true|false)\>"
VARIABLE: "(\[|,)\s*[a-zA-Z0-9]*\s*:"
diff --git
a/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
b/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
index 557f54cb3f..a13f280867 100644
--- a/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
+++ b/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
@@ -15,7 +15,7 @@
syntax "Groovy" "\.(groovy|gradle)$"
-TYPE:
"\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|var|void)\>"
+TYPE:
"\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|val|var|void)\>"
KEYWORD:
"\<(case|catch|default|do|else|finally|for|if|return|switch|throw|try|while)\>"
KEYWORD:
"\<(abstract|class|extends|final|implements|import|instanceof|interface|native|non-sealed|package)\>"
KEYWORD:
"\<(permits|private|protected|public|record|sealed|static|strictfp|super|synchronized|throws|trait|volatile)\>"
diff --git
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_jfreechart.png
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_jfreechart.png
new file mode 100644
index 0000000000..0d958ec908
Binary files /dev/null and
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_jfreechart.png
differ
diff --git
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_orson.png
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_orson.png
new file mode 100644
index 0000000000..8ab332d333
Binary files /dev/null and
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_orson.png differ
diff --git
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_smile.png
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_smile.png
new file mode 100644
index 0000000000..83fdb63737
Binary files /dev/null and
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_smile.png differ
diff --git
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_xchart.png
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_xchart.png
new file mode 100644
index 0000000000..18829168a0
Binary files /dev/null and
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_xchart.png differ
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index 6b0bb47621..cf311f46c7 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -720,6 +720,144 @@ groovy> /imports
import java.util.concurrent.BlockingQueue
--------------
+[[GroovyShell-img]]
+==== `/img`
+
+The `/img` command displays an image inline using JLine's terminal-graphics
+support (Sixel, Kitty, or iTerm2 protocols, auto-detected). The argument is
+either a local file path, an `http(s)://` URL, or a Groovy variable
+reference using the standard `$` syntax.
+
+[source,jshell]
+--------------
+groovy> /img chart.png
+groovy> /img --width=80 https://example.com/diagram.png
+groovy> img = new java.awt.image.BufferedImage(200, 100, 1) // some
BufferedImage
+groovy> /img $img
+--------------
+
+When the argument resolves to a Groovy value (rather than a path), `/img`
+will accept any of:
+
+* a `java.awt.image.BufferedImage` — used as-is
+* a `java.awt.image.RenderedImage` — drawn into a `BufferedImage`
+* anything with a `createBufferedImage(int, int)` method (e.g.
+ `org.jfree.chart.JFreeChart`) — duck-typed, no compile-time dependency
+* anything with a `toBufferedImage(int, int)` method (e.g. Smile's
+ `smile.plot.swing.Figure`) — duck-typed sibling for libraries that
+ follow the `to…` rather than `create…` naming convention
+* a `javax.swing.JComponent` (e.g. Smile's `Canvas` and `MultiFigurePane`) —
+ laid out and painted to a `BufferedImage` at the requested size
+
+So once you've grabbed JFreeChart you can render charts in the REPL
+without saving to a file:
+
+[source,jshell]
+--------------
+groovy> /grab org.jfree:jfreechart:1.5.6
+groovy> import org.jfree.data.category.DefaultCategoryDataset
+groovy> ds = new DefaultCategoryDataset()
+groovy> ds.addValue(3d, 'Series', 'Mon'); ds.addValue(5d, 'Series', 'Tue')
+groovy> ds.addValue(7d, 'Series', 'Wed'); ds.addValue(4d, 'Series', 'Thu')
+groovy> import org.jfree.chart.ChartFactory
+groovy> chart = ChartFactory.createBarChart('demo', 'day', 'count', ds)
+groovy> /img $chart --width=600 --height=400
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_jfreechart.png[Using /img with
JFreeChart]
+
+Smile is similar — `Figure.toBufferedImage(int, int)` matches the second
+duck-type, so a fresh `Figure` can be handed straight to `/img`. The
+following draws the classic Iris scatter plot:
+
+[source,jshell]
+--------------
+groovy> /grab com.github.haifengl:smile-io:3.1.0
+groovy> import smile.io.Read
+groovy> iris = Read.arff('iris.arff')
+groovy> import smile.plot.swing.ScatterPlot
+groovy> figure = ScatterPlot.of(iris, 'sepallength', 'sepalwidth', 'class',
'*' as char).figure()
+groovy> figure.setAxisLabels('sepallength', 'sepalwidth')
+groovy> /img $figure --width=600 --height=400
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_smile.png[Using /img with Smile]
+
+Smile's `Canvas` and `MultiFigurePane` are themselves `JComponent` subclasses,
+so the SPLOM (scatter-plot matrix) goes through the `JComponent` path with no
+extra wrapping:
+
+[source,jshell]
+--------------
+groovy> import smile.plot.swing.MultiFigurePane
+groovy> splom = MultiFigurePane.splom(iris, '*' as char, 'class')
+groovy> /img $splom --width=900 --height=900
+--------------
+
+Orson Charts gives 3D chart shapes (pie, bar, scatter, surface, …).
+`Chart3D` doesn't expose `createBufferedImage` directly, but pairs with
+`Chart3DPanel` (a `JComponent`) which reaches `/img` through the same
+JComponent path as Smile's SPLOM. Pairing that with Apache Commons CSV
+— handed a `population.csv` whose columns are `country` and `millions`
+— gives a 3D pie of the world's ten most populous countries:
+
+[source,jshell]
+--------------
+groovy> /grab org.apache.commons:commons-csv:1.14.1
+groovy> import static org.apache.commons.csv.CSVFormat.RFC4180 as CSV
+groovy> parser = CSV.builder().setHeader().setSkipHeaderRecord(true).build()
+groovy> records = parser.parse(new FileReader('population.csv'))
+groovy> /grab org.jfree:org.jfree.chart3d:2.1.1
+groovy> import module org.jfree.chart3d
+groovy> data = new StandardPieDataset3D()
+groovy> records.each { data.add(it.country, it.millions as double) }
+groovy> chart = Chart3DFactory.createPieChart('Top 10 by population
(millions)', null, data)
+groovy> panel = new Chart3DPanel(chart)
+groovy> /img $panel --width=700 --height=420
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_orson.png[Using /img with Orson
Charts]
+
+XChart's `BitmapEncoder.getBufferedImage(chart)` yields a plain
+`BufferedImage`, which is the simplest path of all — assign it to a
+variable and hand it straight to `/img`. Combining that with the
+`/slurp` command (which prefers `groovy-csv`'s `CsvSlurper` and falls
+back to Apache Commons CSV) gives a declarative CSV-to-chart pipeline.
+Reading a `co2-mlo.csv` whose columns are `Year` and `Mean` (annual mean
+Mauna Loa CO2 in ppm) draws the iconic Keeling-curve line:
+
+[source,jshell]
+--------------
+groovy> /grab org.knowm.xchart:xchart:3.8.8
+groovy> rows = /slurp co2-mlo.csv
+groovy> import org.knowm.xchart.XYChartBuilder
+groovy> chart = new XYChartBuilder().title('Mauna Loa annual mean
CO2').xAxisTitle('Year').yAxisTitle('CO2 (ppm)').width(600).height(400).build()
+groovy> chart.addSeries('CO2', rows.collect { it.Year as int }, rows.collect {
it.Mean as double })
+groovy> import org.knowm.xchart.BitmapEncoder
+groovy> img = BitmapEncoder.getBufferedImage(chart)
+groovy> /img $img
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_xchart.png[Using /img with XChart]
+
+The `--width` and `--height` options have two distinct meanings depending
+on the input. For raw-pixel inputs (file path, `http(s)://` URL,
+`BufferedImage`, `RenderedImage`), they control the rendered size in
+*terminal character cells*. For inputs that generate the image at the
+requested size (`createBufferedImage(int,int)`, `toBufferedImage(int,int)`,
+`JComponent`), they are *source-image pixels* — the terminal then renders
+the image at its natural fit. Aspect ratio is preserved by default
+(override with `--no-preserve-aspect-ratio`).
+
+If the active terminal doesn't speak any of the supported graphics protocols
+(common cases: macOS Terminal.app, VS Code's built-in terminal, JetBrains
+terminals, plain Windows console), `/img` prints a `[image: WxH, label]`
+summary line instead. Pass `--gui` to open a Swing window with the image
+regardless of terminal capability.
+
+The command requires the `java.desktop` module at runtime; on a JVM where
+that module is unavailable, `/img` is not registered.
+
[[GroovyShell-inspect]]
==== `/inspect`
diff --git
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/CompletionTest.groovy
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/CompletionTest.groovy
new file mode 100644
index 0000000000..1ac678735b
--- /dev/null
+++
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/CompletionTest.groovy
@@ -0,0 +1,50 @@
+/*
+ * 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.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Smoke test for tab completion. Each registered command in
+ * {@link org.apache.groovy.groovysh.jline.GroovyCommands} wires up a
+ * completer factory; previously, none of those factories were exercised
+ * by any test.
+ *
+ * <p>The smoke we test here is "every factory compiles without throwing".
+ * Driving end-to-end completion (typing a partial line, asserting
+ * candidates) needs a real LineReader pumping the parser — this unit
+ * harness can't fake that reliably. Even so, this catches the most
+ * common breakage: a typo or missing import in a completer factory
+ * function that would NPE the first time a user hits TAB.
+ */
+class CompletionTest extends SystemTestSupport {
+
+ @Test
+ void everyCommandsCompleterFactoryCompilesWithoutThrowing() {
+ // CommandRegistry.compileCompleters() invokes every per-command
+ // completer factory function. If any of them throws (typo in a
+ // class reference, missing JLine import, broken constructor
+ // chain), this test surfaces it.
+ def systemCompleter = groovy.compileCompleters()
+ assert systemCompleter != null
+ assert !systemCompleter.compiled
+ systemCompleter.compile()
+ assert systemCompleter.compiled
+ }
+}
diff --git
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
new file mode 100644
index 0000000000..950834e402
--- /dev/null
+++
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
@@ -0,0 +1,49 @@
+/*
+ * 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.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the {@code /console} command. The command launches a Swing
+ * Groovy console window — we can't (and shouldn't) drive that in CI, but
+ * the registration and help paths are deterministic.
+ */
+class ConsoleCommandTest extends SystemTestSupport {
+
+ @Test
+ void consoleIsRegisteredWhenObjectBrowserOnClasspath() {
+ // /console is conditionally registered: present iff
+ // groovy.console.ui.ObjectBrowser is on the classpath. The test
+ // module pulls groovy-console in transitively, so it should be
+ // there. If anyone removes that dep without intent, this fails.
+ assert '/console' in groovy.commandNames()
+ }
+
+ @Test
+ void consoleHelpFlagDoesNotLaunchTheConsole() {
+ // maybePrintHelp short-circuits before `new Console(...)`, so
+ // `/console --help` is the only way to exercise the command in a
+ // headless test without opening a frame. Asserts on output growth
+ // — the help machinery captures via the printer.
+ int before = printer.output.size()
+ system.execute('/console --help')
+ assert printer.output.size() > before
+ }
+}
diff --git
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy
new file mode 100644
index 0000000000..142cdd8473
--- /dev/null
+++
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy
@@ -0,0 +1,53 @@
+/*
+ * 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.commands
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for the {@code /doc} command. The command opens documentation in
+ * a browser via {@link java.awt.Desktop} when configured, but the early
+ * exit and error paths (no args, missing config, headless JVM) are
+ * deterministic and cheap to cover.
+ */
+class DocTest extends SystemTestSupport {
+
+ @Test
+ void docWithNoArgsIsNoOp() {
+ // doc() returns early when xargs is empty; no exception, no output.
+ system.execute('/doc')
+ }
+
+ @Test
+ void docForUnknownTargetSurfacesAClearError() {
+ // Without a CONSOLE_OPTIONS map, /doc throws IllegalStateException.
+ // The exact message varies by environment:
+ // - headless JVM: "Desktop is not supported!"
+ // - desktop dev box: "No documents configuration!"
+ // Don't pin to either; just lock in that the failure is targeted
+ // and not, e.g., an NPE walking through xargs.
+ def thrown = shouldFail(IllegalStateException) {
+ system.execute('/doc List')
+ }
+ assert thrown.message != null
+ assert !thrown.message.empty
+ }
+}
diff --git
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
index 662b75a890..49f62ee7a8 100644
---
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
+++
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
@@ -22,6 +22,8 @@ import groovy.junit6.plugin.ForkedJvm
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.condition.EnabledIfSystemProperty
+import static groovy.test.GroovyAssert.shouldFail
+
/**
* Tests for the {@code /grab} command — Maven-coordinate dependency
* resolution via Grape. The actual artifact-fetching test is forked and
@@ -50,4 +52,37 @@ class GrabTest extends SystemTestSupport {
assert cls != null
assert cls.name == 'org.apache.commons.lang3.StringUtils'
}
+
+ @Test
+ void grabWithMalformedCoordsRaisesAClearError() {
+ // Coords must be group:module:version (3 colon-separated parts).
+ // An incomplete spec should fail fast with a targeted message
+ // rather than reaching the network and timing out.
+ def thrown = shouldFail(IllegalArgumentException) {
+ system.execute('/grab org.apache.commons:commons-lang3')
+ }
+ assert thrown.message.contains('Invalid command parameter')
+ assert thrown.message.contains('commons-lang3')
+ }
+
+ @Test
+ void grabWithUnknownTwoArgFlagRaisesAClearError() {
+ // Two-arg form only accepts -v/--verbose. Anything else (here a
+ // bogus -x) is rejected before any network attempt.
+ def thrown = shouldFail(IllegalArgumentException) {
+ system.execute('/grab -x foo:bar:1.0')
+ }
+ assert thrown.message.contains('Unknown command parameters')
+ }
+
+ @Test
+ void grabListEnumeratesCachedGrapes() {
+ // /grab --list calls Grape.instance.enumerateGrapes() which is
+ // local-only (no network); even with an empty cache it returns
+ // an empty map without throwing. Verifies the --list branch is
+ // reachable and produces output via the printer.
+ int before = printer.output.size()
+ system.execute('/grab --list')
+ assert printer.output.size() > before
+ }
}
diff --git
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy
new file mode 100644
index 0000000000..31bb6067ef
--- /dev/null
+++
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy
@@ -0,0 +1,163 @@
+/*
+ * 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.commands
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import javax.imageio.ImageIO
+import javax.swing.JLabel
+import java.awt.image.BufferedImage
+import java.nio.file.Path
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Smoke tests for the {@code /img} command. Asserts on the fallback path
+ * (dumb terminal doesn't speak Sixel/Kitty/iTerm2) — the actual ANSI-image
+ * emission isn't testable through a captured byte stream without a real
+ * graphics-protocol terminal.
+ */
+class ImgTest extends SystemTestSupport {
+
+ @TempDir
+ Path tmp
+
+ private Path writePng(String name, int w, int h) {
+ Path file = tmp.resolve(name)
+ BufferedImage image = new BufferedImage(w, h,
BufferedImage.TYPE_INT_RGB)
+ ImageIO.write(image, 'png', file.toFile())
+ file
+ }
+
+ @Test
+ void unsupportedTerminalProducesSummaryLine() {
+ Path file = writePng('chart.png', 12, 8)
+ system.execute("/img ${forwardSlashes(file)}")
+ // Dumb terminal — none of Sixel/Kitty/iTerm2 — falls through to the
+ // summary-line branch in img(). Assert on dimensions and the file
+ // identifier; don't pin to exact wording.
+ def out = printer.output.join()
+ assert out.contains('12x8')
+ assert out.contains('chart.png')
+ }
+
+ @Test
+ void missingFileSurfacesAClearError() {
+ // /img saves exceptions via saveException(); JLine's
+ // AbstractCommandRegistry.invoke rethrows them, so the user sees a
+ // clear error rather than a silent no-op.
+ def thrown = shouldFail(IllegalArgumentException) {
+ system.execute('/img no-such-file.png')
+ }
+ assert thrown.message.contains('File not found')
+ assert thrown.message.contains('no-such-file.png')
+ }
+
+ @Test
+ void imgFromBufferedImageVariable() {
+ // /img $var resolves the engine variable to its value via xargs.
+ BufferedImage src = new BufferedImage(20, 10,
BufferedImage.TYPE_INT_RGB)
+ engine.put('imgVar', src)
+ system.execute('/img $imgVar')
+ def out = printer.output.join()
+ // Dumb terminal — falls through to the summary, which echoes the
+ // BufferedImage's actual dimensions.
+ assert out.contains('20x10')
+ }
+
+ @Test
+ void imgFromObjectWithCreateBufferedImage() {
+ // Duck-typed dispatch: anything with createBufferedImage(int,int) —
+ // mirrors JFreeChart's signature without us depending on JFreeChart.
+ engine.put('chart', new ChartLikeForTest())
+ system.execute('/img $chart --width=64 --height=32')
+ def out = printer.output.join()
+ assert out.contains('64x32')
+ }
+
+ @Test
+ void imgFromObjectWithToBufferedImage() {
+ // Sibling duck-type: anything with toBufferedImage(int,int) —
+ // mirrors Smile Figure's signature without us depending on Smile.
+ engine.put('figure', new FigureLikeForTest())
+ system.execute('/img $figure --width=72 --height=48')
+ def out = printer.output.join()
+ assert out.contains('72x48')
+ }
+
+ @Test
+ void imgFromJComponent() {
+ // JComponent path — laid out and painted into a BufferedImage at the
+ // requested size.
+ engine.put('label', new JLabel('hello'))
+ system.execute('/img $label --width=80 --height=24')
+ def out = printer.output.join()
+ assert out.contains('80x24')
+ }
+
+ @Test
+ void imgFromUnsupportedTypeErrors() {
+ engine.put('thing', 42)
+ def thrown = shouldFail(IllegalArgumentException) {
+ system.execute('/img $thing')
+ }
+ assert thrown.message.contains("don't know how to render")
+ assert thrown.message.contains('Integer')
+ }
+
+ @Test
+ void undefinedVariableProducesClearError() {
+ // $panel is not defined — JLine resolves it to null and passes null
into
+ // input.args(). Should surface as a friendly message, not a raw NPE on
+ // startsWith().
+ def thrown = shouldFail(IllegalArgumentException) {
+ system.execute('/img $panel')
+ }
+ assert thrown.message.contains('null')
+ assert !thrown.message.contains('startsWith')
+ }
+
+ @Test
+ void trailingWidthFlagWithoutValueProducesClearError() {
+ // `/img --width` (no value, no positional) used to walk past the end
+ // of args() and surface as an opaque ArrayIndexOutOfBoundsException
+ // via saveException. Should now be a targeted IllegalArgumentException
+ // naming the missing flag.
+ def thrown = shouldFail(IllegalArgumentException) {
+ system.execute('/img --width')
+ }
+ assert thrown.message.contains('--width')
+ assert thrown.message.contains('missing value')
+ }
+
+ /** A test stand-in for JFreeChart-shaped objects. */
+ static class ChartLikeForTest {
+ BufferedImage createBufferedImage(int w, int h) {
+ new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
+ }
+ }
+
+ /** A test stand-in for Smile-Figure-shaped objects. */
+ static class FigureLikeForTest {
+ BufferedImage toBufferedImage(int w, int h) {
+ new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
+ }
+ }
+}
diff --git
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy
new file mode 100644
index 0000000000..c30354935b
--- /dev/null
+++
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy
@@ -0,0 +1,63 @@
+/*
+ * 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.commands
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Path
+
+/**
+ * End-to-end test that drives several commands in sequence — the kind of
+ * cross-command regression no single per-command test can catch.
+ *
+ * <p>Distinct from {@link SaveLoadTest}, which exercises {@code /save} →
+ * {@code engine.reset()} → {@code /load} programmatically: this test
+ * routes the reset through the {@code /reset} command itself, so the
+ * full registry-dispatch path is exercised end-to-end.
+ */
+class RoundTripTest extends SystemTestSupport {
+
+ @TempDir
+ Path tmp
+
+ @Test
+ void buildSaveResetLoadRestoresState() {
+ system.execute('class Probe {}')
+ system.execute('def doubler(n) { n * 2 }')
+ system.execute('import java.time.LocalDate')
+
+ Path file = tmp.resolve('session.groovy')
+ system.execute("/save ${forwardSlashes(file)}")
+
+ // Reset via the registered /reset command (not engine.reset()).
+ // This is the bit SaveLoadTest doesn't cover.
+ system.execute('/reset')
+ assert engine.types.isEmpty()
+ assert engine.methodNames.isEmpty()
+ assert engine.imports.isEmpty()
+
+ system.execute("/load ${forwardSlashes(file)}")
+
+ assert engine.types.containsKey('Probe')
+ assert engine.methodNames.contains('doubler')
+ assert engine.imports.values().any {
it.contains('java.time.LocalDate') }
+ assert engine.execute('doubler(21)') == 42
+ }
+}
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 da853d70ed..5310699410 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
@@ -70,4 +70,60 @@ class GroovyEngineTest {
engine.execute('import java.awt.Point')
assert engine.imports.values().any { it.contains('java.awt.Point') }
}
+
+ @Test
+ void resetClearsTrackedDefinitionsButLeavesBindingVarsForFreshExecutes() {
+ // /reset wipes types/methods/imports/snippet-tracked variables;
+ // it does NOT delete shared/binding variables (those are managed
+ // by the underlying ScriptEngine and survive). This is contract
+ // users rely on.
+ engine.execute('class C {}')
+ engine.execute('def m(x) { x * 3 }')
+ engine.put('survivor', 'still here')
+
+ engine.reset()
+
+ assert engine.types.isEmpty()
+ assert engine.methodNames.isEmpty()
+ assert engine.imports.isEmpty()
+ assert engine.hasVariable('survivor')
+ assert engine.execute('survivor') == 'still here'
+ }
+
+ @Test
+ void redefiningATypeReplacesTheTrackedSnippet() {
+ // The user redefines a class (common in interactive use). The
+ // engine should keep only one snippet under that name and run
+ // the latest body — not stack two definitions and produce
+ // ambiguous behaviour.
+ engine.execute('class T { String greet() { "first" } }')
+ engine.execute('class T { String greet() { "second" } }')
+ assert engine.types.containsKey('T')
+ assert engine.execute('new T().greet()') == 'second'
+ }
+
+ @Test
+ void removeMethodDropsItFromTracking() {
+ engine.execute('def disposable() { 1 }')
+ assert engine.methodNames.contains('disposable')
+ engine.removeMethod('disposable')
+ assert !engine.methodNames.contains('disposable')
+ }
+
+ @Test
+ void removeTypeDropsItFromTracking() {
+ engine.execute('class Disposable {}')
+ assert engine.types.containsKey('Disposable')
+ engine.removeType('Disposable')
+ assert !engine.types.containsKey('Disposable')
+ }
+
+ @Test
+ void closureBindingVariableSurvivesAcrossExecutes() {
+ // A closure stored in the binding can be invoked by name on a
+ // later execute — useful for "save a callback, use it later"
+ // patterns that show up in REPL workflows.
+ engine.execute('greet = { name -> "hi, $name" }')
+ assert engine.execute("greet('paul')") == 'hi, paul'
+ }
}