You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/10/07 07:43:54 UTC

(groovy) 02/02: GROOVY-12411: Wire groovy-console and groovysh to the in-process language engine

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12411
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit c1208174ba8831ce3b8d90e5de2ee07b0a9ca37e
Author: Daniel Sun <su...@apache.org>
AuthorDate: Wed Oct 7 16:42:54 2026 +0900

    GROOVY-12411: Wire groovy-console and groovysh to the in-process language engine
    
    Hosts take a compileOnly groovy-lsp dependency and load GroovyLanguageEngine
    via Class.forName. Each host owns its buffer URI (groovy-buffer:console,
    groovy-buffer:repl). groovysh maps engine candidates onto JLine Candidate
    with curBuf+insert and complete=false.
---
 subprojects/groovy-console/build.gradle            |   2 +
 .../main/groovy/groovy/console/ui/Console.groovy   |  26 ++
 .../console/ui/language/ConsoleLanguageHost.groovy | 336 +++++++++++++++++++++
 .../groovy/groovy/console/ui/language/package.html |  27 ++
 .../src/spec/doc/groovy-console.adoc               |   6 +
 .../ui/language/ConsoleLanguageHostTest.groovy     | 136 +++++++++
 subprojects/groovy-groovysh/ARCHITECTURE.md        |  10 +
 subprojects/groovy-groovysh/build.gradle           |   2 +
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |  27 ++
 .../language/GroovyshLanguageSession.groovy        | 300 ++++++++++++++++++
 .../groovy/groovysh/language/package-info.groovy   |  25 ++
 .../groovy-groovysh/src/spec/doc/groovysh.adoc     |   7 +
 .../language/GroovyshLanguageSessionTest.groovy    | 182 +++++++++++
 13 files changed, 1086 insertions(+)

diff --git a/subprojects/groovy-console/build.gradle b/subprojects/groovy-console/build.gradle
index 695c3f2e3c..ef7e84c245 100644
--- a/subprojects/groovy-console/build.gradle
+++ b/subprojects/groovy-console/build.gradle
@@ -32,6 +32,8 @@ dependencies {
     runtimeOnly "org.abego.treelayout:org.abego.treelayout.core:${versions.treelayout}"
     testImplementation projects.groovyTest
     testImplementation testFixtures(projects.groovySwing)
+    compileOnly projects.groovyLsp
+    testImplementation projects.groovyLsp
 
     groovyCompilerClasspath(project(":")) {
         capabilities {
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
index 2c4d7afcaf..8418f96682 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
@@ -294,6 +294,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
     CompilerConfiguration config
     /** Shell used to compile and run scripts. */
     GroovyShell shell
+    /** Optional groovy-lsp host; null when groovy-lsp is not on the classpath. */
+    def languageHost
     /** Counter used for generated script names. */
     int scriptNameCounter = 0
     /** Interceptor that captures stdout. */
@@ -507,6 +509,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             config.addCompilationCustomizers(new ASTTransformationCustomizer(ThreadInterrupt))
         }
         shell = new GroovyShell(parent, binding, config)
+        languageHost?.setParentLoader(shell.classLoader)
     }
 
     /** Default Swing delegates used to build the console frame and menu bar. */
@@ -579,9 +582,26 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         }
         installInterceptor()
         updateTitle()
+        installLanguageHost()
         swing.doLater inputArea.&requestFocus
     }
 
