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() }
+ }
+}