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 ac2513bc2dc..026921c06f1 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 7030a7685ee..47cf60f1c3f 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']), @@ -115,6 +131,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)] } @@ -214,6 +236,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: + * + * 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. * @@ -871,6 +1101,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'), @@ -962,6 +1204,13 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry { new OptionCompleter([new StringsCompleter((Supplier)() -> engine.imports.keySet()), NullCompleter.INSTANCE], this::compileOptDescs, 1))] } + private List 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 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 d6267769fe4..c8915ad110a 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: "\" +COMMAND: "\" 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 557f54cb3fc..a13f280867d 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 00000000000..0d958ec908d 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 00000000000..8ab332d333d 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 00000000000..83fdb63737e 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 00000000000..18829168a00 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 e53a5522c42..5d4dcd8a97b 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 00000000000..1ac678735ba --- /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. + * + *

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 00000000000..950834e4020 --- /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 00000000000..142cdd84734 --- /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 662b75a890c..49f62ee7a89 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 00000000000..31bb6067ef9 --- /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 00000000000..c30354935b1 --- /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. + * + *

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 da853d70ed7..53106994104 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' + } }