+    /**
+     * Attaches the optional groovy-lsp host when that module is on the
+     * classpath. The host class is loaded reflectively so groovy-console
+     * stays usable without groovy-lsp or LSP4J.
+     */
+    private void installLanguageHost() {
+        try {
+            Class.forName('org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine')
+            def hostClass = Class.forName('groovy.console.ui.language.ConsoleLanguageHost')
+            languageHost = hostClass.getMethod('attach', JTextPane, ClassLoader)
+                    .invoke(null, inputArea, shell.classLoader)
+        } catch (Exception | LinkageError ignored) {
+            languageHost = null
+        }
+    }
+
     /**
      * Make the console frames capable of native fullscreen
      * for Mac OS X Lion and beyond.
@@ -1067,6 +1087,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         if (askToInterruptScript()) {
             def exit = askToSaveFile()
             if (exit) {
+                languageHost?.close()
+                languageHost = null
                 if (frame instanceof Window) {
                     frame.hide()
                     frame.dispose()
@@ -1114,6 +1136,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         nativeFullScreenForMac(swing.consoleFrame)
         swing.consoleFrame.pack()
         swing.consoleFrame.show()
+        consoleController.installLanguageHost()
         swing.doLater swing.inputArea.&requestFocus
     }
 
@@ -1145,6 +1168,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
                     listeners.each { inputArea.document.addDocumentListener(it) }
                     setDirty(false)
                     inputArea.caretPosition = 0
+                    languageHost?.refresh()
                 }
             } finally {
                 swing.edt { inputArea.editable = true }
@@ -1713,6 +1737,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             fc.selectedFiles?.each { file ->
                 shell.getClassLoader().addURL(file.toURL())
             }
+            languageHost?.setParentLoader(shell.classLoader)
         }
     }
 
@@ -1725,6 +1750,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             currentClasspathDir = fc.currentDirectory
             Preferences.userNodeForPackage(Console).put('currentClasspathDir', currentClasspathDir.path)
             shell.getClassLoader().addURL(fc.selectedFile.toURL())
+            languageHost?.setParentLoader(shell.classLoader)
         }
     }
 
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy
new file mode 100644
index 0000000000..6e6357150f
--- /dev/null
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy
@@ -0,0 +1,336 @@
+/*
+ *  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 groovy.console.ui.language
+
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
+
+import javax.swing.JMenuItem
+import javax.swing.JPopupMenu
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+import javax.swing.event.DocumentEvent
+import javax.swing.event.DocumentListener
+import javax.swing.text.BadLocationException
+import javax.swing.text.DefaultHighlighter
+import java.awt.Color
+import java.awt.Point
+import java.awt.event.InputEvent
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Optional in-process language host. Loaded only when groovy-lsp is present.
+ * Compiles the unsaved editor buffer through {@link GroovyLanguageEngine}
+ * (no class generation) and paints diagnostics; Ctrl-Space offers completions.
+ */
+final class ConsoleLanguageHost implements AutoCloseable {
+
+    /** This editor's unsaved buffer. The engine does not assign the name. */
+    static final URI BUFFER_URI = URI.create('groovy-buffer:console')
+
+    private static final int DEBOUNCE_MS = 150
+    private static final int MAX_COMPLETIONS = 20
+
+    private final GroovyLanguageEngine engine
+    private final JTextPane input
+    private final DefaultHighlighter.DefaultHighlightPainter errorPainter =
+            new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 180, 180))
+    private final List<Object> highlights = []
+    private final AtomicInteger generation = new AtomicInteger()
+    private final ScheduledExecutorService debounce = Executors.newSingleThreadScheduledExecutor { Runnable r ->
+        Thread thread = new Thread(r, 'groovy-console-language')
+        thread.daemon = true
+        thread
+    }
+    private ScheduledFuture<?> pending
+
+    private ConsoleLanguageHost(JTextPane input, ClassLoader loader) {
+        this.input = input
+        this.engine = new GroovyLanguageEngine()
+        engine.setParentLoader(loader)
+        engine.openBuffer(BUFFER_URI, 'groovy', textOf(input))
+        if (input?.document != null) {
+            input.document.addDocumentListener(new DocumentListener() {
+                @Override
+                void insertUpdate(DocumentEvent e) { schedule() }
+
+                @Override
+                void removeUpdate(DocumentEvent e) { schedule() }
+
+                @Override
+                void changedUpdate(DocumentEvent e) { }
+            })
+            input.addKeyListener(new KeyAdapter() {
+                @Override
+                void keyPressed(KeyEvent e) {
+                    if (e.keyCode == KeyEvent.VK_SPACE && (e.modifiersEx & InputEvent.CTRL_DOWN_MASK) != 0) {
+                        showCompletions()
+                        e.consume()
+                    }
+                }
+            })
+        }
+    }
+
+    /**
+     * @param input editor pane
+     * @param loader GroovyShell class loader
+     * @return a host bound to {@code input}
+     */
+    static ConsoleLanguageHost attach(JTextPane input, ClassLoader loader) {
+        new ConsoleLanguageHost(input, loader)
+    }
+
+    /**
+     * Compiles {@code text} immediately and returns diagnostics.
+     *
+     * @param text buffer text
+     * @return hits in 0-based UTF-16 coordinates
+     */
+    List<GroovyLanguageEngine.Hit> compileAndDiagnostics(String text) {
+        engine.updateBuffer(BUFFER_URI, text)
+        engine.compileNow()
+        engine.diagnostics(BUFFER_URI)
+    }
+
+    /**
+     * Completions at a 0-based UTF-16 position.
+     *
+     * @param text buffer text
+     * @param line 0-based line
+     * @param character 0-based character
+     * @return candidates
+     */
+    List<GroovyLanguageEngine.Candidate> complete(String text, int line, int character) {
+        engine.updateBuffer(BUFFER_URI, text)
+        engine.compileNow()
+        engine.complete(BUFFER_URI, line, character)
+    }
+
+    /**
+     * Hover markdown at a 0-based UTF-16 position.
+     *
+     * @param text buffer text
+     * @param line 0-based line
+     * @param character 0-based character
+     * @return markdown
+     */
+    String describe(String text, int line, int character) {
+        engine.updateBuffer(BUFFER_URI, text)
+        engine.compileNow()
+        engine.describe(BUFFER_URI, line, character)
+    }
+
+    /**
+     * Compiles the current editor text and paints diagnostic highlights.
+     */
+    void refresh() {
+        generation.incrementAndGet()
+        pending?.cancel(false)
+        String text = textOf(input)
+        paint(text, compileAndDiagnostics(text))
+    }
+
+    /**
+     * Offers completions at the caret. Used by Ctrl-Space and tests.
+     */
+    void completeAtCaret() {
+        showCompletions()
+    }
+
+    /**
+     * @param loader GroovyShell class loader after classpath changes
+     */
+    void setParentLoader(ClassLoader loader) {
+        engine.setParentLoader(loader)
+        engine.putExtra('classpathGeneration', String.valueOf(urlStamp(loader)))
+    }
+
+    @Override
+    void close() {
+        generation.incrementAndGet()
+        pending?.cancel(false)
+        debounce.shutdownNow()
+        engine.close()
+    }
+
+    private void schedule() {
+        int gen = generation.incrementAndGet()
+        String text = textOf(input)
+        pending?.cancel(false)
+        pending = debounce.schedule({
+            try {
+                if (gen != generation.get()) {
+                    return
+                }
+                def hits = compileAndDiagnostics(text)
+                if (gen != generation.get()) {
+                    return
+                }
+                SwingUtilities.invokeLater {
+                    if (gen == generation.get()) {
+                        paint(text, hits)
+                    }
+                }
+            } catch (RuntimeException ignored) {
+            }
+        }, DEBOUNCE_MS, TimeUnit.MILLISECONDS)
+    }
+
+    private void paint(String text, List<GroovyLanguageEngine.Hit> hits) {
+        def highlighter = input?.highlighter
+        if (highlighter == null) {
+            return
+        }
+        highlights.each { highlighter.removeHighlight(it) }
+        highlights.clear()
+        hits?.each { hit ->
+            int start = offsetOf(text, hit.startLine(), hit.startCharacter())
+            int end = offsetOf(text, hit.endLine(), hit.endCharacter())
+            if (end <= start) {
+                end = Math.min(start + 1, text == null ? 0 : text.length())
+            }
+            try {
+                highlights << highlighter.addHighlight(start, end, errorPainter)
+            } catch (BadLocationException ignored) {
+            }
+        }
+        if (hits) {
+            input.toolTipText = hits.take(3).collect { it.message() }.join('\n')
+        } else {
+            input.toolTipText = null
+        }
+    }
+
+    private void showCompletions() {
+        if (input == null) {
+            return
+        }
+        String text = textOf(input)
+        int caret = Math.min(Math.max(input.caretPosition, 0), text.length())
+        int line = 0
+        int character = 0
+        for (int i = 0; i < caret; i++) {
+            if (text.charAt(i) == '\n' as char) {
+                line++
+                character = 0
+            } else {
+                character++
+            }
+        }
+        pending?.cancel(false)
+        pending = debounce.schedule({
+            try {
+                def items = complete(text, line, character)
+                SwingUtilities.invokeLater { showMenu(items) }
+            } catch (RuntimeException ignored) {
+            }
+        }, 0, TimeUnit.MILLISECONDS)
+    }
+
+    private void showMenu(List<GroovyLanguageEngine.Candidate> items) {
+        if (input == null || !items) {
+            return
+        }
+        try {
+            def menu = new JPopupMenu()
+            items.take(MAX_COMPLETIONS).each { item ->
+                def entry = new JMenuItem(item.label() ?: item.insertText())
+                entry.addActionListener {
+                    insertAtCaret(input, plainInsert(item.insertText() ?: item.label()))
+                }
+                menu.add(entry)
+            }
+            Point point = input.caret.magicCaretPosition ?: new Point(0, 0)
+            menu.show(input, point.x, point.y + 16)
+        } catch (RuntimeException ignored) {
+        }
+    }
+
+    static void insertAtCaret(JTextPane pane, String insert) {
+        if (pane?.document == null) {
+            return
+        }
+        String text = textOf(pane)
+        String piece = insert ?: ''
+        int caret = Math.min(Math.max(pane.caretPosition, 0), text.length())
+        int start = caret
+        while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - 1))) {
+            start--
+        }
+        String prefix = text.substring(start, caret)
+        try {
+            if (prefix && piece.startsWith(prefix)) {
+                pane.document.insertString(caret, piece.substring(prefix.length()), null)
+            } else if (prefix) {
+                pane.document.remove(start, caret - start)
+                pane.document.insertString(start, piece, null)
+            } else {
+                pane.document.insertString(caret, piece, null)
+            }
+        } catch (BadLocationException ignored) {
+        }
+    }
+
+    static String plainInsert(String insert) {
+        if (insert == null || insert.isEmpty()) {
+            return ''
+        }
+        insert.replaceAll(/\$\{\d+:([^}]*)\}/, '$1').replaceAll(/\$\d+/, '')
+    }
+
+    private static int urlStamp(ClassLoader loader) {
+        if (loader instanceof URLClassLoader) {
+            int hash = 1
+            loader.URLs.each { url ->
+                String form = url == null ? '' : url.toExternalForm()
+                hash = 31 * hash + form.hashCode()
+            }
+            return hash
+        }
+        System.identityHashCode(loader)
+    }
+
+    private static String textOf(JTextPane pane) {
+        pane == null ? '' : (pane.text ?: '')
+    }
+
+    static int offsetOf(String text, int line, int character) {
+        if (text == null || text.isEmpty()) {
+            return 0
+        }
+        int pos = 0
+        int current = 0
+        int row = Math.max(line, 0)
+        while (current < row) {
+            int nl = text.indexOf('\n', pos)
+            if (nl < 0) {
+                return text.length()
+            }
+            pos = nl + 1
+            current++
+        }
+        Math.min(pos + Math.max(character, 0), text.length())
+    }
+}
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/package.html b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/package.html
new file mode 100644
index 0000000000..4f060f5c44
--- /dev/null
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/package.html
@@ -0,0 +1,27 @@
+<!--
+  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.
+-->
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>package groovy.console.ui.language.*</title>
+  </head>
+  <body>
+    <p>Optional in-process groovy-lsp host for the Swing console. Loaded only when groovy-lsp is on the classpath.</p>
+  </body>
+</html>
diff --git a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
index e8b19811b8..a2222aabbb 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -167,6 +167,12 @@ being run by adding a new JAR or a directory to the classpath from the
 `Script` menu
 * Error hyperlinking from the output area when a compilation error is
 expected or when an exception is thrown
