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/11 16:03:28 UTC
(groovy) branch GROOVY-12448 created (now 94a0e10208)
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a change to branch GROOVY-12448
in repository https://gitbox.apache.org/repos/asf/groovy.git
at 94a0e10208 GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
This branch includes the following new commits:
new 94a0e10208 GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(groovy) 01/01: GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch GROOVY-12448
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 94a0e1020825c97dca69988612b554bb7c7b3ff7
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sun Oct 11 23:31:52 2026 +0900
GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
---
THREAT_MODEL.md | 3 +-
licenses/LICENSE-BINZIP | 2 +-
licenses/LICENSE-SDK | 2 +-
licenses/lsp4j-BINZIP.txt | 2 +-
notices/NOTICE-BINZIP | 2 +-
notices/NOTICE-SDK | 2 +-
notices/lsp4j-BINZIP.txt | 2 +-
subprojects/groovy-console/build.gradle | 1 +
.../main/groovy/groovy/console/ui/Console.groovy | 20 ++
.../console/ui/language/ConsoleLanguageHost.groovy | 385 +++++++++++++++++++++
.../groovy/groovy/console/ui/language/package.html | 27 ++
.../src/spec/doc/groovy-console.adoc | 5 +
.../ui/language/ConsoleLanguageHostTest.groovy | 235 +++++++++++++
subprojects/groovy-groovysh/ARCHITECTURE.md | 13 +
subprojects/groovy-groovysh/build.gradle | 1 +
.../groovy/org/apache/groovy/groovysh/Main.groovy | 20 ++
.../language/GroovyshLanguageSession.groovy | 308 +++++++++++++++++
.../groovy/groovysh/language/package-info.groovy | 25 ++
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 7 +
.../language/GroovyshLanguageSessionTest.groovy | 302 ++++++++++++++++
subprojects/groovy-lsp/AGENTS.md | 4 +-
subprojects/groovy-lsp/ARCHITECTURE.md | 9 +-
.../groovy/lsp/internal/LanguageServerContext.java | 20 +-
.../lsp/internal/engine/GroovyLanguageEngine.java | 158 +++++++++
.../groovy-lsp/src/spec/doc/lsp-userguide.adoc | 7 +-
.../engine/GroovyLanguageEngineBufferTest.groovy | 30 ++
.../internal/feature/LanguageQualityTest.groovy | 5 +
27 files changed, 1579 insertions(+), 18 deletions(-)
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index 0f26ad77f1..6f3878447e 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -636,7 +636,8 @@ If you embed or script with Groovy, **you** own these:
11. **Ship only the modules you use.** `org.apache.groovy:groovy` is the
core runtime and compiler. `groovy-all` is an aggregate that also brings
in the interactive and developer tooling — `groovy-groovysh`,
- `groovy-console`, `groovy-swing`, `groovy-servlet`, `groovy-jmx`,
+ `groovy-console` (and their `groovy-lsp` runtime dependency),
+ `groovy-swing`, `groovy-servlet`, `groovy-jmx`,
`groovy-groovydoc`, `groovy-docgenerator` — and both Grape engines;
a server-side application rarely needs any of them. This is not only
about what an attacker can reach. A module changes behavior by being
diff --git a/licenses/LICENSE-BINZIP b/licenses/LICENSE-BINZIP
index f23e78c49c..1d54648ec6 100644
--- a/licenses/LICENSE-BINZIP
+++ b/licenses/LICENSE-BINZIP
@@ -308,7 +308,7 @@ Eclipse LSP4J License
This product bundles Eclipse LSP4J (`org.eclipse.lsp4j:org.eclipse.lsp4j`)
and Eclipse LSP4J JSON-RPC (`org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc`)
-as binary dependencies of the optional `groovy-lsp` module.
+as binary dependencies of `groovy-lsp`, which Groovy Console and groovysh require.
Those artifacts are licensed under the Eclipse Public License 2.0.
See licenses/lsp4j-license.txt for the full license text.
diff --git a/licenses/LICENSE-SDK b/licenses/LICENSE-SDK
index c7cf037cb3..fd3ad899bd 100644
--- a/licenses/LICENSE-SDK
+++ b/licenses/LICENSE-SDK
@@ -308,7 +308,7 @@ Eclipse LSP4J License
This product bundles Eclipse LSP4J (`org.eclipse.lsp4j:org.eclipse.lsp4j`)
and Eclipse LSP4J JSON-RPC (`org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc`)
-as binary dependencies of the optional `groovy-lsp` module.
+as binary dependencies of `groovy-lsp`, which Groovy Console and groovysh require.
Those artifacts are licensed under the Eclipse Public License 2.0.
See licenses/lsp4j-license.txt for the full license text.
diff --git a/licenses/lsp4j-BINZIP.txt b/licenses/lsp4j-BINZIP.txt
index 56cc889a94..7c328d9eb0 100644
--- a/licenses/lsp4j-BINZIP.txt
+++ b/licenses/lsp4j-BINZIP.txt
@@ -2,7 +2,7 @@ Eclipse LSP4J License
This product bundles Eclipse LSP4J (`org.eclipse.lsp4j:org.eclipse.lsp4j`)
and Eclipse LSP4J JSON-RPC (`org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc`)
-as binary dependencies of the optional `groovy-lsp` module.
+as binary dependencies of `groovy-lsp`, which Groovy Console and groovysh require.
Those artifacts are licensed under the Eclipse Public License 2.0.
See licenses/lsp4j-license.txt for the full license text.
diff --git a/notices/NOTICE-BINZIP b/notices/NOTICE-BINZIP
index 1b037b97e6..2e4caab6b4 100644
--- a/notices/NOTICE-BINZIP
+++ b/notices/NOTICE-BINZIP
@@ -43,7 +43,7 @@ which are available under the terms of the Eclipse Public License v2.0
Eclipse LSP4J
This product bundles Eclipse LSP4J and Eclipse LSP4J JSON-RPC
-(org.eclipse.lsp4j) as binary dependencies of the optional groovy-lsp module.
+(org.eclipse.lsp4j) as binary dependencies of groovy-lsp, which Groovy Console and groovysh require.
Those artifacts are licensed under the Eclipse Public License 2.0.
See licenses/lsp4j-license.txt.
diff --git a/notices/NOTICE-SDK b/notices/NOTICE-SDK
index fce2cdf6ec..e7ad63ccc5 100644
--- a/notices/NOTICE-SDK
+++ b/notices/NOTICE-SDK
@@ -43,7 +43,7 @@ which are available under the terms of the Eclipse Public License v2.0
Eclipse LSP4J
This product bundles Eclipse LSP4J and Eclipse LSP4J JSON-RPC
-(org.eclipse.lsp4j) as binary dependencies of the optional groovy-lsp module.
+(org.eclipse.lsp4j) as binary dependencies of groovy-lsp, which Groovy Console and groovysh require.
Those artifacts are licensed under the Eclipse Public License 2.0.
See licenses/lsp4j-license.txt.
diff --git a/notices/lsp4j-BINZIP.txt b/notices/lsp4j-BINZIP.txt
index ebe16b2a60..83d0f9f909 100644
--- a/notices/lsp4j-BINZIP.txt
+++ b/notices/lsp4j-BINZIP.txt
@@ -1,6 +1,6 @@
Eclipse LSP4J
This product bundles Eclipse LSP4J and Eclipse LSP4J JSON-RPC
-(org.eclipse.lsp4j) as binary dependencies of the optional groovy-lsp module.
+(org.eclipse.lsp4j) as binary dependencies of groovy-lsp, which Groovy Console and groovysh require.
Those artifacts are licensed under the Eclipse Public License 2.0.
See licenses/lsp4j-license.txt.
diff --git a/subprojects/groovy-console/build.gradle b/subprojects/groovy-console/build.gradle
index 695c3f2e3c..83f62a5e39 100644
--- a/subprojects/groovy-console/build.gradle
+++ b/subprojects/groovy-console/build.gradle
@@ -32,6 +32,7 @@ dependencies {
runtimeOnly "org.abego.treelayout:org.abego.treelayout.core:${versions.treelayout}"
testImplementation projects.groovyTest
testImplementation testFixtures(projects.groovySwing)
+ implementation 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..0b5b7780d1 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
@@ -25,6 +25,7 @@ import com.github.javaparser.ast.CompilationUnit
import com.github.javaparser.ast.body.TypeDeclaration
import groovy.cli.internal.CliBuilderInternal
import groovy.cli.internal.OptionAccessor
+import groovy.console.ui.language.ConsoleLanguageHost
import groovy.console.ui.text.FindReplaceUtility
import groovy.console.ui.text.GroovyFilter
import groovy.console.ui.text.SmartDocumentFilter
@@ -294,6 +295,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
CompilerConfiguration config
/** Shell used to compile and run scripts. */
GroovyShell shell
+ /** In-process language host for diagnostics and completions. */
+ private ConsoleLanguageHost languageHost
/** Counter used for generated script names. */
int scriptNameCounter = 0
/** Interceptor that captures stdout. */
@@ -507,6 +510,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 +583,19 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
}
installInterceptor()
updateTitle()
+ installLanguageHost()
swing.doLater inputArea.&requestFocus
}
+ /**
+ * Attaches the in-process language host to the editor. groovy-console
+ * depends on groovy-lsp, so the host is always constructed.
+ */
+ @CompileStatic
+ private void installLanguageHost() {
+ languageHost = ConsoleLanguageHost.attach(inputArea, shell.classLoader)
+ }
+
/**
* Make the console frames capable of native fullscreen
* for Mac OS X Lion and beyond.
@@ -1067,6 +1081,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 +1130,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 +1162,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 +1731,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
fc.selectedFiles?.each { file ->
shell.getClassLoader().addURL(file.toURL())
}
+ languageHost?.setParentLoader(shell.classLoader)
}
}
@@ -1725,6 +1744,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..2046c96007
--- /dev/null
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy
@@ -0,0 +1,385 @@
+/*
+ * 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 groovy.transform.CompileStatic
+import groovy.transform.Internal
+import groovy.transform.PackageScope
+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.RejectedExecutionException
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * In-process language host for Groovy Console. Compiles the unsaved editor
+ * buffer through {@link GroovyLanguageEngine} (no class generation) and
+ * paints diagnostic highlights; Ctrl+Space offers completions.
+ * Implementation detail of Groovy Console, not an embedding API.
+ */
+@CompileStatic
+@Internal
+final class ConsoleLanguageHost implements AutoCloseable {
+
+ /** This editor's unsaved buffer. The engine does not assign the name. */
+ @PackageScope
+ 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 DocumentListener documentListener
+ private final KeyAdapter completionKeys
+ 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 volatile boolean closed
+ private volatile ClassLoader parent
+ private ScheduledFuture<?> pending
+
+ private ConsoleLanguageHost(JTextPane input, ClassLoader loader) {
+ this.input = input
+ this.parent = loader
+ this.engine = new GroovyLanguageEngine()
+ engine.setParentLoader(loader)
+ engine.openBuffer(BUFFER_URI, 'groovy', textOf(input))
+ this.documentListener = new DocumentListener() {
+ @Override
+ void insertUpdate(DocumentEvent e) { schedule() }
+
+ @Override
+ void removeUpdate(DocumentEvent e) { schedule() }
+
+ @Override
+ void changedUpdate(DocumentEvent e) { }
+ }
+ this.completionKeys = new KeyAdapter() {
+ @Override
+ void keyPressed(KeyEvent e) {
+ if (e.keyCode == KeyEvent.VK_SPACE && (e.modifiersEx & InputEvent.CTRL_DOWN_MASK) != 0) {
+ showCompletions()
+ e.consume()
+ }
+ }
+ }
+ if (input?.document != null) {
+ input.document.addDocumentListener(documentListener)
+ input.addKeyListener(completionKeys)
+ }
+ }
+
+ /**
+ * @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
+ */
+ @PackageScope
+ List<GroovyLanguageEngine.Hit> compileAndDiagnostics(String text) {
+ engine.setParentLoader(parent)
+ engine.updateBuffer(BUFFER_URI, text)
+ 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
+ */
+ @PackageScope
+ List<GroovyLanguageEngine.Candidate> complete(String text, int line, int character) {
+ engine.setParentLoader(parent)
+ engine.updateBuffer(BUFFER_URI, text)
+ 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
+ */
+ @PackageScope
+ String describe(String text, int line, int character) {
+ engine.setParentLoader(parent)
+ engine.updateBuffer(BUFFER_URI, text)
+ engine.describe(BUFFER_URI, line, character)
+ }
+
+ /**
+ * Compiles the current editor text and paints diagnostic highlights
+ * on the calling thread. Production callers run this on the EDT
+ * after a file load; typing uses {@link #schedule()} instead.
+ */
+ void refresh() {
+ if (closed) {
+ return
+ }
+ generation.incrementAndGet()
+ pending?.cancel(false)
+ String text = textOf(input)
+ paint(text, compileAndDiagnostics(text))
+ }
+
+ /**
+ * Offers completions at the caret. Used by Ctrl+Space and tests.
+ */
+ @PackageScope
+ void completeAtCaret() {
+ showCompletions()
+ }
+
+ /**
+ * Records the GroovyShell class loader after classpath or shell changes
+ * and schedules a background recompile so highlights catch up without a
+ * keystroke.
+ *
+ * @param loader GroovyShell class loader
+ */
+ void setParentLoader(ClassLoader loader) {
+ if (closed) {
+ return
+ }
+ this.parent = loader
+ engine.setParentLoader(loader)
+ schedule()
+ }
+
+ @Override
+ void close() {
+ if (closed) {
+ return
+ }
+ closed = true
+ generation.incrementAndGet()
+ pending?.cancel(false)
+ if (input != null) {
+ if (input.document != null) {
+ input.document.removeDocumentListener(documentListener)
+ }
+ input.removeKeyListener(completionKeys)
+ }
+ debounce.shutdownNow()
+ try {
+ debounce.awaitTermination(1, TimeUnit.SECONDS)
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt()
+ }
+ engine.close()
+ }
+
+ private void schedule() {
+ if (closed || debounce.isShutdown()) {
+ return
+ }
+ int gen = generation.incrementAndGet()
+ String text = textOf(input)
+ pending?.cancel(false)
+ try {
+ pending = debounce.schedule({
+ try {
+ if (closed || gen != generation.get()) {
+ return
+ }
+ def hits = compileAndDiagnostics(text)
+ if (closed || gen != generation.get()) {
+ return
+ }
+ SwingUtilities.invokeLater {
+ if (!closed && gen == generation.get()) {
+ paint(text, hits)
+ }
+ }
+ } catch (RuntimeException ignored) {
+ }
+ }, DEBOUNCE_MS, TimeUnit.MILLISECONDS)
+ } catch (RejectedExecutionException ignored) {
+ }
+ }
+
+ 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[] span = highlightSpan(text, hit.startLine(), hit.startCharacter(),
+ hit.endLine(), hit.endCharacter())
+ try {
+ highlights << highlighter.addHighlight(span[0], span[1], errorPainter)
+ } catch (BadLocationException ignored) {
+ }
+ }
+ if (hits) {
+ input.toolTipText = hits.take(3).collect { it.message() }.join('\n')
+ } else {
+ input.toolTipText = null
+ }
+ }
+
+ private void showCompletions() {
+ if (closed || input == null || debounce.isShutdown()) {
+ return
+ }
+ String text = textOf(input)
+ int caret = Math.min(Math.max(input.caretPosition, 0), text.length())
+ int[] position = GroovyLanguageEngine.positionOf(text, caret)
+ int gen = generation.get()
+ pending?.cancel(false)
+ try {
+ pending = debounce.schedule({
+ try {
+ if (closed || gen != generation.get()) {
+ return
+ }
+ def items = complete(text, position[0], position[1])
+ SwingUtilities.invokeLater {
+ if (!closed && gen == generation.get()) {
+ showMenu(items)
+ }
+ }
+ } catch (RuntimeException ignored) {
+ }
+ }, 0, TimeUnit.MILLISECONDS)
+ } catch (RejectedExecutionException ignored) {
+ }
+ }
+
+ 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, (int) point.x, (int) point.y + 16)
+ } catch (RuntimeException ignored) {
+ }
+ }
+
+ @PackageScope
+ static void insertAtCaret(JTextPane pane, String insert) {
+ if (pane?.document == null) {
+ return
+ }
+ String text = textOf(pane)
+ String piece = insert ?: ''
+ if (piece.isEmpty()) {
+ return
+ }
+ int caret = Math.min(Math.max(pane.caretPosition, 0), text.length())
+ int start = caret
+ while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - 1))) {
+ start--
+ }
+ int end = caret
+ while (end < text.length() && Character.isJavaIdentifierPart(text.charAt(end))) {
+ end++
+ }
+ String prefix = text.substring(start, caret)
+ try {
+ if (prefix && piece.startsWith(prefix)) {
+ if (end > caret) {
+ pane.document.remove(caret, end - caret)
+ }
+ pane.document.insertString(caret, piece.substring(prefix.length()), null)
+ } else if (start < end) {
+ pane.document.remove(start, end - start)
+ pane.document.insertString(start, piece, null)
+ } else {
+ pane.document.insertString(caret, piece, null)
+ }
+ } catch (BadLocationException ignored) {
+ }
+ }
+
+ @PackageScope
+ static String plainInsert(String insert) {
+ if (insert == null || insert.isEmpty()) {
+ return ''
+ }
+ insert.replaceAll(/\$\{\d+:([^}]*)\}/, '$1').replaceAll(/\$\d+/, '')
+ }
+
+ private static String textOf(JTextPane pane) {
+ pane == null ? '' : (pane.text ?: '')
+ }
+
+ /**
+ * Maps a 0-based UTF-16 diagnostic range onto document offsets.
+ * A zero-width or inverted range becomes one character so the
+ * highlighter has something to paint.
+ */
+ @PackageScope
+ static int[] highlightSpan(String text, int startLine, int startCharacter, int endLine, int endCharacter) {
+ int start = GroovyLanguageEngine.offsetOf(text, startLine, startCharacter)
+ int end = GroovyLanguageEngine.offsetOf(text, endLine, endCharacter)
+ if (end <= start) {
+ end = Math.min(start + 1, text == null ? 0 : text.length())
+ }
+ [start, end] as int[]
+ }
+}
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..f4d203f7bf
--- /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>In-process groovy-lsp host for the Swing console. Implementation detail of Groovy Console; not an embedding API.</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..5bee55011f 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -167,6 +167,11 @@ 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
+* The editor compiles the unsaved buffer in the background and highlights
+compiler diagnostics. `Ctrl+Space` offers compiler-backed completions.
+Adding jars or directories to the classpath is picked up on the next
+background 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..3cd29f85ba
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy
@@ -0,0 +1,235 @@
+/*
+ * 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 org.junit.jupiter.api.Test
+
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+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.any { it.label() == 'ping' || it.insertText()?.contains('ping') }
+ host.setParentLoader(ConsoleLanguageHostTest.classLoader)
+ host.refresh()
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void offsetOfPositionOfAndPlainInsertCoverEdges() {
+ assert GroovyLanguageEngine.offsetOf(null, 0, 0) == 0
+ assert GroovyLanguageEngine.offsetOf('', 0, 0) == 0
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 0, 0) == 0
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 1, 1) == 4
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 9, 0) == 5
+ assert GroovyLanguageEngine.offsetOf('ab', -1, 99) == 2
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 1, -3) == 3
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 0, 99) == 5
+ assert GroovyLanguageEngine.positionOf(null, 0) == [0, 0] as int[]
+ assert GroovyLanguageEngine.positionOf('', 1) == [0, 0] as int[]
+ assert GroovyLanguageEngine.positionOf('ab\ncd', 0) == [0, 0] as int[]
+ assert GroovyLanguageEngine.positionOf('ab\ncd', 4) == [1, 1] as int[]
+ assert GroovyLanguageEngine.positionOf('ab\ncd', 99) == [1, 2] as int[]
+ 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.plainInsert('$0') == ''
+ assert ConsoleLanguageHost.plainInsert('${1:foo}$2') == 'foo'
+ assert ConsoleLanguageHost.BUFFER_URI.toString() == 'groovy-buffer:console'
+ }
+
+ @Test
+ void highlightSpanClampsInvertedAndZeroWidth() {
+ assert ConsoleLanguageHost.highlightSpan('ab\ncd', 0, 2, 0, 0) == [2, 3] as int[]
+ assert ConsoleLanguageHost.highlightSpan('x', 0, 0, 0, 0) == [0, 1] as int[]
+ assert ConsoleLanguageHost.highlightSpan('', 0, 0, 0, 0) == [0, 0] as int[]
+ assert ConsoleLanguageHost.highlightSpan(null, 0, 0, 0, 0) == [0, 0] as int[]
+ assert ConsoleLanguageHost.highlightSpan('ab', 0, 0, 0, 2) == [0, 2] as int[]
+ }
+
+ @Test
+ void refreshPaintsAndClearsTooltipsSynchronously() {
+ 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
+ assert pane.toolTipText
+ 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'
+ host.refresh()
+ assert pane.highlighter.highlights.length == 0
+ assert pane.toolTipText == null
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void closeThenEditDoesNotThrowAndCloseIsIdempotent() {
+ def pane = new JTextPane()
+ pane.text = 'class Broken { def x = }\n'
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ pane.text = 'class Ok {}\n'
+ host.completeAtCaret()
+ } finally {
+ host.close()
+ }
+ host.close()
+ pane.text = 'class Later {}\n'
+ pane.dispatchEvent(new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+ InputEvent.CTRL_DOWN_MASK, KeyEvent.VK_SPACE, ' ' as char))
+ def detached = ConsoleLanguageHost.attach(null, null)
+ try {
+ detached.refresh()
+ detached.completeAtCaret()
+ detached.setParentLoader(null)
+ assert detached.compileAndDiagnostics(null) != null
+ } finally {
+ detached.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()
+ }
+ }
+
+ @Test
+ void completeAtCaretFlushesMenuPath() {
+ def pane = new JTextPane()
+ pane.text = '''\
+ class Sample {
+ def ping() { 1 }
+ }
+ new Sample().
+ '''.stripIndent()
+ pane.caretPosition = pane.text.length()
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ host.completeAtCaret()
+ long deadline = System.currentTimeMillis() + 500
+ while (System.currentTimeMillis() < deadline) {
+ SwingUtilities.invokeAndWait {}
+ Thread.sleep(25)
+ }
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void compilePicksUpInPlaceLoaderUrlChanges() {
+ def loader = new GroovyClassLoader()
+ def host = ConsoleLanguageHost.attach(new JTextPane(), loader)
+ try {
+ host.compileAndDiagnostics('1')
+ loader.addURL(URI.create('file:/tmp/extra-language-host.jar').toURL())
+ assert host.compileAndDiagnostics('1') != null
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void setParentLoaderThenRefreshSeesNewClasses() {
+ def loader = new GroovyClassLoader()
+ loader.parseClass('class CpHit {}')
+ def host = ConsoleLanguageHost.attach(new JTextPane(), ConsoleLanguageHostTest.classLoader)
+ try {
+ def before = host.compileAndDiagnostics('CpHit x\n')
+ host.setParentLoader(loader)
+ def after = host.compileAndDiagnostics('CpHit x\n')
+ assert before.any { (it.message() ?: '').contains('CpHit') }
+ assert !after.any { (it.message() ?: '').contains('CpHit') }
+ } finally {
+ host.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 == 'name'
+ pane.text = ''
+ pane.caretPosition = 0
+ ConsoleLanguageHost.insertAtCaret(pane, 'x')
+ assert pane.text == 'x'
+ ConsoleLanguageHost.insertAtCaret(null, 'x')
+ ConsoleLanguageHost.insertAtCaret(pane, '')
+ pane.text = 'foo.'
+ pane.caretPosition = 4
+ ConsoleLanguageHost.insertAtCaret(pane, 'bar')
+ assert pane.text == 'foo.bar'
+ pane.text = 'name'
+ pane.caretPosition = 1
+ ConsoleLanguageHost.insertAtCaret(pane, 'name')
+ assert pane.text == 'name'
+ 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..521023bd52 100644
--- a/subprojects/groovy-groovysh/ARCHITECTURE.md
+++ b/subprojects/groovy-groovysh/ARCHITECTURE.md
@@ -238,6 +238,19 @@ 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.
+- `groovy-lsp`: groovysh takes an `implementation` dependency.
+ `GroovyshLanguageSession` is an `@Internal` adapter constructed
+ from `Main`. It 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. Result records are JDK types; the adapter does not
+ import LSP4J. `SYNTAX` tail-tips return compiler diagnostics and
+ do not evaluate; `METHOD` signatures use the full line so an
+ unclosed `(` is visible. `/grab` is noticed because the engine
+ compile fingerprint includes the parent loader's URLs. Binding
+ names already in the REPL buffer come from the compiled AST, not
+ a host regex. 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..64263cf227 100644
--- a/subprojects/groovy-groovysh/build.gradle
+++ b/subprojects/groovy-groovysh/build.gradle
@@ -33,6 +33,7 @@ dependencies {
implementation projects.groovyXml
implementation projects.groovyJson
implementation projects.groovyNio
+ implementation 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..3fb037f158 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
@@ -27,6 +27,7 @@ import org.apache.groovy.groovysh.jline.GroovyConsoleEngine
import org.apache.groovy.groovysh.jline.GroovyEngine
import org.apache.groovy.groovysh.jline.GroovyPrinter
import org.apache.groovy.groovysh.jline.GroovySystemRegistry
+import org.apache.groovy.groovysh.language.GroovyshLanguageSession
import org.apache.groovy.groovysh.util.DocFinder
import org.apache.groovy.lang.annotation.Incubating
import org.codehaus.groovy.tools.shell.util.MessageSource
@@ -267,6 +268,7 @@ class Main {
return 0
}
String evaluate = options.e ?: null
+ GroovyshLanguageSession 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,21 @@ class Main {
start(GroovyshOptions.builder().build(), args)
}
+ /**
+ * Attaches the in-process language session. groovysh depends on
+ * groovy-lsp, so the session is always constructed.
+ *
+ * @param scriptEngine REPL engine
+ * @param systemRegistry command registry
+ * @return the session
+ */
+ private static GroovyshLanguageSession installLanguageSession(GroovyEngine scriptEngine, GroovySystemRegistry systemRegistry) {
+ GroovyshLanguageSession session = new GroovyshLanguageSession(scriptEngine)
+ systemRegistry.addCompleter(session)
+ systemRegistry.setScriptDescription(session::scriptDescription)
+ return session
+ }
+
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..3926e9a3ac
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSession.groovy
@@ -0,0 +1,308 @@
+/*
+ * 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 groovy.transform.CompileStatic
+import groovy.transform.Internal
+import groovy.transform.PackageScope
+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
+
+/**
+ * Compiler-backed session for groovysh. 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.
+ * Implementation detail of groovysh, not an embedding API.
+ */
+@CompileStatic
+@Internal
+final class GroovyshLanguageSession implements AutoCloseable, Completer {
+
+ /** This session's unsaved buffer. The engine does not assign the name. */
+ @PackageScope
+ 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 String namesPreamble
+ private Set<String> namesCache
+
+ GroovyshLanguageSession(GroovyEngine groovy) {
+ this.groovy = groovy
+ this.engine = new GroovyLanguageEngine()
+ engine.setParentLoader(groovy.classLoader)
+ engine.openBuffer(BUFFER_URI, 'groovy', '')
+ }
+
+ /**
+ * Compiles the session buffer plus {@code line} and returns diagnostics.
+ *
+ * @param line current input
+ * @return hits
+ */
+ @PackageScope
+ List<GroovyLanguageEngine.Hit> diagnosticsFor(String line) {
+ engine.setParentLoader(groovy.classLoader)
+ engine.updateBuffer(BUFFER_URI, compose(line ?: ''))
+ engine.diagnostics(BUFFER_URI)
+ }
+
+ /**
+ * Completions for the current line at end-of-buffer.
+ *
+ * @param line current input
+ * @return candidates
+ */
+ @PackageScope
+ List<GroovyLanguageEngine.Candidate> completeLine(String line) {
+ completeLine(line, -1)
+ }
+
+ /**
+ * Completions for {@code line} at {@code cursor} (0-based index into
+ * {@code line}). A negative cursor means end of the composed script.
+ *
+ * @param line current input
+ * @param cursor offset in {@code line}
+ * @return candidates
+ */
+ @PackageScope
+ List<GroovyLanguageEngine.Candidate> completeLine(String line, int cursor) {
+ engine.setParentLoader(groovy.classLoader)
+ String current = line ?: ''
+ String script = compose(current)
+ engine.updateBuffer(BUFFER_URI, script)
+ int[] caret
+ if (cursor < 0 || cursor > current.length()) {
+ caret = GroovyLanguageEngine.positionOf(script, script.length())
+ } else {
+ int currentStart = script.length() - current.length()
+ caret = GroovyLanguageEngine.positionOf(script, currentStart + cursor)
+ }
+ engine.complete(BUFFER_URI, caret[0], caret[1])
+ }
+
+ /**
+ * Tail-tip description. {@code SYNTAX} reports compiler diagnostics and
+ * does not evaluate. {@code METHOD} asks for signatures on the full line
+ * (so an unclosed {@code (} is visible) and falls back to Inspector only
+ * when the compiler has none.
+ *
+ * @param line JLine command line
+ * @return description, or {@code null}
+ */
+ CmdDesc scriptDescription(CmdLine line) {
+ if (line != null && line.descriptionType == CmdLine.DescriptionType.SYNTAX) {
+ try {
+ def hits = diagnosticsFor(line.line ?: '')
+ if (hits) {
+ def desc = new CmdDesc()
+ desc.setMainDesc(hits.take(5).collect { new AttributedString(it.message() ?: '') })
+ return desc
+ }
+ } catch (Exception ignored) {
+ }
+ return null
+ }
+ try {
+ if (line != null && line.descriptionType == CmdLine.DescriptionType.METHOD) {
+ def set = signaturesFor(line.line ?: '')
+ if (set.signatures()) {
+ def desc = new CmdDesc()
+ desc.setMainDesc(set.signatures().collect { new AttributedString(it.label() ?: '') })
+ return desc
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ try {
+ return groovy.scriptDescription(line)
+ } catch (Exception ignored) {
+ return null
+ }
+ }
+
+ @PackageScope
+ GroovyLanguageEngine.SignatureSet signaturesFor(String line) {
+ engine.setParentLoader(groovy.classLoader)
+ String script = compose(line ?: '')
+ engine.updateBuffer(BUFFER_URI, script)
+ int[] caret = GroovyLanguageEngine.positionOf(script, script.length())
+ engine.signatures(BUFFER_URI, caret[0], caret[1])
+ }
+
+ @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, line.cursor())
+ mapEngineCandidates(items, line, candidates)
+ } 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
+ */
+ @PackageScope
+ String compose(String current) {
+ String preamble = groovy.buffer ?: ''
+ String decls = bindingDeclarations(namesIn(preamble))
+ def parts = []
+ if (preamble) {
+ parts << preamble
+ }
+ if (decls) {
+ parts << decls
+ }
+ parts << (current ?: '')
+ parts.join('\n')
+ }
+
+ private Set<String> namesIn(String preamble) {
+ if (!preamble) {
+ return [] as Set
+ }
+ if (preamble == namesPreamble) {
+ return namesCache
+ }
+ engine.setParentLoader(groovy.classLoader)
+ engine.updateBuffer(BUFFER_URI, preamble)
+ namesCache = engine.declaredNames(BUFFER_URI)
+ namesPreamble = preamble
+ namesCache
+ }
+
+ private String bindingDeclarations(Set<String> already) {
+ Map variables
+ try {
+ def found = groovy.find(null)
+ if (!(found instanceof Map)) {
+ return ''
+ }
+ variables = new LinkedHashMap(found)
+ } catch (Exception ignored) {
+ return ''
+ }
+ 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')
+ }
+
+ @PackageScope
+ 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'
+ }
+ if (!Modifier.isPublic(type.modifiers)) {
+ return 'java.lang.Object'
+ }
+ String canonical = type.canonicalName
+ canonical != null ? canonical : 'java.lang.Object'
+ }
+
+ @PackageScope
+ 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)
+ }
+
+ /**
+ * Maps engine candidates onto JLine {@link Candidate} values using the
+ * current word as the replacement prefix.
+ */
+ @PackageScope
+ static void mapEngineCandidates(List<GroovyLanguageEngine.Candidate> items, ParsedLine line,
+ List<Candidate> candidates) {
+ if (items == null || line == null || candidates == null) {
+ return
+ }
+ 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) : ''
+ for (GroovyLanguageEngine.Candidate item : items) {
+ if (item == null) {
+ continue
+ }
+ String insert = plainInsert(item.insertText() ?: item.label())
+ if (insert.isEmpty()) {
+ insert = item.label() ?: ''
+ }
+ if (insert.isEmpty()) {
+ continue
+ }
+ String value = insert.startsWith(curBuf) ? insert : curBuf + insert
+ String kind = item.kind()
+ if (kind == null || kind.isEmpty()) {
+ kind = 'other'
+ }
+ candidates.add(new Candidate(value, item.label() ?: insert, kind,
+ item.detail() ?: null, null, null, false))
+ }
+ }
+}
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..984629ccc6
--- /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.
+ */
+
+/**
+ * Compiler-backed groovysh session. The adapter stays in this tree and talks
+ * to groovy-lsp through {@code GroovyLanguageEngine} JDK records, not LSP4J
+ * types. Implementation detail of groovysh, not an embedding API.
+ */
+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..ea77f69418 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.
+* 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 next
+completion or tail-tip notices the loader change. Syntax tail-tips report
+compiler diagnostics and do not evaluate the line. 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..8893526ed9
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy
@@ -0,0 +1,302 @@
+/*
+ * 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.jline.utils.AttributedString
+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('GROOVY_OPTIONS', [:])
+ groovy.put('CONSOLE_OPTIONS', [:])
+ groovy.put('ROOT', '/')
+ groovy.put('NANORC', 'x')
+ groovy.put('_args', [])
+ groovy.put('gone', null)
+ 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('GROOVY_OPTIONS')
+ assert !script.contains('CONSOLE_OPTIONS')
+ assert !script.contains('ROOT')
+ assert !script.contains('NANORC')
+ assert !script.contains('_args')
+ assert !script.contains('gone')
+ 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
+ assert session.completeLine('new Box().', 9) != null
+ assert session.completeLine('new Box().', -1) != null
+ def candidates = []
+ session.complete(null, new Line('Box().'), candidates)
+ 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) == ''
+ assert GroovyshLanguageSession.plainInsert('') == ''
+ assert GroovyshLanguageSession.plainInsert('$0') == ''
+ assert GroovyshLanguageSession.plainInsert('${1:foo}$2bar') == 'foobar'
+ assert GroovyshLanguageSession.plainInsert('one\ntwo') == 'one'
+ assert GroovyshLanguageSession.BUFFER_URI.toString() == 'groovy-buffer:repl'
+ }
+
+ @Test
+ void mapEngineCandidatesPrefixesAndSkipsEmptyInsert() {
+ def out = []
+ def items = [
+ new GroovyLanguageEngine.Candidate('name', 'field', 'String', 'name', ''),
+ new GroovyLanguageEngine.Candidate('', 'method', '', '', ''),
+ new GroovyLanguageEngine.Candidate('ping', 'method', '', '${1:ping}', ''),
+ ]
+ GroovyshLanguageSession.mapEngineCandidates(items, new Line('Box().'), out)
+ assert out.size() == 2
+ assert out[0].value() == 'Box().name'
+ assert out[0].displ() == 'name'
+ assert out[0].group() == 'field'
+ assert out[1].value() == 'Box().ping'
+ out.clear()
+ GroovyshLanguageSession.mapEngineCandidates(
+ [new GroovyLanguageEngine.Candidate('println', 'method', '', 'println', '')],
+ new Line('pri', 3), out)
+ assert out[0].value() == 'println'
+ out.clear()
+ GroovyshLanguageSession.mapEngineCandidates(
+ [new GroovyLanguageEngine.Candidate('name', 'field', '', 'Box().name', '')],
+ new Line('Box().'), out)
+ assert out[0].value() == 'Box().name'
+ out.clear()
+ GroovyshLanguageSession.mapEngineCandidates(
+ [new GroovyLanguageEngine.Candidate('x', null, null, 'x', '')],
+ new Line('z'), out)
+ assert out[0].group() == 'other'
+ GroovyshLanguageSession.mapEngineCandidates(null, new Line('x'), out)
+ GroovyshLanguageSession.mapEngineCandidates(items, null, out)
+ GroovyshLanguageSession.mapEngineCandidates(items, new Line('x'), null)
+ GroovyshLanguageSession.mapEngineCandidates(
+ [null, new GroovyLanguageEngine.Candidate('ok', 'method', '', 'ok', '')],
+ new Line('z'), out)
+ assert out.any { it.value() == 'ok' || it.value().endsWith('ok') }
+ }
+
+ @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 clean = new CmdLine('1', '1', '', [], CmdLine.DescriptionType.SYNTAX)
+ assert session.scriptDescription(clean) == null
+ def method = new CmdLine('println(', 'println', '', ['println'], CmdLine.DescriptionType.METHOD)
+ session.scriptDescription(method)
+ session.scriptDescription(null)
+ assert session.signaturesFor('') != null
+ def callLine = 'Box.ping('
+ def call = new CmdLine(callLine, 'Box.ping', '', ['Box'], CmdLine.DescriptionType.METHOD)
+ def withParen = session.signaturesFor(callLine)
+ def headOnly = session.signaturesFor('Box.ping')
+ session.scriptDescription(call)
+ assert withParen.signatures() != null
+ assert headOnly.signatures() != null
+ }
+
+ @Test
+ void helpersCoverEmptyCompletionsAndInspectorFallback() {
+ 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
+ def method = new CmdLine('zzz(', 'zzz', '', ['zzz'], CmdLine.DescriptionType.METHOD)
+ assert session.scriptDescription(method) == null
+ }
+
+ @Test
+ void typeLiteralMatrix() {
+ assert GroovyshLanguageSession.typeLiteral('x') == 'java.lang.String'
+ assert GroovyshLanguageSession.typeLiteral(1L) == 'java.lang.Long'
+ assert GroovyshLanguageSession.typeLiteral(new Object()) == 'java.lang.Object'
+ assert GroovyshLanguageSession.typeLiteral(new int[0]) == 'int[]'
+ assert GroovyshLanguageSession.typeLiteral(new String[0]) == 'java.lang.String[]'
+ assert GroovyshLanguageSession.typeLiteral(new Runnable() {
+ @Override
+ void run() {
+ }
+ }) == 'java.lang.Object'
+ assert GroovyshLanguageSession.typeLiteral(Nested.A) == Nested.canonicalName
+ Runnable lambda = () -> {}
+ assert GroovyshLanguageSession.typeLiteral(lambda) == 'java.lang.Object'
+ assert GroovyshLanguageSession.typeLiteral(new Hidden()) == 'java.lang.Object'
+ }
+
+ @Test
+ void bindingDeclarationsToleratesFindFailure() {
+ groovy = new GroovyEngine() {
+ @Override
+ Map<String, Object> find(String name) {
+ throw new IllegalStateException('boom')
+ }
+ }
+ session = new GroovyshLanguageSession(groovy)
+ assert session.compose('1').trim() == '1'
+ session.close()
+ groovy = new GroovyEngine() {
+ @Override
+ Map<String, Object> find(String name) {
+ null
+ }
+ }
+ session = new GroovyshLanguageSession(groovy)
+ assert session.compose('1').trim() == '1'
+ }
+
+ @Test
+ void completeSwallowsEngineFailureAfterClose() {
+ groovy = new GroovyEngine()
+ session = new GroovyshLanguageSession(groovy)
+ session.close()
+ def candidates = []
+ session.complete(null, new Line('x'), candidates)
+ assert candidates.isEmpty()
+ session = null
+ }
+
+ @Test
+ void closeAfterDiagnostics() {
+ groovy = new GroovyEngine()
+ session = new GroovyshLanguageSession(groovy)
+ assert session.diagnosticsFor('1') instanceof List
+ session.close()
+ session = null
+ }
+
+ @Test
+ void scriptDescriptionFallsBackWhenMethodHasNoSignatures() {
+ groovy = new GroovyEngine() {
+ @Override
+ CmdDesc scriptDescription(CmdLine line) {
+ def d = new CmdDesc()
+ d.setMainDesc([new AttributedString('fallback')])
+ return d
+ }
+ }
+ session = new GroovyshLanguageSession(groovy)
+ def method = new CmdLine('zzz(', 'zzz', '', ['zzz'], CmdLine.DescriptionType.METHOD)
+ def desc = session.scriptDescription(method)
+ assert desc?.mainDesc[0].toString().contains('fallback')
+ }
+
+ enum Nested { A }
+
+ private static final class Hidden {}
+
+ private static final class Line implements ParsedLine {
+ private final String text
+ private final int cursor
+
+ Line(String text) {
+ this(text, text.length())
+ }
+
+ Line(String text, int cursor) {
+ this.text = text
+ this.cursor = cursor
+ }
+
+ @Override
+ String word() { text }
+
+ @Override
+ int wordCursor() { Math.min(cursor, text.length()) }
+
+ @Override
+ int wordIndex() { 0 }
+
+ @Override
+ List<String> words() { [text] }
+
+ @Override
+ String line() { text }
+
+ @Override
+ int cursor() { cursor }
+ }
+}
diff --git a/subprojects/groovy-lsp/AGENTS.md b/subprojects/groovy-lsp/AGENTS.md
index 58ee24a0e3..1378638ecc 100644
--- a/subprojects/groovy-lsp/AGENTS.md
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -37,8 +37,8 @@ resolution lives in `MethodBinding`, shared by navigation, hover,
completion and rename. In-process callers use `GroovyLanguageEngine`
(`open(Path)` or a caller-chosen buffer URI; queries take that URI)
and JDK result records.
-The engine does not reserve buffer names. Hosts (console, groovysh)
-take a `compileOnly` dependency and load the engine with `Class.forName`.
+The engine does not reserve buffer names. Console and groovysh take an
+`implementation` dependency and construct their adapters directly.
Configuration is `workspace/didChangeConfiguration` and
`workspace/configuration` under the `groovy` section (see
`lsp-userguide.adoc`). Do not add OSGi, extra JSON-RPC methods, or a
diff --git a/subprojects/groovy-lsp/ARCHITECTURE.md b/subprojects/groovy-lsp/ARCHITECTURE.md
index 05c260fff3..9f8ddf460c 100644
--- a/subprojects/groovy-lsp/ARCHITECTURE.md
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -280,8 +280,13 @@ process working directory — that would compile the whole checkout.
JSON-RPC round-trips are not required to prove feature logic.
`GroovyLanguageEngine` tests use `openBuffer` with a caller-chosen
non-file URI (or a temp file) and never add workspace folders. Console and
-groovysh adapters live in those subprojects (`compileOnly` plus
-`Class.forName`) so groovy-all does not grow an LSP4J dependency.
+groovysh take an `implementation` dependency on groovy-lsp and construct
+their adapters directly. Result records are JDK types; those adapters do
+not import LSP4J. Feature services still use LSP4J types internally, so
+the engine jar still needs LSP4J at runtime; splitting a protocol-free
+engine module is a later cut. The compile fingerprint includes
+`URLClassLoader` URLs so in-place `addURL` (`/grab`, console classpath)
+does not need a host-side stamp.
## Related
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
index b890205486..44a7de4272 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
@@ -45,6 +45,8 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.services.LanguageClient;
import java.net.URI;
+import java.net.URL;
+import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -401,8 +403,9 @@ public final class LanguageServerContext {
/**
* Parent loader for the next compile. {@code null} uses this class's loader.
- * The compile fingerprint is cleared only when the loader identity changes,
- * so setting the same loader again does not force a recompile.
+ * The compile fingerprint is cleared when the loader identity changes.
+ * In-place {@code URLClassLoader#addURL} is visible on the next compile
+ * because the fingerprint includes that loader's URLs.
*
* @param parentLoader parent loader, or {@code null}
*/
@@ -497,8 +500,9 @@ public final class LanguageServerContext {
}
/**
- * Stable key including the parent loader identity. In-place
- * {@code addURL} on the same loader still needs {@code extra}.
+ * Stable key including the parent loader identity and, for a
+ * {@link URLClassLoader}, its URL list so in-place {@code addURL} is
+ * visible.
*
* @param open open documents
* @param extra workspace files
@@ -517,6 +521,14 @@ public final class LanguageServerContext {
.append(settings.getThroughPhase()).append('|')
.append(settings.getExtra()).append('|')
.append(System.identityHashCode(parentLoader));
+ if (parentLoader instanceof URLClassLoader urls) {
+ URL[] listed = urls.getURLs();
+ if (listed != null) {
+ for (URL url : listed) {
+ builder.append('|').append(url == null ? "" : url.toExternalForm());
+ }
+ }
+ }
if (open != null) {
for (TextDocument document : open) {
builder.append('\n').append(document.getUri()).append(':')
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java
index 37c845c896..4b8cb483cd 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java
@@ -28,6 +28,19 @@ import org.apache.groovy.lsp.internal.position.PositionEncoding;
import org.apache.groovy.lsp.internal.position.Positions;
import org.apache.groovy.lsp.internal.util.Uris;
import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.ModuleNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.syntax.Types;
import org.eclipse.lsp4j.CompletionItem;
import org.eclipse.lsp4j.CompletionList;
import org.eclipse.lsp4j.Diagnostic;
@@ -52,9 +65,11 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
/**
* In-process facade over the same compile snapshot the LSP handlers use.
@@ -254,6 +269,41 @@ public final class GroovyLanguageEngine implements AutoCloseable {
.toList();
}
+ /**
+ * Script-level names the compiler already introduced in an open buffer:
+ * non-script type simple names, script methods other than {@code run}/{@code main},
+ * and assignments in the script {@code run} body. Used by REPL hosts so
+ * binding declarations are not duplicated.
+ *
+ * @param uri buffer identity
+ * @return names, never {@code null}
+ */
+ public Set<String> declaredNames(final URI uri) {
+ TextDocument document = documentAt(uri);
+ CompiledDocument compiled = context.getSnapshot().get(document);
+ if (compiled == null || compiled.getModule() == null) {
+ return Set.of();
+ }
+ Set<String> names = new LinkedHashSet<>();
+ ModuleNode module = compiled.getModule();
+ for (ClassNode classNode : module.getClasses()) {
+ if (classNode.isScript()) {
+ for (MethodNode method : classNode.getMethods()) {
+ if (scriptLevelMethod(method)) {
+ names.add(method.getName());
+ }
+ }
+ addScriptAssignments(classNode, names);
+ } else {
+ String simple = classNode.getNameWithoutPackage();
+ if (simple != null && !simple.isEmpty()) {
+ names.add(simple);
+ }
+ }
+ }
+ return names;
+ }
+
/**
* Hover markdown for an open buffer.
*
@@ -493,6 +543,114 @@ public final class GroovyLanguageEngine implements AutoCloseable {
return Uris.normalize(uri);
}
+ private static boolean scriptLevelMethod(final MethodNode method) {
+ if (method == null || method.isSynthetic()) {
+ return false;
+ }
+ String name = method.getName();
+ if (name == null || name.isEmpty() || "<init>".equals(name) || "<clinit>".equals(name)) {
+ return false;
+ }
+ if ("run".equals(name) && method.getParameters().length == 0) {
+ return false;
+ }
+ return !"main".equals(name);
+ }
+
+ private static void addScriptAssignments(final ClassNode script, final Set<String> names) {
+ MethodNode run = script.getMethod("run", Parameter.EMPTY_ARRAY);
+ if (run == null || run.getCode() == null) {
+ return;
+ }
+ collectAssignments(run.getCode(), names);
+ }
+
+ private static void collectAssignments(final Statement statement, final Set<String> names) {
+ if (statement instanceof BlockStatement block) {
+ for (Statement nested : block.getStatements()) {
+ collectAssignments(nested, names);
+ }
+ return;
+ }
+ if (!(statement instanceof ExpressionStatement expressionStatement)) {
+ return;
+ }
+ Expression expression = expressionStatement.getExpression();
+ if (expression instanceof DeclarationExpression declaration) {
+ addLeftNames(declaration.getLeftExpression(), names);
+ return;
+ }
+ if (expression instanceof BinaryExpression binary
+ && binary.getOperation() != null
+ && binary.getOperation().getType() == Types.ASSIGN) {
+ addLeftNames(binary.getLeftExpression(), names);
+ }
+ }
+
+ private static void addLeftNames(final Expression left, final Set<String> names) {
+ if (left instanceof VariableExpression variable) {
+ String name = variable.getName();
+ if (name != null && !name.isEmpty()) {
+ names.add(name);
+ }
+ } else if (left instanceof TupleExpression tuple) {
+ for (Expression element : tuple.getExpressions()) {
+ addLeftNames(element, names);
+ }
+ }
+ }
+
+ /**
+ * Maps a UTF-16 document offset to a 0-based line and character.
+ *
+ * @param text document text
+ * @param offset UTF-16 offset
+ * @return {@code {line, character}}
+ */
+ public static int[] positionOf(final String text, final int offset) {
+ if (text == null || text.isEmpty() || offset <= 0) {
+ return new int[] {0, 0};
+ }
+ int capped = Math.min(offset, text.length());
+ int line = 0;
+ int character = 0;
+ for (int i = 0; i < capped; i++) {
+ if (text.charAt(i) == '\n') {
+ line++;
+ character = 0;
+ } else {
+ character++;
+ }
+ }
+ return new int[] {line, character};
+ }
+
+ /**
+ * Maps a 0-based line and character to a UTF-16 document offset.
+ *
+ * @param text document text
+ * @param line 0-based line
+ * @param character 0-based character
+ * @return offset in {@code text}
+ */
+ public static int offsetOf(final String text, final int line, final 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++;
+ }
+ return Math.min(pos + Math.max(character, 0), text.length());
+ }
+
private PositionEncoding encoding() {
return context.getPositionEncoding();
}
diff --git a/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc b/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
index e1bfd93c67..35a8fc0f65 100644
--- a/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
+++ b/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
@@ -480,7 +480,7 @@ Selection ranges grow from the caret through each enclosing AST node, so each re
[[lsp-in-process]]
== In-process use
-Console, groovysh, and tests can use the same snapshot without speaking JSON-RPC.
+Groovy Console and groovysh depend on this module and use the same snapshot without speaking JSON-RPC.
`GroovyLanguageEngine` is that facade.
Its results are JDK records.
They do not use LSP4J types.
@@ -532,10 +532,11 @@ It does not append the `_inferred_` line that the protocol hover adds.
`compileNow` compiles immediately.
`scheduleCompile` compiles after the delay you pass.
`setParentLoader`, `setClasspath`, and `putExtra` are the session controls.
+`setParentLoader` records loader identity and, for a `URLClassLoader`, its URL list, so an in-place `addURL` is visible on the next compile.
`putExtra` records one opaque string and, like a classpath change, forces the next compile onto a fresh loader.
-Hosts that must not put LSP4J on the Groovy classpath can depend on `groovy-lsp` as `compileOnly` and load the engine with `Class.forName`.
-Groovy Console and groovysh do that.
+Groovy Console and groovysh depend on `groovy-lsp` and construct `GroovyLanguageEngine` in-process.
+Hosts that must not put LSP4J on their own compile classpath can still depend on `groovy-lsp` as `compileOnly` and load the engine with `Class.forName`.
****
*Inferred results.*
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy
index 6b38b347b8..7de69ec932 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy
@@ -120,6 +120,36 @@ final class GroovyLanguageEngineBufferTest {
def one = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), new GroovyClassLoader())
def two = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), new GroovyClassLoader())
assert one != two
+ def loader = new GroovyClassLoader()
+ def before = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader)
+ loader.addURL(URI.create('file:/tmp/lsp-url-stamp.jar').toURL())
+ def afterUrls = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader)
+ assert before != afterUrls
+ }
+
+ @Test
+ void declaredNamesReadsScriptMembersAndTypesFromTheAst() {
+ engine = new GroovyLanguageEngine()
+ engine.openBuffer(BUFFER, 'groovy', '''\
+ class Box {}
+ def ping() { 1 }
+ int kept = 7
+ x = 1
+ def outer() {
+ int inner = 1
+ }
+ '''.stripIndent())
+ def names = engine.declaredNames(BUFFER)
+ assert names.contains('Box')
+ assert names.contains('ping')
+ assert names.contains('kept')
+ assert names.contains('x')
+ assert names.contains('outer')
+ assert !names.contains('inner')
+ assert GroovyLanguageEngine.positionOf('ab\ncd', 4) == [1, 1] as int[]
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 1, 1) == 4
+ assert GroovyLanguageEngine.positionOf(null, 1) == [0, 0] as int[]
+ assert GroovyLanguageEngine.offsetOf('', 0, 0) == 0
}
@Test
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/LanguageQualityTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/LanguageQualityTest.groovy
index 3f983df952..31b55b1dab 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/LanguageQualityTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/LanguageQualityTest.groovy
@@ -216,6 +216,11 @@ final class LanguageQualityTest {
def extra = LanguageServerContext.fingerprint([a], [],
new CompilerSettings([], [], CompilerSettings.defaults().throughPhase, false, false, [gdsl: 'x']))
assert extra != left
+ def loader = new GroovyClassLoader()
+ def before = LanguageServerContext.fingerprint([a], [], CompilerSettings.defaults(), loader)
+ loader.addURL(URI.create('file:/tmp/lsp-fingerprint.jar').toURL())
+ def after = LanguageServerContext.fingerprint([a], [], CompilerSettings.defaults(), loader)
+ assert before != after
}
@Test