+* When the optional `groovy-lsp` jar is on the classpath, the editor
+compiles the unsaved buffer in the background and underlines compiler
+diagnostics. `Ctrl+Space` offers compiler-backed completions. Adding
+jars or directories to the classpath is visible to that compile.
+Scripts still run through `GroovyShell`. The language host answers
+questions about the buffer.
 
 [[GroovyConsole-EmbeddingtheConsole]]
 == Embedding the Console
diff --git a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy
new file mode 100644
index 0000000000..87431b85f6
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy
@@ -0,0 +1,136 @@
+/*
+ *  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 groovy.console.ui.language
+
+import org.junit.jupiter.api.Test
+
+import javax.swing.JTextPane
+import java.awt.event.InputEvent
+import java.awt.event.KeyEvent
+
+final class ConsoleLanguageHostTest {
+
+    @Test
+    void diagnosticsHoverAndCompletionUseTheEngine() {
+        def pane = new JTextPane()
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            def broken = 'class Broken { def x = }\n'
+            def hits = host.compileAndDiagnostics(broken)
+            assert hits.any { it.message() }
+
+            def src = '''\
+                class Sample {
+                    def ping() { ping() }
+                }
+                '''.stripIndent()
+            def hover = host.describe(src, 0, 6)
+            assert hover.contains('Sample')
+            def items = host.complete(src, 1, 16)
+            assert items != null
+            host.setParentLoader(ConsoleLanguageHostTest.classLoader)
+            host.refresh()
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void offsetOfAndPlainInsertCoverEdges() {
+        assert ConsoleLanguageHost.offsetOf(null, 0, 0) == 0
+        assert ConsoleLanguageHost.offsetOf('', 0, 0) == 0
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 0, 0) == 0
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 1, 1) == 4
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 9, 0) == 5
+        assert ConsoleLanguageHost.offsetOf('ab', -1, 99) == 2
+        assert ConsoleLanguageHost.plainInsert(null) == ''
+        assert ConsoleLanguageHost.plainInsert('') == ''
+        assert ConsoleLanguageHost.plainInsert('length') == 'length'
+        assert ConsoleLanguageHost.plainInsert('class ${1:Name} {\n    $0\n}\n') == 'class Name {\n    \n}\n'
+        assert ConsoleLanguageHost.BUFFER_URI.toString() == 'groovy-buffer:console'
+    }
+
+    @Test
+    void refreshPaintsDiagnosticsAndCompleteAtCaretIsSafe() {
+        def pane = new JTextPane()
+        pane.text = 'class Broken { def x = }\n'
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            host.refresh()
+            assert pane.highlighter.highlights.length > 0
+            pane.caretPosition = pane.text.indexOf('Broken')
+            host.completeAtCaret()
+            pane.dispatchEvent(new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+                    InputEvent.CTRL_DOWN_MASK, KeyEvent.VK_SPACE, ' ' as char))
+            pane.text = 'class Ok {}\n'
+            long deadline = System.currentTimeMillis() + 2000
+            while (System.currentTimeMillis() < deadline && pane.highlighter.highlights.length > 0) {
+                Thread.sleep(50)
+            }
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void attachWithEmptyPaneAndCloseIsIdempotentEnough() {
+        def host = ConsoleLanguageHost.attach(new JTextPane(), null)
+        try {
+            assert host.compileAndDiagnostics('') != null
+            assert host.complete('', 0, 0) != null
+            assert host.describe('class A {}', 0, 6) != null
+        } finally {
+            host.close()
+        }
+        def detached = ConsoleLanguageHost.attach(null, null)
+        try {
+            detached.refresh()
+            detached.completeAtCaret()
+            assert detached.compileAndDiagnostics(null) != null
+        } finally {
+            detached.close()
+        }
+    }
+
+    @Test
+    void insertAtCaretReplacesPrefixOrInserts() {
+        def pane = new JTextPane()
+        pane.text = 'na'
+        pane.caretPosition = 2
+        ConsoleLanguageHost.insertAtCaret(pane, 'name')
+        assert pane.text == 'name'
+        pane.text = 'xy'
+        pane.caretPosition = 2
+        ConsoleLanguageHost.insertAtCaret(pane, 'name')
+        assert pane.text.contains('name')
+        pane.text = ''
+        pane.caretPosition = 0
+        ConsoleLanguageHost.insertAtCaret(pane, 'x')
+        assert pane.text == 'x'
+        ConsoleLanguageHost.insertAtCaret(null, 'x')
+        pane.text = 'ab\ncd'
+        pane.caretPosition = pane.text.length()
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            host.completeAtCaret()
+        } finally {
+            host.close()
+        }
+    }
+}
diff --git a/subprojects/groovy-groovysh/ARCHITECTURE.md b/subprojects/groovy-groovysh/ARCHITECTURE.md
index be512c493c..0a15cc7bb4 100644
--- a/subprojects/groovy-groovysh/ARCHITECTURE.md
+++ b/subprojects/groovy-groovysh/ARCHITECTURE.md
@@ -238,6 +238,16 @@ the public seams rather than copying `Main`:
 - `ExtraConsoleCommands` — public top-level class for `/clear`,
   `/pwd`, `/cd`, the POSIX-style commands, and `/!`.
   `Main.ExtraConsoleCommands` is a deprecated subclass.
+- Optional `groovy-lsp`: `GroovyshLanguageSession` is loaded from
+  `Main` with `Class.forName` when
+  `org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine` is
+  present. groovysh depends on groovy-lsp as `compileOnly` so
+  groovy-all does not grow an LSP4J dependency. The session
+  implements JLine `Completer`, wraps `scriptDescription`, and
+  composes `GroovyEngine.getBuffer()` plus binding type
+  declarations plus the current line onto its own buffer URI
+  (`groovy-buffer:repl`). The engine does not assign that name.
+  Tests for it live at the engine layer (no terminal).
 
 Where a `GroovyshOptions` setting has an equivalent on JLine's
 `ShellBuilder`, it uses the same name and parameter type on purpose.
diff --git a/subprojects/groovy-groovysh/build.gradle b/subprojects/groovy-groovysh/build.gradle
index f0b9134065..71854d462a 100644
--- a/subprojects/groovy-groovysh/build.gradle
+++ b/subprojects/groovy-groovysh/build.gradle
@@ -33,6 +33,8 @@ dependencies {
     implementation projects.groovyXml
     implementation projects.groovyJson
     implementation projects.groovyNio
+    compileOnly projects.groovyLsp
+    testImplementation projects.groovyLsp
     testImplementation projects.groovyTest
     testImplementation projects.groovyCsv         // for /slurp .csv default coverage
     testImplementation projects.groovyMarkdown    // for /slurp .md default coverage
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 cc2836b06e..0a14a55b8f 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
@@ -40,6 +40,7 @@ import org.jline.console.Printer
 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
@@ -267,6 +268,7 @@ class Main {
             return 0
         }
         String evaluate = options.e ?: null
+        def languageSession = null
 
         try {
             DefaultParser parser = new DefaultParser(
@@ -376,6 +378,7 @@ class Main {
                 invoke '/alias', '/h', '/help'
                 setConsoleOption "ignoreUnknownPipes", true
             }
+            languageSession = installLanguageSession(scriptEngine, systemRegistry)
 
             def highlighter = new SystemHighlighter(commandHighlighter, argsHighlighter, groovyHighlighter).tap {
                 if (!OSUtils.IS_WINDOWS) {
@@ -476,6 +479,8 @@ class Main {
         } catch (Throwable t) {
             t.printStackTrace()
             return 1
+        } finally {
+            languageSession?.close()
         }
         return 0
     }
@@ -492,6 +497,28 @@ class Main {
         start(GroovyshOptions.builder().build(), args)
     }
 
+    /**
+     * Attaches the optional groovy-lsp session when that module is on the
+     * classpath. Loaded reflectively so groovysh stays usable without
+     * groovy-lsp or LSP4J.
+     *
+     * @param scriptEngine REPL engine
+     * @param systemRegistry command registry
+     * @return the session, or {@code null}
+     */
+    private static Object installLanguageSession(GroovyEngine scriptEngine, GroovySystemRegistry systemRegistry) {
+        try {
+            Class.forName('org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine')
+            def sessionClass = Class.forName('org.apache.groovy.groovysh.language.GroovyshLanguageSession')
+            def session = sessionClass.getConstructor(GroovyEngine).newInstance(scriptEngine)
+            systemRegistry.addCompleter((Completer) session)
+            systemRegistry.setScriptDescription { line -> session.scriptDescription(line) }
+            return session
+        } catch (Throwable ignored) {
+            return null
+        }
+    }
+
     private static void emitResult(Printer printer, Object result, GroovyshOptions.ResultHandler handler) throws Exception {
         if (handler != null) {
             handler.handle(printer, result)
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSession.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSession.groovy
new file mode 100644
index 0000000000..f4d4ef936a
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSession.groovy
@@ -0,0 +1,300 @@
+/*
+ *  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.language
+
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
+import org.jline.console.CmdDesc
+import org.jline.console.CmdLine
+import org.jline.reader.Candidate
+import org.jline.reader.Completer
+import org.jline.reader.LineReader
+import org.jline.reader.ParsedLine
+import org.jline.utils.AttributedString
+
+import java.lang.reflect.Modifier
+import java.util.regex.Pattern
+
+/**
+ * Optional compiler-backed session for groovysh. Loaded only when groovy-lsp
+ * is on the classpath. Does not import LSP4J. The adapter composes the REPL
+ * buffer, binding type declarations, and the current line; the engine compiles
+ * that synthetic script through semantic analysis.
+ */
+final class GroovyshLanguageSession implements AutoCloseable, Completer {
+
+    /** This session's unsaved buffer. The engine does not assign the name. */
+    private static final URI BUFFER_URI = URI.create('groovy-buffer:repl')
+
+    private static final Pattern SYSTEM_VAR = Pattern.compile('[A-Z]+[A-Z_]*')
+    private static final Pattern IDENTIFIER = Pattern.compile('[\\p{L}_$][\\p{L}\\p{N}_$]*')
+    private static final Set<String> SKIP = ['GROOVYSH_OPTIONS', 'GROOVY_OPTIONS', 'CONSOLE_OPTIONS',
+            'ROOT', 'PWD', 'NANORC', '_args'] as Set
+
+    private final GroovyEngine groovy
+    private final GroovyLanguageEngine engine
+    private int grapeGeneration
+    private int loaderStamp
+
+    GroovyshLanguageSession(GroovyEngine groovy) {
+        this.groovy = groovy
+        this.engine = new GroovyLanguageEngine()
+        refreshLoader()
+        engine.openBuffer(BUFFER_URI, 'groovy', '')
+    }
+
+    /**
+     * Compiles the session buffer plus {@code line} and returns diagnostics.
+     *
+     * @param line current input
+     * @return hits
+     */
+    List<GroovyLanguageEngine.Hit> diagnosticsFor(String line) {
+        refreshLoader()
+        engine.updateBuffer(BUFFER_URI, compose(line ?: ''))
+        engine.compileNow()
+        engine.diagnostics(BUFFER_URI)
+    }
+
+    /**
+     * Completions for the current line at end-of-buffer.
+     *
+     * @param line current input
+     * @return candidates
+     */
+    List<GroovyLanguageEngine.Candidate> completeLine(String line) {
+        refreshLoader()
+        String script = compose(line ?: '')
+        engine.updateBuffer(BUFFER_URI, script)
+        engine.compileNow()
+        int[] caret = endOf(script)
+        engine.complete(BUFFER_URI, caret[0], caret[1])
+    }
+
+    /**
+     * Tail-tip description: compiler diagnostics for syntax, signatures for
+     * an open call, otherwise the existing Inspector path.
+     *
+     * @param line JLine command line
+     * @return description, or {@code null}
+     */
+    CmdDesc scriptDescription(CmdLine line) {
+        try {
+            if (line != null && line.descriptionType == CmdLine.DescriptionType.SYNTAX) {
+                def hits = diagnosticsFor(line.head ?: '')
+                if (hits) {
+                    def desc = new CmdDesc()
+                    desc.setMainDesc(hits.take(5).collect { new AttributedString(it.message() ?: '') })
+                    return desc
+                }
+            }
+            if (line != null && line.descriptionType == CmdLine.DescriptionType.METHOD) {
+                def set = signaturesFor(line.head ?: '')
+                if (set.signatures()) {
+                    def desc = new CmdDesc()
+                    desc.setMainDesc(set.signatures().collect { new AttributedString(it.label() ?: '') })
+                    return desc
+                }
+            }
+            return groovy.scriptDescription(line)
+        } catch (Throwable ignored) {
+            return null
+        }
+    }
+
+    GroovyLanguageEngine.SignatureSet signaturesFor(String line) {
+        refreshLoader()
+        String script = compose(line ?: '')
+        engine.updateBuffer(BUFFER_URI, script)
+        engine.compileNow()
+        int[] caret = endOf(script)
+        int column = Math.max(caret[1] - 1, 0)
+        engine.signatures(BUFFER_URI, caret[0], column)
+    }
+
+    /**
+     * Call after {@code /grab} so the next compile does not reuse a stale loader.
+     */
+    void bumpGrapeGeneration() {
+        grapeGeneration++
+        engine.putExtra('grapeGeneration', String.valueOf(grapeGeneration))
+        engine.setParentLoader(groovy.classLoader)
+        loaderStamp = urlStamp(groovy.classLoader)
+    }
+
+    @Override
+    void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
+        if (candidates == null || line == null) {
+            return
+        }
+        String raw = line.line() ?: ''
+        if (raw.startsWith('/')) {
+            return
+        }
+        try {
+            def items = completeLine(raw)
+            String word = line.word() ?: ''
+            int cursor = Math.min(Math.max(line.wordCursor(), 0), word.length())
+            String buffer = word.substring(0, cursor)
+            int lastDelim = buffer.lastIndexOf('.')
+            String curBuf = lastDelim >= 0 ? buffer.substring(0, lastDelim + 1) : ''
+            items.each { item ->
+                String insert = plainInsert(item.insertText() ?: item.label())
+                if (insert.isEmpty()) {
+                    insert = item.label() ?: ''
+                }
+                if (insert.isEmpty()) {
+                    return
+                }
+                String value = insert.startsWith(curBuf) ? insert : curBuf + insert
+                candidates.add(new Candidate(value, item.label() ?: insert, item.kind() ?: 'other',
+                        item.detail() ?: null, null, null, false))
+            }
+        } catch (RuntimeException ignored) {
+        }
+    }
+
+    @Override
+    void close() {
+        engine.close()
+    }
+
+    /**
+     * Synthetic script: remembered snippets, then binding declarations for
+     * names the buffer does not already introduce, then the current line.
+     *
+     * @param current current input
+     * @return script text
+     */
+    String compose(String current) {
+        String preamble = groovy.buffer ?: ''
+        String decls = bindingDeclarations(preamble)
+        def parts = []
+        if (preamble) {
+            parts << preamble
+        }
+        if (decls) {
+            parts << decls
+        }
+        parts << (current ?: '')
+        parts.join('\n')
+    }
+
+    private void refreshLoader() {
+        def loader = groovy.classLoader
+        engine.setParentLoader(loader)
+        int stamp = urlStamp(loader)
+        if (stamp != loaderStamp) {
+            loaderStamp = stamp
+            engine.putExtra('grapeGeneration', String.valueOf(stamp))
+        }
+    }
+
+    static int urlStamp(ClassLoader loader) {
+        if (loader instanceof URLClassLoader) {
+            int hash = 1
+            loader.URLs.each { url ->
+                String form = url == null ? '' : url.toExternalForm()
+                hash = 31 * hash + form.hashCode()
+            }
+            return hash
+        }
+        System.identityHashCode(loader)
+    }
+
+    private String bindingDeclarations(String preamble) {
+        Map variables
+        try {
+            def found = groovy.find(null)
+            if (!(found instanceof Map)) {
+                return ''
+            }
+            variables = new LinkedHashMap(found)
+        } catch (Exception ignored) {
+            return ''
+        }
+        Set<String> already = declaredNames(preamble)
+        def lines = []
+        variables.each { key, value ->
+            String name = String.valueOf(key)
+            if (SKIP.contains(name) || SYSTEM_VAR.matcher(name).matches() || value == null) {
+                return
+            }
+            if (!IDENTIFIER.matcher(name).matches() || already.contains(name)) {
+                return
+            }
+            lines << "${typeLiteral(value)} ${name} = null"
+        }
+        lines.join('\n')
+    }
+
+    static Set<String> declaredNames(String buffer) {
+        def names = [] as Set
+        if (!buffer) {
+            return names
+        }
+        buffer.eachLine { String line ->
+            def assignment = line =~ /^\s*(?:@[\p{L}_][\p{L}\p{N}_.]*(?:\([^)]*\))?\s+)*(?:[\p{L}_][\p{L}\p{N}_.\$]*(?:\s+[\p{L}_][\p{L}\p{N}_.\$]*)*\s+)?(\p{L}[\p{L}\p{N}_\$]*)\s*=/
+            if (assignment.find()) {
+                names << assignment.group(1)
+            }
+            def method = line =~ /^\s*(?:def|void|int|long|boolean|[\p{L}_][\p{L}\p{N}_.]*)\s+(\p{L}[\p{L}\p{N}_\$]*)\s*\(/
+            if (method.find()) {
+                names << method.group(1)
+            }
+        }
+        names
+    }
+
+    static String typeLiteral(Object value) {
+        Class type = value.getClass()
+        if (type.isArray()) {
+            return type.canonicalName
+        }
+        if (type.isAnonymousClass() || type.isLocalClass() || type.isSynthetic()) {
+            return 'java.lang.Object'
+        }
+        String name = type.name
+        if (name.indexOf('$') >= 0 || !Modifier.isPublic(type.modifiers)) {
+            return 'java.lang.Object'
+        }
+        name
+    }
+
+    static int[] endOf(String script) {
+        if (script == null) {
+            return [0, 0] as int[]
+        }
+        String[] lines = script.split('\n', -1)
+        if (lines.length == 0) {
+            return [0, 0] as int[]
+        }
+        int row = lines.length - 1
+        [row, lines[row].length()] as int[]
+    }
+
+    static String plainInsert(String insert) {
+        if (insert == null || insert.isEmpty()) {
+            return ''
+        }
+        String flattened = insert.replaceAll(/\$\{\d+:([^}]*)\}/, '$1').replaceAll(/\$\d+/, '')
+        int nl = flattened.indexOf('\n')
+        nl < 0 ? flattened : flattened.substring(0, nl)
+    }
+}
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy
new file mode 100644
index 0000000000..d277961064
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy
@@ -0,0 +1,25 @@
+/*
+ *  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.
+ */
+
+/**
+ * Optional compiler-backed groovysh session. Loaded only when groovy-lsp is
+ * on the classpath; the adapter stays in this tree so groovysh does not take
+ * a runtime dependency on LSP4J.
+ */
+package org.apache.groovy.groovysh.language;
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index 51085b77a0..f0fcc6402a 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -38,6 +38,13 @@ to https://github.com/jline/jline3[JLine3].
 * ANSI colors (prompt, exception traces, etc).
 * Simple, yet robust, command system with online help, user alias
 support and more.
+* When the optional `groovy-lsp` jar is on the classpath, tab
+completion and tail-tip descriptions also consult a compiler-backed
+session over the current buffer (imports, types, methods, and
+binding variables). `/grab` remains the way to add Maven artifacts;
+the language session notices the loader change. Lines still evaluate
+through `GroovyEngine`. The language session answers questions about
+the buffer.
 
 [[GroovyShell-Command-lineOptionsandArguments]]
 === Command-line Options and Arguments
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy
new file mode 100644
index 0000000000..ab3f3e4cf8
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy
@@ -0,0 +1,182 @@
+/*
+ *  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.language
+
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
+import org.jline.console.CmdDesc
+import org.jline.console.CmdLine
+import org.jline.reader.ParsedLine
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+
+final class GroovyshLanguageSessionTest {
+
+    private GroovyEngine groovy
+    private GroovyshLanguageSession session
+
+    @AfterEach
+    void tearDown() {
+        session?.close()
+    }
+
+    @Test
+    void diagnosticsForBrokenInputReturnHits() {
+        groovy = new GroovyEngine()
+        session = new GroovyshLanguageSession(groovy)
+        def hits = session.diagnosticsFor('class Broken { def x = }')
+        assert hits.any { it.message() }
+    }
+
+    @Test
+    void composeDeclaresBindingVariablesAndSkipsInternals() {
+        groovy = new GroovyEngine()
+        groovy.put('answer', 42)
+        groovy.put('PWD', '/tmp')
+        groovy.put('FOO', 'skip')
+        groovy.put('1bad', 'skip')
+        groovy.put('nums', [1, 2] as int[])
+        groovy.put('run', new Runnable() {
+            @Override
+            void run() {
+            }
+        })
+        session = new GroovyshLanguageSession(groovy)
+        String script = session.compose('answer')
+        assert script.contains('java.lang.Integer answer = null')
+        assert !script.contains('PWD')
+        assert !script.contains('FOO')
+        assert !script.contains('1bad')
+        assert script.contains('int[] nums = null')
+        assert script.contains('java.lang.Object run = null')
+    }
+
+    @Test
+    void composeDoesNotRedeclareBufferNames() {
+        groovy = new GroovyEngine()
+        groovy.put('GROOVYSH_OPTIONS', [interpreterMode: true])
+        groovy.execute('int kept = 7')
+        session = new GroovyshLanguageSession(groovy)
+        String script = session.compose('kept')
+        assert script.readLines().count { it ==~ /.*\bkept\s*=.*/ } == 1
+    }
+
+    @Test
+    void completeLineAndCompleterMapEngineCandidates() {
+        groovy = new GroovyEngine()
+        groovy.execute('class Box { String name }')
+        session = new GroovyshLanguageSession(groovy)
+        def items = session.completeLine('new Box().')
+        assert items != null
+        def candidates = []
+        session.complete(null, new Line('Box().'), candidates)
+        assert candidates.every { it.value().startsWith('Box().') || it.value() }
+        session.complete(null, new Line('/grab'), candidates)
+        session.complete(null, null, candidates)
+        session.complete(null, new Line(''), null)
+        assert GroovyshLanguageSession.plainInsert('class ${1:Name} {\n    $0\n}') == 'class Name {'
+        assert GroovyshLanguageSession.plainInsert(null) == ''
+    }
+
+    @Test
+    void scriptDescriptionUsesDiagnosticsThenFallsBack() {
+        groovy = new GroovyEngine()
+        session = new GroovyshLanguageSession(groovy)
+        def syntax = new CmdLine('class Broken { def x = }', 'class Broken { def x = }', '', [],
+                CmdLine.DescriptionType.SYNTAX)
+        def desc = session.scriptDescription(syntax)
+        assert desc?.mainDesc
+        def method = new CmdLine('println(', 'println(', '', ['println'], CmdLine.DescriptionType.METHOD)
+        session.scriptDescription(method)
+        session.scriptDescription(null)
+        assert session.signaturesFor('') != null
+        groovy.execute('''\
+            class Box {
+                def ping(int a) { a }
+            }
+            '''.stripIndent())
+        def call = new CmdLine('new Box().ping(', 'new Box().ping(', '', ['new', 'Box'],
+                CmdLine.DescriptionType.METHOD)
+        session.scriptDescription(call)
+    }
+
+    @Test
+    void helpersCoverLoaderStampDeclaredNamesAndEmptyCompletions() {
+        assert GroovyshLanguageSession.urlStamp(new ClassLoader(null) {}) != 0
+        assert GroovyshLanguageSession.typeLiteral(Nested.A) == 'java.lang.Object'
+        assert GroovyshLanguageSession.declaredNames('def ping() {\n  1\n}').contains('ping')
+        assert GroovyshLanguageSession.declaredNames('@Deprecated int kept = 7').contains('kept')
+        assert GroovyshLanguageSession.declaredNames('').isEmpty()
+        assert GroovyshLanguageSession.endOf('') == [0, 0] as int[]
+        groovy = new GroovyEngine() {
+            @Override
+            CmdDesc scriptDescription(CmdLine line) {
+                throw new RuntimeException('boom')
+            }
+        }
+        session = new GroovyshLanguageSession(groovy)
+        def syntax = new CmdLine('1', '1', '', [], CmdLine.DescriptionType.SYNTAX)
+        assert session.scriptDescription(syntax) == null
+        session.metaClass.completeLine = { String line ->
+            [new GroovyLanguageEngine.Candidate('', '', '', '', '', false)]
+        }
+        def candidates = []
+        session.complete(null, new Line('x'), candidates)
+        assert candidates.isEmpty()
+    }
+
+    @Test
+    void bumpGrapeGenerationAndClose() {
+        groovy = new GroovyEngine()
+        session = new GroovyshLanguageSession(groovy)
+        session.bumpGrapeGeneration()
+        session.bumpGrapeGeneration()
+        assert session.diagnosticsFor('1') != null
+        session.close()
+        session = null
+    }
+
+    enum Nested { A }
+
+    private static final class Line implements ParsedLine {
+        private final String text
+
+        Line(String text) {
+            this.text = text
+        }
+
+        @Override
+        String word() { text }
+
+        @Override
+        int wordCursor() { text.length() }
+
+        @Override
+        int wordIndex() { 0 }
+
+        @Override
+        List<String> words() { [text] }
+
+        @Override
+        String line() { text }
+
+        @Override
+        int cursor() { text.length() }
+    }
+}