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/16 23:30:51 UTC
(groovy) branch GROOVY-12448 updated (cb6fa1fed9 -> 44710c0166)
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
discard cb6fa1fed9 GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
new 44710c0166 GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (cb6fa1fed9)
\
N -- N -- N refs/heads/GROOVY-12448 (44710c0166)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
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.
Summary of changes:
.../ui/language/ConsoleLanguageHostTest.groovy | 23 ++++++++++++++++++++++
.../groovy/lsp/GroovyLanguageServerTest.groovy | 11 +++++++++++
2 files changed, 34 insertions(+)
(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 44710c01660733b96d692adb7c4a4666555bbdc3
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 +-
config/checkstyle/gate-suppressions.xml | 2 +
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 | 3 +
.../main/groovy/groovy/console/ui/Console.groovy | 24 +-
.../groovy/groovy/console/ui/ObjectBrowser.groovy | 9 +-
.../groovy/groovy/console/ui/ThemeManager.groovy | 4 +-
.../console/ui/language/ConsoleLanguageHost.groovy | 476 ++++++++++++++++++
.../groovy/groovy/console/ui/language/package.html | 27 +
.../console/ui/text/MatchingHighlighter.java | 5 +-
.../console/ui/text/SmartDocumentFilter.java | 526 +++++++------------
.../src/spec/doc/groovy-console.adoc | 12 +
.../console/ui/ConsoleLanguageWiringTest.groovy | 132 +++++
.../ui/language/ConsoleLanguageHostTest.groovy | 556 +++++++++++++++++++++
.../console/ui/text/SmartDocumentFilterTest.groovy | 349 ++++++++++++-
subprojects/groovy-groovysh/ARCHITECTURE.md | 21 +
subprojects/groovy-groovysh/build.gradle | 3 +
.../groovy/org/apache/groovy/groovysh/Main.groovy | 23 +-
.../language/GroovyshLanguageSession.groovy | 357 +++++++++++++
.../groovy/groovysh/language/package-info.groovy | 27 +
.../groovy-groovysh/src/spec/doc/groovysh.adoc | 11 +
.../groovy/groovysh/GroovyshEmbeddingTest.groovy | 22 +
.../language/GroovyshLanguageSessionTest.groovy | 364 ++++++++++++++
subprojects/groovy-lsp/AGENTS.md | 12 +-
subprojects/groovy-lsp/ARCHITECTURE.md | 60 ++-
.../groovy/lsp/internal/LanguageServerContext.java | 125 ++++-
.../lsp/internal/compile/CompilerSettings.java | 23 +-
.../lsp/internal/compile/GroovyCompiler.java | 15 +-
.../groovy/lsp/internal/compile/ScriptNames.java | 143 ++++++
.../lsp/internal/engine/GroovyLanguageEngine.java | 340 +++++++++++--
.../internal/feature/SemanticTokensService.java | 82 ++-
.../groovy/lsp/internal/util/GroovyKeywords.java | 36 ++
.../lsp/internal/util/GroovySourceTokens.java | 34 +-
.../lsp/internal/workspace/TextDocument.java | 34 ++
.../groovy-lsp/src/spec/doc/lsp-userguide.adoc | 23 +-
.../groovy/lsp/GroovyLanguageServerTest.groovy | 71 +++
.../lsp/internal/compile/ScriptNamesTest.groovy | 124 +++++
.../engine/GroovyLanguageEngineBufferTest.groovy | 256 ++++++++++
.../internal/feature/LanguageQualityTest.groovy | 5 +
.../lsp/internal/feature/SemanticTokensTest.groovy | 34 ++
.../internal/feature/SupportServicesTest.groovy | 1 +
.../lsp/internal/util/GroovyKeywordsTest.groovy | 19 +
.../internal/util/GroovySourceTokensTest.groovy | 12 +
48 files changed, 3976 insertions(+), 441 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/config/checkstyle/gate-suppressions.xml b/config/checkstyle/gate-suppressions.xml
index 92b84ddcc8..55c3cb8ab8 100644
--- a/config/checkstyle/gate-suppressions.xml
+++ b/config/checkstyle/gate-suppressions.xml
@@ -92,5 +92,7 @@
<!-- the language-server AstQuery is itself named AstQuery and calls org.codehaus.groovy.ast.query.AstQuery -->
<suppress id="FullyQualifiedName" files="subprojects[/\\]groovy-lsp[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]groovy[/\\]lsp[/\\]internal[/\\]compile[/\\]AstQuery\.java$"/>
<suppress id="FullyQualifiedNameInJavadoc" files="subprojects[/\\]groovy-lsp[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]groovy[/\\]lsp[/\\]internal[/\\]compile[/\\]AstQuery\.java$"/>
+ <!-- GroovyLanguageEngine declares its own Token record, so the ANTLR token parameter stays qualified -->
+ <suppress id="FullyQualifiedName" files="subprojects[/\\]groovy-lsp[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]groovy[/\\]lsp[/\\]internal[/\\]engine[/\\]GroovyLanguageEngine\.java$"/>
</suppressions>
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..497a9a2e49 100644
--- a/subprojects/groovy-console/build.gradle
+++ b/subprojects/groovy-console/build.gradle
@@ -29,6 +29,9 @@ dependencies {
implementation "org.ow2.asm:asm-util:${versions.asm}"
implementation projects.groovyGrapeIvy
implementation projects.groovyTemplates
+ // Editor language services. implementation, not api: the engine stays
+ // internal, and LSP4J is not part of this module's published API.
+ implementation projects.groovyLsp
runtimeOnly "org.abego.treelayout:org.abego.treelayout.core:${versions.treelayout}"
testImplementation projects.groovyTest
testImplementation testFixtures(projects.groovySwing)
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..75b147b1b6 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?.bind(shell.classLoader, config)
}
/** Default Swing delegates used to build the console frame and menu bar. */
@@ -579,9 +583,20 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
}
installInterceptor()
updateTitle()
+ installLanguageHost()
swing.doLater inputArea.&requestFocus
}
+ /**
+ * Attaches the in-process language host to the editor and publishes
+ * the console compiler configuration. groovy-console depends on
+ * groovy-lsp, so the host is always constructed.
+ */
+ @CompileStatic
+ private void installLanguageHost() {
+ languageHost = ConsoleLanguageHost.attach(inputArea, shell.classLoader, config)
+ }
+
/**
* Make the console frames capable of native fullscreen
* for Mac OS X Lion and beyond.
@@ -954,7 +969,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
}
}
- // update SmartDocumentFilter styles (ANTLR token-based highlighting)
+ // update SmartDocumentFilter styles (groovy-lsp semantic tokens)
SmartDocumentFilter.updateStyles()
// force re-parse to apply new colors to existing text — suppress
@@ -1007,6 +1022,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
if (threadInterrupt) {
config.addCompilationCustomizers(new ASTTransformationCustomizer(ThreadInterrupt))
}
+ languageHost?.bind(shell.classLoader, config)
}
/** Updates cached selection bounds and the row/column display. */
@@ -1067,6 +1083,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 +1132,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 +1164,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 +1733,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
fc.selectedFiles?.each { file ->
shell.getClassLoader().addURL(file.toURL())
}
+ languageHost?.bind(shell.classLoader, config)
}
}
@@ -1725,6 +1746,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?.bind(shell.classLoader, config)
}
}
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ObjectBrowser.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ObjectBrowser.groovy
index 59c710781d..da8140cdaf 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ObjectBrowser.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ObjectBrowser.groovy
@@ -22,6 +22,7 @@ import groovy.beans.Bindable
import groovy.inspect.Inspector
import groovy.swing.SwingBuilder
import groovy.swing.table.TableSorter
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
import javax.swing.ToolTipManager
import javax.swing.WindowConstants
@@ -32,7 +33,6 @@ import java.awt.datatransfer.StringSelection
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
-import static groovy.console.ui.text.SmartDocumentFilter.HIGHLIGHTED_TOKEN_TYPE_LIST
import static groovy.inspect.Inspector.MEMBER_DECLARER_IDX
import static groovy.inspect.Inspector.MEMBER_EXCEPTIONS_IDX
import static groovy.inspect.Inspector.MEMBER_MODIFIER_IDX
@@ -42,7 +42,7 @@ import static groovy.inspect.Inspector.MEMBER_PARAMS_IDX
import static groovy.inspect.Inspector.MEMBER_TYPE_IDX
import static groovy.inspect.Inspector.MEMBER_VALUE_IDX
import static javax.swing.ListSelectionModel.SINGLE_SELECTION
-import static org.apache.groovy.parser.antlr4.GroovyLexer.VOCABULARY
+
/**
* A little GUI to show some of the Inspector capabilities.
* Starting this script opens the ObjectBrowser on "some String".
@@ -179,9 +179,8 @@ class ObjectBrowser {
return p
}.join('\n').trim()
- for (def ln in (HIGHLIGHTED_TOKEN_TYPE_LIST.collect {
- VOCABULARY.getLiteralName(it)?.replace("'", '')
- }.grep { it } + ['module', 'true', 'false'])) {
+ // Lexer vocabulary, including primitives, true, false, and null.
+ for (def ln in GroovyLanguageEngine.keywords()) {
content = content.replaceAll(/\b(${ln})\b/, '<b>$1</b>')
}
content = content.replace('\n', '<br/>').replace(',', '<b>,</b>').replace(':', '<b>:</b>')
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ThemeManager.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ThemeManager.groovy
index bad4210dbb..d1236d678e 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ThemeManager.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ThemeManager.groovy
@@ -177,9 +177,9 @@ class ThemeManager {
/**
* Returns the attributes for a single theme style key as a plain map
* (foreground/background Colors plus bold/italic/underline Booleans).
- * Consumed by SmartDocumentFilter so ANTLR-token-driven highlighting
+ * Consumed by SmartDocumentFilter so semantic-token highlighting
* picks up the same theme values as the regex-based GroovyFilter styles.
- * Returns an empty map if the theme doesn't define the key.
+ * Returns an empty map when {@code key} is blank or the theme does not define it.
*/
static Map getStyleAttrs(String key) {
if (!key) return [:]
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..0970a8623f
--- /dev/null
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy
@@ -0,0 +1,476 @@
+/*
+ * 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 org.codehaus.groovy.control.CompilerConfiguration
+
+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 javax.swing.text.Highlighter
+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; Alt+/ offers completions and does not
+ * bump the highlight generation. The constructor and {@link #bind}
+ * publish the console {@code CompilerConfiguration}. Later queries
+ * publish the parent loader only, so an unchanged buffer can reuse the
+ * snapshot. Typing debounces diagnostic highlights and does not paint
+ * compiled name-kind tokens.
+ * 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
+ /** Set on Alt+/ so the following keyTyped does not insert '/' or Option+/. */
+ private boolean swallowNextTyped
+ private volatile ClassLoader parent
+ private volatile CompilerConfiguration configuration
+ private ScheduledFuture<?> pendingHighlight
+ private ScheduledFuture<?> pendingCompletion
+
+ private ConsoleLanguageHost(JTextPane input, ClassLoader loader, CompilerConfiguration configuration) {
+ this.input = input
+ this.parent = loader
+ this.configuration = configuration
+ this.engine = new GroovyLanguageEngine()
+ engine.setParentLoader(loader)
+ engine.setHostConfiguration(configuration)
+ 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 (isCompletionChord(e)) {
+ showCompletions()
+ e.consume()
+ swallowNextTyped = true
+ }
+ }
+
+ @Override
+ void keyTyped(KeyEvent e) {
+ if (swallowNextTyped) {
+ swallowNextTyped = false
+ e.consume()
+ }
+ }
+
+ @Override
+ void keyReleased(KeyEvent e) {
+ if (e.keyCode == KeyEvent.VK_SLASH) {
+ swallowNextTyped = false
+ }
+ }
+ }
+ 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) {
+ attach(input, loader, null)
+ }
+
+ /**
+ * @param input editor pane
+ * @param loader GroovyShell class loader
+ * @param configuration console compiler configuration
+ * @return a host bound to {@code input}
+ */
+ static ConsoleLanguageHost attach(JTextPane input, ClassLoader loader, CompilerConfiguration configuration) {
+ new ConsoleLanguageHost(input, loader, configuration)
+ }
+
+ /**
+ * 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) {
+ bindEngine()
+ engine.diagnostics(BUFFER_URI, text)
+ }
+
+ /**
+ * 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) {
+ bindEngine()
+ engine.complete(BUFFER_URI, text, 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) {
+ bindEngine()
+ engine.describe(BUFFER_URI, text, 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()
+ pendingHighlight?.cancel(false)
+ String text = textOf(input)
+ paint(text, compileAndDiagnostics(text))
+ }
+
+ /**
+ * Offers completions at the caret. Used by Alt+/ and tests.
+ */
+ @PackageScope
+ void completeAtCaret() {
+ showCompletions()
+ }
+
+ /**
+ * Records the GroovyShell class loader, republishes the current
+ * compiler configuration, and schedules a background recompile so
+ * highlights catch up without a keystroke.
+ *
+ * @param loader GroovyShell class loader
+ */
+ void setParentLoader(ClassLoader loader) {
+ bind(loader, configuration)
+ }
+
+ /**
+ * Records the console compiler configuration (imports, AST customizers)
+ * and schedules a recompile so query compiles match {@code GroovyShell}.
+ * Call this after replacing the configuration or editing a customizer
+ * in place.
+ *
+ * @param configuration console configuration, or {@code null}
+ */
+ void setCompilerConfiguration(CompilerConfiguration configuration) {
+ bind(parent, configuration)
+ }
+
+ /**
+ * Records the shell loader and compiler configuration, then schedules
+ * a recompile. Call this after replacing the configuration or editing
+ * its customizers in place. Queries publish the loader only.
+ *
+ * @param loader GroovyShell class loader
+ * @param configuration console compiler configuration
+ */
+ void bind(ClassLoader loader, CompilerConfiguration configuration) {
+ if (closed) {
+ return
+ }
+ this.parent = loader
+ this.configuration = configuration
+ engine.setParentLoader(loader)
+ engine.setHostConfiguration(configuration)
+ schedule()
+ }
+
+ /** Loader identity and URLs. Configuration is published by the constructor and {@link #bind}. */
+ private void bindEngine() {
+ engine.setParentLoader(parent)
+ }
+
+ @Override
+ void close() {
+ if (closed) {
+ return
+ }
+ closed = true
+ generation.incrementAndGet()
+ pendingHighlight?.cancel(false)
+ pendingCompletion?.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)
+ pendingHighlight?.cancel(false)
+ try {
+ pendingHighlight = debounce.schedule({
+ runHighlight(gen, text)
+ }, DEBOUNCE_MS, TimeUnit.MILLISECONDS)
+ } catch (RejectedExecutionException ignored) {
+ }
+ }
+
+ private void runHighlight(int gen, String text) {
+ 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) {
+ }
+ }
+
+ 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 = GroovyLanguageEngine.spanOf(text, hit)
+ Object tag = addErrorHighlight(highlighter, span[0], span[1], errorPainter)
+ if (tag != null) {
+ highlights << tag
+ }
+ }
+ if (hits) {
+ input.toolTipText = hits.take(3).collect { it.message() }.join('\n')
+ } else {
+ input.toolTipText = null
+ }
+ }
+
+ private static Object addErrorHighlight(Highlighter highlighter, int start, int end,
+ DefaultHighlighter.DefaultHighlightPainter painter) {
+ try {
+ return highlighter.addHighlight(start, end, painter)
+ } catch (BadLocationException ignored) {
+ return null
+ }
+ }
+
+ /**
+ * Builds the completion popup without showing it, so tests can invoke the
+ * insert action without a display.
+ */
+ @PackageScope
+ JPopupMenu completionMenu(List<GroovyLanguageEngine.Candidate> items) {
+ def menu = new JPopupMenu()
+ items.take(MAX_COMPLETIONS).each { item ->
+ def entry = new JMenuItem(item.label() ?: item.insertText())
+ entry.addActionListener {
+ acceptCompletion(item)
+ }
+ menu.add(entry)
+ }
+ menu
+ }
+
+ @PackageScope
+ void acceptCompletion(GroovyLanguageEngine.Candidate item) {
+ insertAtCaret(input, GroovyLanguageEngine.flattenSnippet(item.insertText() ?: item.label()))
+ }
+
+ 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()
+ pendingCompletion?.cancel(false)
+ try {
+ pendingCompletion = debounce.schedule({
+ runCompletions(gen, text, position[0], position[1])
+ }, 0, TimeUnit.MILLISECONDS)
+ } catch (RejectedExecutionException ignored) {
+ }
+ }
+
+ private void runCompletions(int gen, String text, int line, int character) {
+ try {
+ if (closed || gen != generation.get()) {
+ return
+ }
+ def items = complete(text, line, character)
+ SwingUtilities.invokeLater {
+ if (!closed && gen == generation.get()) {
+ showMenu(items)
+ }
+ }
+ } catch (RuntimeException ignored) {
+ }
+ }
+
+ private void showMenu(List<GroovyLanguageEngine.Candidate> items) {
+ if (input == null || !items) {
+ return
+ }
+ try {
+ def menu = completionMenu(items)
+ 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) {
+ }
+ }
+
+ private static String textOf(JTextPane pane) {
+ pane == null ? '' : (pane.text ?: '')
+ }
+
+ /**
+ * Alt+/. Ctrl+Space is left free: it switches the input method on
+ * Chinese desktops and on macOS. Ctrl+/ remains comment/uncomment.
+ */
+ private static boolean isCompletionChord(KeyEvent e) {
+ if (e.keyCode != KeyEvent.VK_SLASH) {
+ return false
+ }
+ int mods = e.modifiersEx
+ return (mods & InputEvent.ALT_DOWN_MASK) != 0
+ && (mods & (InputEvent.CTRL_DOWN_MASK | InputEvent.META_DOWN_MASK
+ | InputEvent.SHIFT_DOWN_MASK | InputEvent.ALT_GRAPH_DOWN_MASK)) == 0
+ }
+
+}
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..3d7c83c676
--- /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. The host publishes the console compiler configuration when it is attached or bound, and later queries publish the parent loader only. Implementation detail of Groovy Console; not an embedding API.</p>
+ </body>
+</html>
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/MatchingHighlighter.java b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/MatchingHighlighter.java
index b3be8dd8b9..653a16c2fd 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/MatchingHighlighter.java
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/MatchingHighlighter.java
@@ -236,10 +236,13 @@ public class MatchingHighlighter implements CaretListener {
private void clearHighlighted() {
if (!highlightedTokenInfoList.isEmpty()) {
for (Tuple3<Integer, Position, Integer> highlightedTokenInfo : highlightedTokenInfoList) {
+ // Braces are not semantic tokens, so the default style is their
+ // syntax style. A saved attribute set can land on the next
+ // token after the matched brace is deleted.
doc.setCharacterAttributes(
highlightedTokenInfo.getV2().getOffset(),
highlightedTokenInfo.getV3(),
- findStyleByTokenType(highlightedTokenInfo.getV1()),
+ defaultStyle,
true
);
}
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/SmartDocumentFilter.java b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/SmartDocumentFilter.java
index 9d639328ad..e85965129e 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/SmartDocumentFilter.java
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/SmartDocumentFilter.java
@@ -18,18 +18,11 @@
*/
package groovy.console.ui.text;
+import groovy.console.ui.ThemeManager;
import groovy.lang.Tuple2;
-import org.antlr.v4.runtime.CharStream;
-import org.antlr.v4.runtime.CharStreams;
-import org.antlr.v4.runtime.CommonTokenStream;
-import org.antlr.v4.runtime.ConsoleErrorListener;
-import org.antlr.v4.runtime.LexerNoViableAltException;
import org.antlr.v4.runtime.Token;
-import org.apache.groovy.parser.antlr4.GroovyLangLexer;
-import org.apache.groovy.parser.antlr4.GroovySyntaxError;
-import org.apache.groovy.parser.antlr4.util.PositionConfigureUtils;
-import org.apache.groovy.parser.antlr4.util.StringUtils;
-import org.apache.groovy.util.ReversedList;
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine;
+import org.apache.groovy.parser.antlr4.GroovyLexer;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
@@ -39,112 +32,41 @@ import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyleContext;
import java.awt.Color;
-import java.io.IOException;
-import java.io.StringReader;
-import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
-
-import groovy.console.ui.ThemeManager;
-
-import static org.apache.groovy.parser.antlr4.GroovyLexer.ABSTRACT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.AS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.AT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.CapitalizedIdentifier;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.DOT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.Identifier;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.ASSERT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.ASYNC;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.AWAIT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.BREAK;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.BooleanLiteral;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.BuiltInPrimitiveType;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.CASE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.CATCH;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.CLASS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.COMMA;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.CONST;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.CONTINUE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.DEF;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.DEFAULT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.DEFER;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.DO;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.ELSE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.ENUM;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.EOF;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.EXTENDS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.FINAL;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.FINALLY;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.FOR;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.FloatingPointLiteral;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.GOTO;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.GStringBegin;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.GStringEnd;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.GStringPart;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.IF;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.IMPLEMENTS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.IMPORT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.IN;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.INSTANCEOF;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.INTERFACE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.IntegerLiteral;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NATIVE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NEW;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NL;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NON_SEALED;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NOT_IN;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NOT_INSTANCEOF;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.NullLiteral;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.PACKAGE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.PERMITS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.PRIVATE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.PROTECTED;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.PUBLIC;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.RECORD;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.RETURN;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.SEALED;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.SEMI;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.STATIC;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.STRICTFP;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.SUPER;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.SWITCH;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.SYNCHRONIZED;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.StringLiteral;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.THIS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.THREADSAFE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.THROW;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.THROWS;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.TRAIT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.TRANSIENT;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.TRY;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.UNEXPECTED_CHAR;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.VAL;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.VAR;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.VOID;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.VOLATILE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.WHILE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.YIELD;
-
/**
- * The document filter based on Parrot's lexer is for highlighting the content of text editor
+ * Swing document filter that styles the editor from groovy-lsp semantic
+ * tokens. The raw walk is {@link GroovyLanguageEngine#lexerTokens(String)};
+ * live colour comes from {@link GroovyLanguageEngine#lexicalTokens(String, List)}
+ * on that same list (legend type names, one lexer walk, no compile). Keywords, numbers,
+ * comments, {@code @}, and comma or semicolon use the matching theme
+ * style. Other legend names, including identifiers, use the identifier
+ * style. A string takes its colour from the lexer token that opened the
+ * literal (single quote, double quote, or slashy). A line-split span
+ * does not repeat that delimiter. The {@code $} that ends a GString
+ * begin or part is left in the default style. Brace matching still
+ * reads {@link #getLatestTokenList()} and is not a semantic token.
*
* @since 3.0.0
*/
public class SmartDocumentFilter extends DocumentFilter {
- /**
- * Token types rendered with the reserved-word style.
- */
- public static final List<Integer> HIGHLIGHTED_TOKEN_TYPE_LIST =
- Arrays.asList(AS, ASYNC, AWAIT, DEF, DEFER, IN, TRAIT, THREADSAFE,
- VAL, VAR, BuiltInPrimitiveType, ABSTRACT, ASSERT, BREAK, CASE, CATCH, CLASS, CONST, CONTINUE, DEFAULT, DO,
- ELSE, ENUM, EXTENDS, FINAL, FINALLY, FOR, IF, GOTO, IMPLEMENTS, IMPORT, INSTANCEOF, INTERFACE,
- NATIVE, NEW, NON_SEALED, NOT_IN, NOT_INSTANCEOF, PACKAGE, PERMITS, PRIVATE, PROTECTED, PUBLIC,
- RECORD, RETURN, SEALED, STATIC, STRICTFP, SUPER, SWITCH, SYNCHRONIZED,
- THIS, THROW, THROWS, TRANSIENT, TRY, VOID, VOLATILE, WHILE, YIELD, NullLiteral, BooleanLiteral);
private static final String MONOSPACED = "Monospaced";
+ private static final String STYLE_COMMENT = "comment";
+ private static final String STYLE_QUOTES = "quotes";
+ private static final String STYLE_SINGLE_QUOTES = "single_quotes";
+ private static final String STYLE_SLASHY_QUOTES = "slashy_quotes";
+ private static final String STYLE_DIGIT = "digit";
+ private static final String STYLE_ANNOTATION = "annotation";
+ private static final String STYLE_OPERATION = "operation";
+ private static final String STYLE_IDENT = "ident";
+ private static final String STYLE_RESERVED = "reserved_word";
+ private static final List<String> THEME_STYLE_KEYS = List.of(
+ STYLE_COMMENT, STYLE_QUOTES, STYLE_SINGLE_QUOTES, STYLE_SLASHY_QUOTES,
+ STYLE_DIGIT, STYLE_ANNOTATION, STYLE_OPERATION, STYLE_IDENT,
+ STYLE_RESERVED);
+
private final DefaultStyledDocument styledDocument;
private final StyleContext styleContext;
private final Style defaultStyle;
@@ -170,9 +92,7 @@ public class SmartDocumentFilter extends DocumentFilter {
@Override
public void insertString(DocumentFilter.FilterBypass fb, int offset,
String text, AttributeSet attrs) throws BadLocationException {
- // remove problem meta characters returns
text = replaceMetaCharacters(text);
-
fb.insertString(offset, text, attrs);
parseDocument();
}
@@ -183,7 +103,6 @@ public class SmartDocumentFilter extends DocumentFilter {
@Override
public void remove(DocumentFilter.FilterBypass fb, int offset, int length)
throws BadLocationException {
-
fb.remove(offset, length);
parseDocument();
}
@@ -195,235 +114,185 @@ public class SmartDocumentFilter extends DocumentFilter {
public void replace(DocumentFilter.FilterBypass fb, int offset,
int length, String text, AttributeSet attrs)
throws BadLocationException {
-
- // text might be null and indicates no replacement text
- if (text == null) text = "";
-
- // remove problem meta characters returns
+ if (text == null) {
+ text = "";
+ }
text = replaceMetaCharacters(text);
-
fb.replace(offset, length, text, attrs);
-
parseDocument();
}
private String replaceMetaCharacters(String string) {
- // just in case remove carriage returns
- string = string.replace("\r\n", "\n");
- return string;
+ return string.replace("\r\n", "\n");
}
private void parseDocument() throws BadLocationException {
- GroovyLangLexer lexer;
- try {
- lexer = createLexer(styledDocument.getText(0, styledDocument.getLength()));
- } catch (IOException e) {
- e.printStackTrace();
- this.latest = false;
- return;
- }
-
- CommonTokenStream tokenStream = new CommonTokenStream(lexer);
-
- try {
- tokenStream.fill();
- } catch (LexerNoViableAltException | GroovySyntaxError e) {
- // ignore
- this.latest = false;
- return;
- } catch (Exception e) {
- e.printStackTrace();
- this.latest = false;
- return;
- }
-
- List<Token> tokenList = tokenStream.getTokens();
- List<Token> tokenListToRender;
+ String text = styledDocument.getText(0, styledDocument.getLength());
+ List<Token> tokenList = GroovyLanguageEngine.lexerTokens(text);
+ this.latestTokenList = tokenList;
+ this.latest = true;
+ int[] dirty = dirtyRange(text);
try {
- tokenListToRender = findTokensToRender(tokenList);
+ render(text, tokenList, dirty[0], dirty[1]);
} finally {
this.setRenderRange(null);
}
+ }
- boolean inAnnotation = false;
- for (Token token : tokenListToRender) {
- int tokenType = token.getType();
-
- if (EOF == tokenType) {
- continue;
- }
-
- int tokenStartIndex = token.getStartIndex();
- int tokenStopIndex = token.getStopIndex();
- int tokenLength = tokenStopIndex - tokenStartIndex + 1;
-
- // color annotation names (identifier/dot tokens after @) with annotation style
- // handles @ToString, @groovy.transform.CompileStatic, etc.
- boolean isAnnotationPart = inAnnotation
- && (tokenType == Identifier || tokenType == CapitalizedIdentifier || tokenType == DOT);
- if (tokenType == AT) {
- inAnnotation = true;
- } else if (isAnnotationPart) {
- // stay in annotation for dotted names
- inAnnotation = (tokenType == DOT);
- } else {
- inAnnotation = false;
- }
+ private int[] dirtyRange(String text) {
+ int last = text.length() - 1;
+ if (last < 0) {
+ return new int[] {0, -1};
+ }
+ Tuple2<Integer, Integer> range = getRenderRange();
+ if (range == null) {
+ return new int[] {0, last};
+ }
+ int start = range.getV1();
+ int stop = range.getV2();
+ if (start < 0 || stop < 0) {
+ return new int[] {0, last};
+ }
+ int from = Math.max(0, Math.min(start, stop));
+ int to = Math.min(last, Math.max(start, stop));
+ return new int[] {from, to};
+ }
- styledDocument.setCharacterAttributes(tokenStartIndex,
- tokenLength,
- isAnnotationPart ? findStyleByTokenType(AT) : findStyleByTokenType(tokenType),
- true);
+ private void render(String text, List<Token> antlrTokens, int dirtyStart, int dirtyStop) {
+ if (text.isEmpty() || dirtyStart > dirtyStop) {
+ return;
+ }
+ styledDocument.setCharacterAttributes(dirtyStart, dirtyStop - dirtyStart + 1, defaultStyle, true);
+ paintSemantic(text, antlrTokens, GroovyLanguageEngine.lexicalTokens(text, antlrTokens), dirtyStart, dirtyStop);
+ }
- if (GStringBegin == tokenType || GStringPart == tokenType) {
- styledDocument.setCharacterAttributes(
- tokenStartIndex + tokenLength - 1,
- 1,
- defaultStyle,
- true);
+ private void paintSemantic(String text, List<Token> antlrTokens, List<GroovyLanguageEngine.Token> tokens,
+ int dirtyStart, int dirtyStop) {
+ for (GroovyLanguageEngine.Token token : tokens) {
+ int[] span = GroovyLanguageEngine.spanOf(text, token);
+ int start = span[0];
+ int end = span[1];
+ if (end > dirtyStart && start <= dirtyStop && end > start) {
+ Style style = styleFor(token, antlrTokens, start);
+ styledDocument.setCharacterAttributes(start, end - start, style, true);
+ unstyleGStringDollar(antlrTokens, start, end);
}
}
-
- this.latestTokenList = tokenList;
- this.latest = true;
}
- private List<Token> findTokensToRender(List<Token> tokenList) {
- final Tuple2<Integer, Integer> renderRange = getRenderRange();
- if (null != renderRange) {
- long startLine = lineNumber(renderRange.getV1());
- long stopLine = lineNumber(renderRange.getV2());
-
- if (startLine < 0 || stopLine < 0) return tokenList; // should never happen
-
- return tokenList.stream()
- .filter(e -> e.getLine() >= startLine && PositionConfigureUtils.endPosition(e).getV1() <= stopLine)
- .collect(Collectors.toList());
+ private Style styleFor(GroovyLanguageEngine.Token token, List<Token> antlrTokens, int start) {
+ String type = token.type();
+ if ("keyword".equals(type)) {
+ return styleNamed(STYLE_RESERVED);
}
-
- List<Token> tmpLatestTokenList = filterNewlines(this.latestTokenList);
- int latestTokenListSize = tmpLatestTokenList.size();
- if (0 == latestTokenListSize) {
- return tokenList;
+ if ("string".equals(type)) {
+ return styleNamed(stringTheme(antlrTokens, start));
}
-
- List<Token> tmpTokenList = filterNewlines(tokenList);
- int tmpTokenListSize = tmpTokenList.size();
- if (0 == tmpTokenListSize) {
- return tokenList;
+ if ("number".equals(type)) {
+ return styleNamed(STYLE_DIGIT);
}
-
- int startTokenIndex = 0;
- int minSize = Math.min(tmpTokenListSize, latestTokenListSize);
- for (int i = 0; i < minSize; i++) {
- Token token = tmpTokenList.get(i);
- Token latestToken = tmpLatestTokenList.get(i);
-
- if (token.getType() == latestToken.getType()
- && token.getStartIndex() == latestToken.getStartIndex()
- && token.getStopIndex() == latestToken.getStopIndex()) {
- continue;
- }
-
- startTokenIndex = i;
- break;
+ if ("comment".equals(type)) {
+ return styleNamed(STYLE_COMMENT);
}
-
- List<Token> newTokenList = new ReversedList<>(tmpTokenList);
- List<Token> newLatestTokenList = new ReversedList<>(tmpLatestTokenList);
-
- int stopTokenIndex = tmpTokenListSize;
-
- Token lastToken = newTokenList.get(0);
- Token lastLatestToken = newLatestTokenList.get(0);
-
- for (int i = 0; i < minSize; i++) {
- Token token = newTokenList.get(i);
- Token latestToken = newLatestTokenList.get(i);
-
- if ((token.getType() == latestToken.getType())
- && (token.getStartIndex() - lastToken.getStartIndex()) == (latestToken.getStartIndex() - lastLatestToken.getStartIndex())
- && ((token.getStopIndex() - lastToken.getStopIndex()) == (latestToken.getStopIndex() - lastLatestToken.getStopIndex()))) {
- continue;
- }
-
- stopTokenIndex = tmpTokenListSize - i;
- break;
+ if ("decorator".equals(type)) {
+ return styleNamed(STYLE_ANNOTATION);
}
-
- if (startTokenIndex == stopTokenIndex) {
- return tmpTokenListSize != latestTokenListSize ? tokenList : Collections.emptyList();
- } else if (startTokenIndex < stopTokenIndex) {
- return tmpTokenList.subList(startTokenIndex, stopTokenIndex);
+ if ("operator".equals(type)) {
+ return styleNamed(STYLE_OPERATION);
}
-
- return tokenList; // should never reach here. If unexpected error occurred, it's better to render all tokens
+ return styleNamed(STYLE_IDENT);
}
/**
- * Returns line number starting with 1
+ * Line-split semantic spans do not repeat the opening delimiter.
+ * The theme comes from the lexer token that opened the literal.
*/
- private long lineNumber(int position) {
- long result = -1;
-
- if (position < 0) return result;
-
- try {
- String text = this.styledDocument.getText(0, position + 1);
- result = StringUtils.countChar(text, '\n') + 1;
- } catch (BadLocationException e) {
- e.printStackTrace(System.err);
+ private static String stringTheme(List<Token> lexerTokens, int offset) {
+ int index = indexCovering(lexerTokens, offset);
+ if (index < 0) {
+ return STYLE_SINGLE_QUOTES;
}
-
- return result;
+ for (int i = index; i >= 0; i--) {
+ Token token = lexerTokens.get(i);
+ int type = token.getType();
+ if (type == GroovyLexer.StringLiteral || type == GroovyLexer.GStringBegin) {
+ return themeOfDelimiter(token.getText());
+ }
+ if (type != GroovyLexer.GStringPart && type != GroovyLexer.GStringEnd) {
+ break;
+ }
+ }
+ return STYLE_QUOTES;
}
- private List<Token> filterNewlines(List<Token> tokenList) {
- return tokenList.stream()
- .filter(e -> !(NL == e.getType() && e.getText().trim().isEmpty()))
- .collect(Collectors.toList());
+ private static String themeOfDelimiter(String body) {
+ if (body != null && (body.startsWith("$/") || body.startsWith("/"))) {
+ return STYLE_SLASHY_QUOTES;
+ }
+ if (body != null && body.startsWith("\"")) {
+ return STYLE_QUOTES;
+ }
+ return STYLE_SINGLE_QUOTES;
}
- private Style findStyleByTokenType(int tokenType) {
- Style style = styleContext.getStyle(String.valueOf(tokenType));
-
- return null == style ? defaultStyle : style;
+ private void unstyleGStringDollar(List<Token> lexerTokens, int spanStart, int spanEnd) {
+ int index = indexCovering(lexerTokens, spanStart);
+ if (index < 0) {
+ return;
+ }
+ Token token = lexerTokens.get(index);
+ int type = token.getType();
+ if (type != GroovyLexer.GStringBegin && type != GroovyLexer.GStringPart) {
+ return;
+ }
+ int dollar = token.getStopIndex();
+ if (dollar >= spanStart && dollar < spanEnd) {
+ styledDocument.setCharacterAttributes(dollar, 1, defaultStyle, true);
+ }
}
- private Style createDefaultStyleByTokenType(int tokenType) {
- return styleContext.addStyle(String.valueOf(tokenType), defaultStyle);
+ /**
+ * Index of the lexer token covering {@code offset}, or {@code -1}.
+ * Lexer tokens are sorted and disjoint, so the rightmost token that
+ * starts at or before {@code offset} is the only candidate.
+ */
+ private static int indexCovering(List<Token> lexerTokens, int offset) {
+ if (lexerTokens == null) {
+ return -1;
+ }
+ int lo = 0;
+ int hi = lexerTokens.size() - 1;
+ int candidate = -1;
+ while (lo <= hi) {
+ int mid = (lo + hi) >>> 1;
+ int start = lexerTokens.get(mid).getStartIndex();
+ if (start >= 0 && start <= offset) {
+ candidate = mid;
+ lo = mid + 1;
+ } else if (start > offset) {
+ hi = mid - 1;
+ } else {
+ lo = mid + 1;
+ }
+ }
+ if (candidate < 0) {
+ return -1;
+ }
+ Token token = lexerTokens.get(candidate);
+ return offset <= token.getStopIndex() ? candidate : -1;
}
- private GroovyLangLexer createLexer(String text) throws IOException {
- CharStream charStream = CharStreams.fromReader(new StringReader(text));
- GroovyLangLexer lexer = new GroovyLangLexer(charStream);
-
- lexer.setErrorIgnored(true);
- lexer.removeErrorListener(ConsoleErrorListener.INSTANCE);
-
- return lexer;
+ private Style styleNamed(String themeKey) {
+ Style style = styleContext.getStyle(themeKey);
+ return null == style ? defaultStyle : style;
}
private void initStyles() {
- // create styles (ensures they exist in the StyleContext)
- createDefaultStyleByTokenType(NL);
- for (int t : Arrays.asList(GStringBegin, GStringPart, GStringEnd)) {
- createDefaultStyleByTokenType(t);
- }
- createDefaultStyleByTokenType(StringLiteral);
- for (int t : Arrays.asList(IntegerLiteral, FloatingPointLiteral)) {
- createDefaultStyleByTokenType(t);
- }
- for (int t : HIGHLIGHTED_TOKEN_TYPE_LIST) {
- createDefaultStyleByTokenType(t);
- }
- for (int t : Arrays.asList(COMMA, SEMI)) {
- createDefaultStyleByTokenType(t);
+ for (String key : THEME_STYLE_KEYS) {
+ if (styleContext.getStyle(key) == null) {
+ styleContext.addStyle(key, defaultStyle);
+ }
}
- createDefaultStyleByTokenType(AT);
- createDefaultStyleByTokenType(UNEXPECTED_CHAR);
-
- // apply theme colors
updateStyles();
}
@@ -446,48 +315,29 @@ public class SmartDocumentFilter extends DocumentFilter {
/**
* Updates all SmartDocumentFilter styles with current theme colors.
* Attribute values are sourced from ThemeManager so custom .theme files
- * feed both this (ANTLR-token-driven) path and the regex-driven GroovyFilter
+ * feed both this semantic-token path and the regex-driven GroovyFilter
* path from one place. Called at init time and on theme switch.
*
* @since 6.0.0
*/
public static void updateStyles() {
StyleContext sc = StyleContext.getDefaultStyleContext();
-
- applyThemeStyle(sc.getStyle(String.valueOf(NL)), "comment");
-
- for (int t : Arrays.asList(GStringBegin, GStringPart, GStringEnd)) {
- applyThemeStyle(sc.getStyle(String.valueOf(t)), "quotes");
- }
- applyThemeStyle(sc.getStyle(String.valueOf(StringLiteral)), "single_quotes");
-
- for (int t : Arrays.asList(IntegerLiteral, FloatingPointLiteral)) {
- applyThemeStyle(sc.getStyle(String.valueOf(t)), "digit");
- }
-
- for (int t : HIGHLIGHTED_TOKEN_TYPE_LIST) {
- applyThemeStyle(sc.getStyle(String.valueOf(t)), "reserved_word");
- }
-
- // commas and semicolons reuse the "operation" theme key — closest semantic match
- for (int t : Arrays.asList(COMMA, SEMI)) {
- applyThemeStyle(sc.getStyle(String.valueOf(t)), "operation");
- }
-
- applyThemeStyle(sc.getStyle(String.valueOf(AT)), "annotation");
-
- // unexpected-char is a lexer-error indicator, not semantic syntax — stay hardcoded
- Style unexpectedChar = sc.getStyle(String.valueOf(UNEXPECTED_CHAR));
- if (unexpectedChar != null) {
- StyleConstants.setForeground(unexpectedChar,
- ThemeManager.isDark() ? Color.CYAN : Color.CYAN.darker());
- }
+ applyThemeStyle(sc.getStyle(STYLE_COMMENT), STYLE_COMMENT);
+ applyThemeStyle(sc.getStyle(STYLE_QUOTES), STYLE_QUOTES);
+ applyThemeStyle(sc.getStyle(STYLE_SINGLE_QUOTES), STYLE_SINGLE_QUOTES);
+ applyThemeStyle(sc.getStyle(STYLE_SLASHY_QUOTES), STYLE_SLASHY_QUOTES);
+ applyThemeStyle(sc.getStyle(STYLE_DIGIT), STYLE_DIGIT);
+ applyThemeStyle(sc.getStyle(STYLE_ANNOTATION), STYLE_ANNOTATION);
+ applyThemeStyle(sc.getStyle(STYLE_OPERATION), STYLE_OPERATION);
+ applyThemeStyle(sc.getStyle(STYLE_IDENT), STYLE_IDENT);
+ applyThemeStyle(sc.getStyle(STYLE_RESERVED), STYLE_RESERVED);
}
@SuppressWarnings("rawtypes")
- private static void applyThemeStyle(Style style, String themeKey) {
- if (style == null) return;
- // clear managed attributes so the previous theme's values don't linger
+ static void applyThemeStyle(Style style, String themeKey) {
+ if (style == null) {
+ return;
+ }
style.removeAttribute(StyleConstants.Foreground);
style.removeAttribute(StyleConstants.Background);
style.removeAttribute(StyleConstants.Bold);
@@ -495,12 +345,22 @@ public class SmartDocumentFilter extends DocumentFilter {
style.removeAttribute(StyleConstants.Underline);
Map attrs = ThemeManager.getStyleAttrs(themeKey);
Object fg = attrs.get("foreground");
- if (fg instanceof Color) StyleConstants.setForeground(style, (Color) fg);
+ if (fg instanceof Color) {
+ StyleConstants.setForeground(style, (Color) fg);
+ }
Object bg = attrs.get("background");
- if (bg instanceof Color) StyleConstants.setBackground(style, (Color) bg);
- if (Boolean.TRUE.equals(attrs.get("bold"))) StyleConstants.setBold(style, true);
- if (Boolean.TRUE.equals(attrs.get("italic"))) StyleConstants.setItalic(style, true);
- if (Boolean.TRUE.equals(attrs.get("underline"))) StyleConstants.setUnderline(style, true);
+ if (bg instanceof Color) {
+ StyleConstants.setBackground(style, (Color) bg);
+ }
+ if (Boolean.TRUE.equals(attrs.get("bold"))) {
+ StyleConstants.setBold(style, true);
+ }
+ if (Boolean.TRUE.equals(attrs.get("italic"))) {
+ StyleConstants.setItalic(style, true);
+ }
+ if (Boolean.TRUE.equals(attrs.get("underline"))) {
+ StyleConstants.setUnderline(style, true);
+ }
}
private volatile boolean latest = false;
diff --git a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
index e8b19811b8..a91b820517 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -161,12 +161,24 @@ follows your operating system's dark mode setting.
image:{reldir_console}/assets/img/gconsole-dark-theme.png[Dark Theme]
* Code is auto indented when you hit return
+* The editor highlights Groovy source from groovy-lsp semantic tokens
+(`GroovyLanguageEngine.lexicalTokens` on each edit). A multiline string
+keeps the color of its opening delimiter (single quote, double quote, or
+slashy). The `$` that opens a GString expression is left uncolored.
+A regex highlighter remains available from `View -> Smart Highlighter`.
* You can drag'n'drop a Groovy script over the text area to open a file
* You can modify the classpath with which the script in the console is
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. `Alt+/` offers compiler-backed completions
+(`Ctrl+Space` is left free for the system input method).
+Adding jars or directories to the classpath is picked up on the next
+background compile. The editor shares the console `CompilerConfiguration`
+(imports and AST customizers) with 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/ConsoleLanguageWiringTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleLanguageWiringTest.groovy
new file mode 100644
index 0000000000..25ce5f1166
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleLanguageWiringTest.groovy
@@ -0,0 +1,132 @@
+/*
+ * 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
+
+import groovy.inspect.Inspector
+import groovy.swing.SwingBuilder
+import org.junit.jupiter.api.Test
+
+import javax.swing.AbstractAction
+import javax.swing.JCheckBoxMenuItem
+import javax.swing.JFileChooser
+import javax.swing.JLayeredPane
+import javax.swing.JRootPane
+import javax.swing.JTextPane
+import javax.swing.RootPaneContainer
+import javax.swing.text.DefaultStyledDocument
+import java.awt.Component
+import java.awt.Container
+import java.awt.HeadlessException
+import java.awt.event.ActionEvent
+
+class ConsoleLanguageWiringTest {
+
+ @Test
+ void consolePublishesTheLanguageHostOnShellClasspathAndExit() {
+ Console console = new Console()
+ def box = new JCheckBoxMenuItem()
+ box.selected = true
+ console.threadInterruption(new ActionEvent(box, 0, 'interrupt'))
+
+ console.inputArea = new JTextPane()
+ console.inputArea.document = new DefaultStyledDocument()
+ console.outputArea = new JTextPane()
+ console.saveAction = new AbstractAction() {
+ @Override void actionPerformed(ActionEvent e) { }
+ }
+ def editor = Console.getDeclaredField('inputEditor')
+ editor.accessible = true
+ def unsafeField = Class.forName('sun.misc.Unsafe').getDeclaredField('theUnsafe')
+ unsafeField.accessible = true
+ def unsafe = unsafeField.get(null)
+ def allocate = unsafe.class.getMethod('allocateInstance', Class)
+ def allocated = allocate.invoke(unsafe, ConsoleTextEditor)
+ allocated.metaClass.repaint = { -> }
+ editor.set(console, allocated)
+ console.frame = new FakeFrame()
+ console.swing = new SwingBuilder()
+ def install = Console.getDeclaredMethod('installLanguageHost')
+ install.accessible = true
+ install.invoke(console)
+ console.newScript(console.shell.classLoader.parent, console.shell.context)
+ box.selected = false
+ console.threadInterruption(new ActionEvent(box, 0, 'interrupt'))
+ box.selected = true
+ console.threadInterruption(new ActionEvent(box, 0, 'interrupt'))
+
+ def file = File.createTempFile('console-host', '.groovy')
+ file.deleteOnExit()
+ file.text = 'int kept = 1\n'
+ console.loadScriptFile(file)
+ assert console.inputArea.text.contains('kept')
+
+ withApprovingChooser {
+ console.addClasspathJar(null)
+ console.addClasspathDir(null)
+ }
+ try {
+ console.exit()
+ } catch (ExceptionInInitializerError | NoClassDefFoundError | HeadlessException ignored) {
+ // FindReplaceUtility builds a dialog after the host is cleared.
+ }
+ try {
+ new Console().exit()
+ } catch (ExceptionInInitializerError | NoClassDefFoundError | HeadlessException ignored) {
+ // Same dialog, with no language host installed.
+ }
+ }
+
+ @Test
+ void objectBrowserBoldsKeywordsInTheClassCard() {
+ def browser = new ObjectBrowser()
+ def swing = new SwingBuilder()
+ def method = ObjectBrowser.declaredMethods.find { it.name == 'makeCard' }
+ method.accessible = true
+ def card = method.invoke(browser, swing, new Inspector('class'), 'path')
+ assert card != null
+ }
+
+ private static final class FakeFrame implements RootPaneContainer {
+ String title = 'GroovyConsole'
+ private final JRootPane root = new JRootPane()
+
+ @Override JRootPane getRootPane() { root }
+ @Override Container getContentPane() { root.contentPane }
+ @Override void setContentPane(Container contentPane) { root.contentPane = contentPane }
+ @Override JLayeredPane getLayeredPane() { root.layeredPane }
+ @Override void setLayeredPane(JLayeredPane layeredPane) { root.layeredPane = layeredPane }
+ @Override Component getGlassPane() { root.glassPane }
+ @Override void setGlassPane(Component glassPane) { root.glassPane = glassPane }
+ }
+
+ private static void withApprovingChooser(Closure body) {
+ def jar = File.createTempFile('console-host', '.jar')
+ jar.deleteOnExit()
+ def dir = jar.parentFile
+ JFileChooser.metaClass.showDialog = { parent, approve -> JFileChooser.APPROVE_OPTION }
+ JFileChooser.metaClass.getSelectedFiles = { -> [jar] as File[] }
+ JFileChooser.metaClass.getSelectedFile = { -> dir }
+ JFileChooser.metaClass.getCurrentDirectory = { -> dir }
+ try {
+ body.call()
+ } finally {
+ GroovySystem.metaClassRegistry.removeMetaClass(JFileChooser)
+ }
+ }
+}
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..1f49898b90
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy
@@ -0,0 +1,556 @@
+/*
+ * 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.console.ui.text.SmartDocumentFilter
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.junit.jupiter.api.Test
+
+import javax.swing.JMenuItem
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+import javax.swing.text.AttributeSet
+import javax.swing.text.BadLocationException
+import javax.swing.text.DefaultHighlighter
+import javax.swing.text.DefaultStyledDocument
+import javax.swing.text.Highlighter
+import java.awt.Color
+import java.awt.Point
+import java.awt.event.ActionEvent
+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 hostCompilerConfigurationImportsAreVisible() {
+ def configuration = new CompilerConfiguration()
+ def imports = new ImportCustomizer()
+ imports.addImports('java.util.concurrent.ConcurrentHashMap')
+ configuration.addCompilationCustomizers(imports)
+ def pane = new JTextPane()
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader, configuration)
+ try {
+ def hits = host.compileAndDiagnostics('ConcurrentHashMap m\n')
+ assert !hits.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ host.setCompilerConfiguration(null)
+ def without = host.compileAndDiagnostics('ConcurrentHashMap m\n')
+ assert without.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ host.setCompilerConfiguration(configuration)
+ def again = host.compileAndDiagnostics('ConcurrentHashMap m\n')
+ assert !again.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ } 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) == 2
+ 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 GroovyLanguageEngine.positionOf('ab\r\ncd', 4) == [1, 0] as int[]
+ assert GroovyLanguageEngine.positionOf('ab\rcd', 3) == [1, 0] as int[]
+ assert GroovyLanguageEngine.offsetOf('ab\r\ncd', 1, 0) == 4
+ assert GroovyLanguageEngine.offsetOf('ab\rcd', 1, 0) == 3
+ assert GroovyLanguageEngine.flattenSnippet(null) == ''
+ assert GroovyLanguageEngine.flattenSnippet('') == ''
+ assert GroovyLanguageEngine.flattenSnippet('length') == 'length'
+ assert GroovyLanguageEngine.flattenSnippet('class ${1:Name} {\n $0\n}\n') == 'class Name {\n \n}\n'
+ assert GroovyLanguageEngine.flattenSnippet('$0') == ''
+ assert GroovyLanguageEngine.flattenSnippet('${1:foo}$2') == 'foo'
+ assert ConsoleLanguageHost.BUFFER_URI.toString() == 'groovy-buffer:console'
+ }
+
+ @Test
+ void highlightSpanClampsInvertedAndZeroWidth() {
+ assert GroovyLanguageEngine.spanOf('ab\ncd', 0, 2, 0, 0) == [2, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('x', 0, 0, 0, 0) == [0, 1] as int[]
+ assert GroovyLanguageEngine.spanOf('', 0, 0, 0, 0) == [0, 0] as int[]
+ assert GroovyLanguageEngine.spanOf(null, 0, 0, 0, 0) == [0, 0] as int[]
+ assert GroovyLanguageEngine.spanOf('ab', 0, 0, 0, 2) == [0, 2] as int[]
+ assert GroovyLanguageEngine.spanOf('abc', 0, 3, 0, 3) == [2, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('abc', 9, 0, 9, 0) == [2, 3] as int[]
+ def hit = new GroovyLanguageEngine.Hit(URI.create('groovy-buffer:t'), 0, 2, 0, 0, 'x', 'Error')
+ assert GroovyLanguageEngine.spanOf('ab\ncd', hit) == [2, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('ab', null as GroovyLanguageEngine.Hit) == [0, 0] 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.ALT_DOWN_MASK, KeyEvent.VK_SLASH, '/' as char))
+ pane.text = 'class Ok {}\n'
+ host.refresh()
+ assert pane.highlighter.highlights.length == 0
+ assert pane.toolTipText == null
+ pane.highlighter = new DefaultHighlighter() {
+ @Override
+ Object addHighlight(int p0, int p1, Highlighter.HighlightPainter p) throws BadLocationException {
+ throw new BadLocationException('test', p0)
+ }
+ }
+ pane.text = 'class Broken { def x = }\n'
+ host.refresh()
+ } 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.ALT_DOWN_MASK, KeyEvent.VK_SLASH, '/' 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 refreshPaintsDiagnosticsOnASmartFilterDocument() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.documentFilter = filter
+ def pane = new JTextPane()
+ pane.document = doc
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ pane.text = 'class Broken { def x = }\n'
+ host.refresh()
+ assert pane.highlighter.highlights.length > 0
+ assert filter.isLatest()
+ assert filter.latestTokenList.any { it.text == 'class' }
+ } 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()
+ def candidate = new GroovyLanguageEngine.Candidate('xyz', 'method', '', 'xyz', '')
+ host.completionMenu([new GroovyLanguageEngine.Candidate(null, 'method', '', 'insert', '')])
+ host.acceptCompletion(new GroovyLanguageEngine.Candidate('x', 'method', '', null, ''))
+ def menu = host.completionMenu([candidate])
+ assert menu.componentCount == 1
+ def item = (JMenuItem) menu.getComponent(0)
+ item.actionListeners.each { it.actionPerformed(new ActionEvent(item, 0, '')) }
+ host.acceptCompletion(candidate)
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void closedHostIgnoresRefreshBindAndSchedule() {
+ def pane = new JTextPane()
+ pane.text = 'class Broken { def x = }\n'
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ Thread.currentThread().interrupt()
+ host.close()
+ assert Thread.interrupted()
+ host.refresh()
+ host.bind(ConsoleLanguageHostTest.classLoader, null)
+ host.setParentLoader(ConsoleLanguageHostTest.classLoader)
+ host.completeAtCaret()
+ host.close()
+ }
+
+ @Test
+ void debounceSchedulePaintsAndDropsStaleGenerations() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.documentFilter = filter
+ def pane = new JTextPane()
+ pane.document = doc
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ pane.text = 'class Broken { def x = }\n'
+ Thread.sleep(50)
+ pane.text = 'class Also { def y = }\n'
+ Thread.sleep(250)
+ SwingUtilities.invokeAndWait {}
+ assert filter.isLatest()
+
+ pane.dispatchEvent(new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+ InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH, KeyEvent.CHAR_UNDEFINED,
+ KeyEvent.KEY_LOCATION_STANDARD))
+ pane.keyListeners.each {
+ it.keyPressed(new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+ InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH, KeyEvent.CHAR_UNDEFINED,
+ KeyEvent.KEY_LOCATION_STANDARD))
+ }
+ Thread.sleep(50)
+ SwingUtilities.invokeAndWait {}
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void refreshPaintsDiagnosticsWhenTheDocumentIsNotASmartFilter() {
+ def pane = new JTextPane()
+ pane.document = new DefaultStyledDocument()
+ pane.text = 'class Broken { def x = }\n'
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ host.refresh()
+ assert pane.highlighter.highlights.length > 0
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void closeInterruptsWhileAHighlightIsRunning() {
+ def pane = new JTextPane()
+ def src = new StringBuilder('class Slow {\n')
+ 200.times { src.append(" def m${it}() { ${it} }\n") }
+ src.append('}\n')
+ pane.text = src.toString()
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ pane.text = src.toString() + '\n'
+ Thread.sleep(160)
+ Thread.currentThread().interrupt()
+ host.close()
+ assert Thread.interrupted()
+ } finally {
+ host.close()
+ }
+ }
+
+ @Test
+ void completionChordIsAltSlashAndLeavesCtrlSpaceForTheInputMethod() {
+ def pane = new JTextPane()
+ pane.text = 'name'
+ pane.caretPosition = 2
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ def altSlash = pressed(pane, InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH)
+ firePressed(pane, altSlash)
+ assert altSlash.consumed
+ def typedSlash = typed(pane, InputEvent.ALT_DOWN_MASK, '/' as char)
+ fireTyped(pane, typedSlash)
+ assert typedSlash.consumed
+ assert pane.text == 'name'
+
+ assert !pressedAndFired(pane, InputEvent.CTRL_DOWN_MASK, KeyEvent.VK_SPACE).consumed
+ assert !pressedAndFired(pane, InputEvent.CTRL_DOWN_MASK, KeyEvent.VK_SLASH).consumed
+ assert !pressedAndFired(pane, InputEvent.ALT_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK, KeyEvent.VK_SLASH).consumed
+ assert !pressedAndFired(pane, InputEvent.ALT_DOWN_MASK | InputEvent.CTRL_DOWN_MASK, KeyEvent.VK_SLASH).consumed
+ assert !pressedAndFired(pane, InputEvent.ALT_DOWN_MASK | InputEvent.META_DOWN_MASK, KeyEvent.VK_SLASH).consumed
+ assert !pressedAndFired(pane, InputEvent.ALT_DOWN_MASK | InputEvent.ALT_GRAPH_DOWN_MASK, KeyEvent.VK_SLASH).consumed
+ assert !pressedAndFired(pane, 0, KeyEvent.VK_SLASH).consumed
+
+ def plainTyped = typed(pane, 0, '/' as char)
+ fireTyped(pane, plainTyped)
+ assert !plainTyped.consumed
+
+ // A non-slash release leaves the pending swallow in place.
+ firePressed(pane, pressed(pane, InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH))
+ fireReleased(pane, released(pane, 0, KeyEvent.VK_A))
+ def stillSwallowed = typed(pane, InputEvent.ALT_DOWN_MASK, '/' as char)
+ fireTyped(pane, stillSwallowed)
+ assert stillSwallowed.consumed
+
+ // Releasing slash clears a swallow that never saw keyTyped.
+ firePressed(pane, pressed(pane, InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH))
+ fireReleased(pane, released(pane, InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH))
+ def afterRelease = typed(pane, 0, 'z' as char)
+ fireTyped(pane, afterRelease)
+ assert !afterRelease.consumed
+ } finally {
+ host.close()
+ }
+ }
+
+ private static KeyEvent pressed(JTextPane pane, int modifiers, int keyCode) {
+ new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+ modifiers, keyCode, KeyEvent.CHAR_UNDEFINED)
+ }
+
+ private static KeyEvent released(JTextPane pane, int modifiers, int keyCode) {
+ new KeyEvent(pane, KeyEvent.KEY_RELEASED, System.currentTimeMillis(),
+ modifiers, keyCode, KeyEvent.CHAR_UNDEFINED)
+ }
+
+ private static KeyEvent typed(JTextPane pane, int modifiers, char keyChar) {
+ new KeyEvent(pane, KeyEvent.KEY_TYPED, System.currentTimeMillis(),
+ modifiers, KeyEvent.VK_UNDEFINED, keyChar)
+ }
+
+ private static void firePressed(JTextPane pane, KeyEvent event) {
+ pane.keyListeners.each { it.keyPressed(event) }
+ }
+
+ private static void fireReleased(JTextPane pane, KeyEvent event) {
+ pane.keyListeners.each { it.keyReleased(event) }
+ }
+
+ private static void fireTyped(JTextPane pane, KeyEvent event) {
+ pane.keyListeners.each { it.keyTyped(event) }
+ }
+
+ private static KeyEvent pressedAndFired(JTextPane pane, int modifiers, int keyCode) {
+ def event = pressed(pane, modifiers, keyCode)
+ firePressed(pane, event)
+ event
+ }
+
+ @Test
+ void guardsCoverClosedScheduleStaleWorkAndBadHighlights() {
+ def pane = new JTextPane()
+ pane.text = 'name'
+ pane.caretPosition = 2
+ def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ pane.dispatchEvent(new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+ 0, KeyEvent.VK_A, 'a' as char))
+ pane.dispatchEvent(new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+ 0, KeyEvent.VK_SPACE, ' ' as char))
+
+ host.@closed = true
+ pane.text = 'later'
+ host.@closed = false
+
+ def highlight = ConsoleLanguageHost.getDeclaredMethod('runHighlight', int, String)
+ highlight.accessible = true
+ highlight.invoke(host, -1, 'class A {}')
+ def current = host.@generation.get()
+ highlight.invoke(host, current, 'class Broken { def x = }')
+ host.@closed = true
+ SwingUtilities.invokeAndWait {}
+ host.@closed = false
+
+ def complete = ConsoleLanguageHost.getDeclaredMethod('runCompletions', int, String, int, int)
+ complete.accessible = true
+ complete.invoke(host, -1, 'name', 0, 0)
+ complete.invoke(host, current, 'name', 0, 2)
+ host.@closed = true
+ SwingUtilities.invokeAndWait {}
+ host.@closed = false
+ host.@engine.close()
+ highlight.invoke(host, current, 'class A {}')
+ complete.invoke(host, current, 'name', 0, 2)
+
+ def paint = ConsoleLanguageHost.getDeclaredMethod('paint', String, List)
+ paint.accessible = true
+ paint.invoke(host, ['name', []] as Object[])
+ def add = ConsoleLanguageHost.getDeclaredMethod('addErrorHighlight', Highlighter, int, int,
+ DefaultHighlighter.DefaultHighlightPainter)
+ add.accessible = true
+ def painter = new DefaultHighlighter.DefaultHighlightPainter(Color.RED)
+ def throwing = new DefaultHighlighter() {
+ @Override
+ Object addHighlight(int p0, int p1, HighlightPainter p) throws BadLocationException {
+ throw new BadLocationException('test', p0)
+ }
+ }
+ assert add.invoke(host, [throwing, 50, 60, painter] as Object[]) == null
+
+ def show = ConsoleLanguageHost.getDeclaredMethod('showMenu', List)
+ show.accessible = true
+ show.invoke(host, [[]] as Object[])
+ def bare = ConsoleLanguageHost.attach(null, null)
+ try {
+ show.invoke(bare, [[new GroovyLanguageEngine.Candidate('x', 'method', '', 'x', '')]] as Object[])
+ } finally {
+ bare.close()
+ }
+
+ def menuHost = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+ try {
+ pane.caret.magicCaretPosition = new Point(3, 4)
+ show.invoke(menuHost, [[new GroovyLanguageEngine.Candidate('x', 'method', '', 'x', '')]] as Object[])
+ pane.caret.magicCaretPosition = null
+ show.invoke(menuHost, [[new GroovyLanguageEngine.Candidate('y', 'method', '', 'y', '')]] as Object[])
+ } finally {
+ menuHost.close()
+ }
+
+ def broken = new JTextPane()
+ broken.document = new DefaultStyledDocument() {
+ @Override
+ void insertString(int offs, String str, AttributeSet a) throws BadLocationException {
+ throw new BadLocationException('test', offs)
+ }
+ }
+ ConsoleLanguageHost.insertAtCaret(broken, 'ab')
+ ConsoleLanguageHost.insertAtCaret(broken, '.z')
+ } finally {
+ host.@closed = true
+ host.@debounce?.shutdownNow()
+ }
+ }
+}
diff --git a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/SmartDocumentFilterTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/SmartDocumentFilterTest.groovy
index ec18a13695..9fbc651f7f 100644
--- a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/SmartDocumentFilterTest.groovy
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/SmartDocumentFilterTest.groovy
@@ -18,9 +18,17 @@
*/
package groovy.console.ui.text
+import groovy.console.ui.ThemeManager
+import org.antlr.v4.runtime.CommonToken
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
+import org.apache.groovy.parser.antlr4.GroovyLexer
import org.junit.jupiter.api.Test
+import javax.swing.text.BadLocationException
import javax.swing.text.DefaultStyledDocument
+import javax.swing.text.StyleConstants
+import javax.swing.text.StyleContext
+import java.awt.Color
class SmartDocumentFilterTest {
@@ -80,9 +88,11 @@ class SmartDocumentFilterTest {
doc.insertString(0, 'def x = 42', null)
- // Verify document has content and no exception thrown during parse
assert doc.getLength() == 10
assert doc.getText(0, doc.getLength()) == 'def x = 42'
+ assert fg(doc, 0) == themeFg('reserved_word')
+ assert fg(doc, 4) == themeFg('ident')
+ assert fg(doc, 8) == themeFg('digit')
}
@Test
@@ -132,6 +142,7 @@ class SmartDocumentFilterTest {
def tokens = filter.getLatestTokenList()
assert tokens != null
assert !tokens.isEmpty()
+ assert tokens.any { it.type == GroovyLexer.DEF }
}
@Test
@@ -149,12 +160,68 @@ class SmartDocumentFilterTest {
}
@Test
- void testHighlightedTokenTypeListContainsKeywords() {
- def list = SmartDocumentFilter.HIGHLIGHTED_TOKEN_TYPE_LIST
- assert list != null
- assert !list.isEmpty()
- // list should contain token type integers (all > 0)
- assert list.every { it > 0 }
+ void testMultilineStringThemeFollowsTheOpeningDelimiter() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+
+ doc.insertString(0, "def a = '''\nline\n'''\n", null)
+ String text = doc.getText(0, doc.length)
+ assert fg(doc, text.indexOf('line')) == themeFg('single_quotes')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def b = """\nline\n"""\n', null)
+ text = doc.getText(0, doc.length)
+ assert fg(doc, text.indexOf('line')) == themeFg('quotes')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def c = /\nline\n/\n', null)
+ text = doc.getText(0, doc.length)
+ assert fg(doc, text.indexOf('line')) == themeFg('slashy_quotes')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def d = """\nn:${x}\n"""\n', null)
+ text = doc.getText(0, doc.length)
+ assert fg(doc, text.indexOf('n:')) == themeFg('quotes')
+ assert fg(doc, text.indexOf('$')) != themeFg('quotes')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def x = 1\n/*\nblock\n*/\n', null)
+ text = doc.getText(0, doc.length)
+ assert fg(doc, text.indexOf('block')) == themeFg('comment')
+ }
+
+ @Test
+ void testStringCommentAnnotationAndOperationStyles() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+
+ doc.insertString(0, "def a = 'x'\ndef b = \"y\"\ndef c = /z/\n// hi", null)
+ String text = doc.getText(0, doc.length)
+ assert fg(doc, text.indexOf("'x'")) == themeFg('single_quotes')
+ assert fg(doc, text.indexOf('"y"')) == themeFg('quotes')
+ assert fg(doc, text.indexOf('/z/')) == themeFg('slashy_quotes')
+ assert fg(doc, text.indexOf('// hi')) == themeFg('comment')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, '@Foo.Bar x, y;', null)
+ assert fg(doc, 0) == themeFg('annotation')
+ assert fg(doc, 1) == themeFg('ident')
+ assert fg(doc, 10) == themeFg('operation')
+ assert fg(doc, 13) == themeFg('operation')
+ }
+
+ @Test
+ void testIncompleteInputStillTokenizes() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+
+ doc.insertString(0, 'def x =', null)
+
+ assert filter.isLatest()
+ assert filter.getLatestTokenList().any { it.text == 'def' }
}
@Test
@@ -206,4 +273,272 @@ class Example {
assert doc.getText(0, doc.getLength()) == 'ac'
}
+
+ @Test
+ void testGStringDollarSlashyAndNonIdentifierAnnotationExtras() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+
+ doc.insertString(0, 'def s = "n:${x}"', null)
+ String gstring = doc.getText(0, doc.length)
+ int dollar = gstring.lastIndexOf('$')
+ assert dollar > 0
+ assert fg(doc, gstring.indexOf('"n:')) == themeFg('quotes')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def s = "foo$', null)
+ assert filter.isLatest()
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def s = $/z/$', null)
+ String slashy = doc.getText(0, doc.length)
+ assert fg(doc, slashy.indexOf('$/')) == themeFg('slashy_quotes')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, '@1 @Foo.Bar x', null)
+ assert fg(doc, 0) == themeFg('annotation')
+ assert fg(doc, 1) != themeFg('annotation')
+ assert fg(doc, 3) == themeFg('annotation')
+ }
+
+ @Test
+ void testRenderRangeAndIncrementalParseKeepExistingStyles() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+
+ doc.insertString(0, 'def x = 1', null)
+ assert fg(doc, 0) == themeFg('reserved_word')
+
+ filter.setRenderRange(Tuple.tuple(-1, 4))
+ doc.insertString(doc.length, '\ndef y = 2', null)
+ assert fg(doc, 0) == themeFg('reserved_word')
+
+ filter.setRenderRange(Tuple.tuple(9999, 10000))
+ doc.insertString(doc.length, '\ndef z = 3', null)
+ assert filter.isLatest()
+
+ filter.setRenderRange(Tuple.tuple(0, -1))
+ doc.insertString(doc.length, '\ndef u = 6', null)
+ assert filter.isLatest()
+
+ filter.setRenderRange(Tuple.tuple(0, 3))
+ doc.insertString(doc.length, '\ndef w = 4', null)
+ assert fg(doc, 0) == themeFg('reserved_word')
+
+ doc.remove(0, doc.length)
+ doc.insertString(0, 'def x = 1\n/*\nblock\n*/\ndef y = 2', null)
+ int comment = doc.getText(0, doc.length).indexOf('/*')
+ filter.setRenderRange(Tuple.tuple(0, comment + 1))
+ doc.insertString(doc.length, '\ndef t = 7', null)
+ assert filter.isLatest()
+
+ doc.insertString(doc.length, '\ndef v = 5', null)
+ assert fg(doc, 0) == themeFg('reserved_word')
+
+ doc.remove(0, doc.length)
+ assert doc.length == 0
+ assert filter.isLatest()
+ }
+
+ @Test
+ void testApplyThemeStyleNullAndBackgroundUnderline() {
+ SmartDocumentFilter.applyThemeStyle(null, 'ident')
+ new SmartDocumentFilter(new DefaultStyledDocument())
+ def file = File.createTempFile('groovy-console-ident', '.theme')
+ file.deleteOnExit()
+ file.text = '''
+ident = #000000, underline on #FFFF00
+comment = #5D5D5D
+quotes = #7C007C
+single_quotes = #007C00
+slashy_quotes = #B28C00
+digit = #B20000
+annotation = #808000
+operation = bold
+reserved_word = #00007C, bold
+'''
+ def oldPath = ThemeManager.customLightPath
+ def oldMode = ThemeManager.currentMode
+ try {
+ ThemeManager.customLightPath = file.absolutePath
+ ThemeManager.applyTheme(ThemeManager.ThemeMode.LIGHT)
+ SmartDocumentFilter.updateStyles()
+ def identStyle = StyleContext.defaultStyleContext.getStyle('ident')
+ assert StyleConstants.getBackground(identStyle) == Color.YELLOW
+ assert StyleConstants.isUnderline(identStyle)
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+ doc.insertString(0, 'name', null)
+ def style = doc.getCharacterElement(0).attributes
+ assert style.getAttribute(StyleConstants.Background) == Color.YELLOW
+ assert style.getAttribute(StyleConstants.Underline)
+ } finally {
+ ThemeManager.customLightPath = oldPath
+ ThemeManager.applyTheme(oldMode)
+ SmartDocumentFilter.updateStyles()
+ file.delete()
+ }
+ def sc = StyleContext.defaultStyleContext
+ def missing = sc.addStyle('smart-doc-missing', null)
+ sc.removeStyle('smart-doc-missing')
+ SmartDocumentFilter.applyThemeStyle(sc.getStyle('smart-doc-missing'), 'ident')
+ assert missing != null
+ }
+
+ @Test
+ void testLatestTokenListStillHasBraceTokens() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+ doc.insertString(0, 'def c = { 1 }', null)
+ assert filter.getLatestTokenList().any { it.type == GroovyLexer.LBRACE }
+ assert filter.getLatestTokenList().any { it.type == GroovyLexer.RBRACE }
+ }
+
+ @Test
+ void testRenderRangePaintsOnlyTheRequestedSpan() {
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+ doc.insertString(0, 'def x = 1', null)
+ assert fg(doc, 0) == themeFg('reserved_word')
+
+ filter.setRenderRange(Tuple.tuple(0, 2))
+ doc.insertString(doc.length, '\ndef y = 2', null)
+ assert fg(doc, 0) == themeFg('reserved_word')
+ assert filter.isLatest()
+ }
+
+ @Test
+ void testReparseToleratesBadLocation() {
+ def doc = new BleDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+ doc.insertString(0, 'def x = 1', null)
+ doc.fail = true
+ filter.reparseDocument()
+ assert doc.fail
+ }
+
+ @Test
+ void testMissingThemeStyleFallsBackToDefault() {
+ def sc = StyleContext.defaultStyleContext
+ def doc = new DefaultStyledDocument()
+ def filter = new SmartDocumentFilter(doc)
+ doc.setDocumentFilter(filter)
+ sc.removeStyle('ident')
+ try {
+ doc.insertString(0, 'name', null)
+ assert doc.getCharacterElement(0).attributes != null
+ } finally {
+ new SmartDocumentFilter(new DefaultStyledDocument())
+ SmartDocumentFilter.updateStyles()
+ }
+ }
+
+ @Test
+ void testStringThemeHandlesMissingAndPartialTokens() {
+ def theme = SmartDocumentFilter.getDeclaredMethod('stringTheme', List, int)
+ def delim = SmartDocumentFilter.getDeclaredMethod('themeOfDelimiter', String)
+ def cover = SmartDocumentFilter.getDeclaredMethod('indexCovering', List, int)
+ def unstyle = SmartDocumentFilter.getDeclaredMethod('unstyleGStringDollar', List, int, int)
+ [theme, delim, cover, unstyle].each { it.accessible = true }
+
+ assert theme.invoke(null, [[], 0] as Object[]) == 'single_quotes'
+ assert theme.invoke(null, [null, 0] as Object[]) == 'single_quotes'
+ def ident = token(GroovyLexer.Identifier, 'name', 0, 3)
+ assert theme.invoke(null, [[ident], 1] as Object[]) == 'quotes'
+ def part = token(GroovyLexer.GStringPart, 'mid$', 0, 3)
+ def begin = token(GroovyLexer.GStringBegin, '"', 0, 0)
+ assert theme.invoke(null, [[begin, part], 1] as Object[]) == 'quotes'
+ def end = token(GroovyLexer.GStringEnd, '"', 4, 4)
+ assert theme.invoke(null, [[begin, end], 4] as Object[]) == 'quotes'
+
+ assert delim.invoke(null, [null] as Object[]) == 'single_quotes'
+ assert delim.invoke(null, ['abc'] as Object[]) == 'single_quotes'
+ assert delim.invoke(null, ['/z/'] as Object[]) == 'slashy_quotes'
+ assert delim.invoke(null, ['$/z/$'] as Object[]) == 'slashy_quotes'
+ assert delim.invoke(null, ['"hi"'] as Object[]) == 'quotes'
+
+ assert cover.invoke(null, [null, 0] as Object[]) == -1
+ assert cover.invoke(null, [[], 0] as Object[]) == -1
+ assert cover.invoke(null, [[token(GroovyLexer.Identifier, 'x', 5, 5)], 0] as Object[]) == -1
+ assert cover.invoke(null, [[token(GroovyLexer.Identifier, 'y', -1, -1)], 0] as Object[]) == -1
+
+ def filter = new SmartDocumentFilter(new DefaultStyledDocument())
+ unstyle.invoke(filter, [null, 0, 1] as Object[])
+ unstyle.invoke(filter, [[], 0, 1] as Object[])
+ unstyle.invoke(filter, [[ident], 0, 2] as Object[])
+ def literal = GroovyLanguageEngine.lexerTokens("'ab'")
+ if (literal) {
+ unstyle.invoke(filter, [literal, literal[0].startIndex, literal[0].stopIndex + 1] as Object[])
+ }
+ def opened = GroovyLanguageEngine.lexerTokens('def s = """\nn:${x}\n"""\n')
+ def dollar = opened.find { it.type == GroovyLexer.GStringBegin || it.type == GroovyLexer.GStringPart }
+ if (dollar != null) {
+ unstyle.invoke(filter, [opened, dollar.stopIndex + 5, dollar.stopIndex + 6] as Object[])
+ }
+ }
+
+ @Test
+ void indexCoveringFindsATokenByItsStart() {
+ def cover = SmartDocumentFilter.getDeclaredMethod('indexCovering', List, int)
+ def theme = SmartDocumentFilter.getDeclaredMethod('stringTheme', List, int)
+ cover.accessible = true
+ theme.accessible = true
+
+ def tokens = (0..<32).collect { int i ->
+ token(GroovyLexer.Identifier, 'a', i * 2, i * 2)
+ }
+ assert cover.invoke(null, [tokens, 0] as Object[]) == 0
+ assert cover.invoke(null, [tokens, 16 * 2] as Object[]) == 16
+ assert cover.invoke(null, [tokens, 31 * 2] as Object[]) == 31
+ assert cover.invoke(null, [tokens, 1] as Object[]) == -1
+
+ def skipped = [
+ token(GroovyLexer.Identifier, 'x', -1, -1),
+ token(GroovyLexer.Identifier, 'y', 4, 6)
+ ]
+ assert cover.invoke(null, [skipped, 5] as Object[]) == 1
+
+ def slashy = [
+ token(GroovyLexer.Identifier, 'n', 0, 0),
+ token(GroovyLexer.GStringBegin, '$/z', 2, 4),
+ token(GroovyLexer.GStringEnd, '/$', 5, 6)
+ ]
+ assert cover.invoke(null, [slashy, 3] as Object[]) == 1
+ assert cover.invoke(null, [slashy, 5] as Object[]) == 2
+ assert cover.invoke(null, [slashy, 1] as Object[]) == -1
+ assert theme.invoke(null, [slashy, 5] as Object[]) == 'slashy_quotes'
+ }
+
+ private static CommonToken token(int type, String text, int start, int stop) {
+ def token = new CommonToken(type, text)
+ token.startIndex = start
+ token.stopIndex = stop
+ token
+ }
+
+ private static final class BleDocument extends DefaultStyledDocument {
+ boolean fail
+
+ @Override
+ String getText(int offset, int length) throws BadLocationException {
+ if (fail) {
+ throw new BadLocationException('test', offset)
+ }
+ super.getText(offset, length)
+ }
+ }
+
+ private static Object fg(DefaultStyledDocument doc, int offset) {
+ doc.getCharacterElement(offset).attributes.getAttribute(StyleConstants.Foreground)
+ }
+
+ private static Object themeFg(String key) {
+ ThemeManager.getStyleAttrs(key).foreground
+ }
}
diff --git a/subprojects/groovy-groovysh/ARCHITECTURE.md b/subprojects/groovy-groovysh/ARCHITECTURE.md
index be512c493c..3fa6985477 100644
--- a/subprojects/groovy-groovysh/ARCHITECTURE.md
+++ b/subprojects/groovy-groovysh/ARCHITECTURE.md
@@ -238,6 +238,27 @@ 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.
+ `GroovyEngine.getCompilerConfiguration()` is forwarded so embedder
+ import and AST customizers match evaluation. The session republishes
+ that configuration only when the instance changes. In-place edits are
+ not a groovysh path: the field is final, and interactive imports are
+ buffer snippets. Binding
+ names already in the REPL buffer come from the compiled AST
+ (`GroovyLanguageEngine.declaredNames`). Queries use the engine's
+ text overloads. Snippet insert text is flattened with
+ `flattenSnippetLine`. 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..fb2bc7f2da 100644
--- a/subprojects/groovy-groovysh/build.gradle
+++ b/subprojects/groovy-groovysh/build.gradle
@@ -33,6 +33,9 @@ dependencies {
implementation projects.groovyXml
implementation projects.groovyJson
implementation projects.groovyNio
+ // Direct implementation dependency. groovy-console does not re-export
+ // groovy-lsp, and LSP4J stays off this module's published API.
+ 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..124bdfe657 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(
@@ -368,7 +370,6 @@ class Main {
registries.addAll([extra, consoleEngine, builtins, groovy])
setCommandRegistries(registries.toArray(new CommandRegistry[0]))
addCompleter(scriptEngine.scriptCompleter)
- setScriptDescription(scriptEngine::scriptDescription)
renameLocal 'exit', '/exit'
renameLocal 'help', '/help'
invoke '/alias', '/x', '/exit'
@@ -376,6 +377,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 +478,8 @@ class Main {
} catch (Throwable t) {
t.printStackTrace()
return 1
+ } finally {
+ languageSession?.close()
}
return 0
}
@@ -492,6 +496,23 @@ class Main {
start(GroovyshOptions.builder().build(), args)
}
+ /**
+ * Attaches the in-process language session. groovysh depends on
+ * groovy-lsp, so the session is always constructed. The session reads
+ * the engine's compiler configuration. An import typed at the prompt
+ * is buffer text.
+ *
+ * @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..9b439cebea
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSession.groovy
@@ -0,0 +1,357 @@
+/*
+ * 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.codehaus.groovy.control.CompilerConfiguration
+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.
+ * The configuration is published when the session is constructed, and
+ * again only when that {@code CompilerConfiguration} instance changes.
+ * Interactive imports are buffer snippets, not customizer edits.
+ * 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
+ private CompilerConfiguration boundConfiguration
+
+ GroovyshLanguageSession(GroovyEngine groovy) {
+ this.groovy = groovy
+ this.engine = new GroovyLanguageEngine()
+ bindEngine()
+ 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) {
+ bindEngine()
+ engine.diagnostics(BUFFER_URI, compose(line ?: ''))
+ }
+
+ /**
+ * 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) {
+ bindEngine()
+ String current = line ?: ''
+ String script = compose(current)
+ int[] caret = caretInComposed(script, current, cursor)
+ engine.complete(BUFFER_URI, script, 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) {
+ String raw = line.line ?: ''
+ String head = line.head ?: ''
+ int cursor = Math.min(head.length() + 1, raw.length())
+ def desc = describeSignatures(signaturesFor(raw, cursor))
+ if (desc != null) {
+ return desc
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ try {
+ return groovy.scriptDescription(line)
+ } catch (Exception ignored) {
+ return null
+ }
+ }
+
+ /**
+ * Signatures at the end of {@code line}.
+ *
+ * @param line current input
+ * @return signatures, never {@code null}
+ */
+ @PackageScope
+ GroovyLanguageEngine.SignatureSet signaturesFor(String line) {
+ signaturesFor(line, -1)
+ }
+
+ /**
+ * Signatures 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 signatures, never {@code null}
+ */
+ @PackageScope
+ GroovyLanguageEngine.SignatureSet signaturesFor(String line, int cursor) {
+ bindEngine()
+ String current = line ?: ''
+ String script = compose(current)
+ int[] caret = caretInComposed(script, current, cursor)
+ engine.signatures(BUFFER_URI, script, caret[0], caret[1])
+ }
+
+ /**
+ * {@code compose} always appends {@code current} last, so a cursor into
+ * {@code current} maps by prefix length of the composed script.
+ */
+ private static int[] caretInComposed(String script, String current, int cursor) {
+ if (cursor < 0 || cursor > current.length()) {
+ return GroovyLanguageEngine.positionOf(script, script.length())
+ }
+ int currentStart = script.length() - current.length()
+ return GroovyLanguageEngine.positionOf(script, currentStart + cursor)
+ }
+
+ /**
+ * Builds a METHOD tail-tip from compiler signatures.
+ *
+ * @param set engine signatures
+ * @return description, or {@code null} when there are none
+ */
+ @PackageScope
+ static CmdDesc describeSignatures(GroovyLanguageEngine.SignatureSet set) {
+ if (set == null || !set.signatures()) {
+ return null
+ }
+ def desc = new CmdDesc()
+ desc.setMainDesc(set.signatures().collect { new AttributedString(it.label() ?: '') })
+ desc
+ }
+
+ @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
+ }
+ bindEngine()
+ namesCache = engine.declaredNames(BUFFER_URI, preamble)
+ namesPreamble = preamble
+ namesCache
+ }
+
+ private void bindEngine() {
+ engine.setParentLoader(groovy.classLoader)
+ CompilerConfiguration configuration = groovy.compilerConfiguration
+ // setHostConfiguration drops the compile cache. Publish only when
+ // the instance changes so a repeated completion can reuse it.
+ // In-place customizer edits are not a groovysh path: the engine
+ // configuration is final and set only at construction, interactive
+ // imports are buffer snippets, and Console republishes through
+ // bind() after it edits customizers.
+ if (configuration != boundConfiguration) {
+ boundConfiguration = configuration
+ engine.setHostConfiguration(configuration)
+ }
+ }
+
+ 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'
+ }
+
+ /**
+ * 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 = GroovyLanguageEngine.flattenSnippetLine(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..f41e05e776
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy
@@ -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.
+ */
+
+/**
+ * Compiler-backed groovysh session. The adapter stays in this tree and talks
+ * to groovy-lsp through {@code GroovyLanguageEngine} JDK records, not LSP4J
+ * types. The engine configuration is the one the shell was constructed
+ * with. Interactive imports are buffer text. 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..647f8fed20 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -38,6 +38,17 @@ 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. Embedder
+`CompilerConfiguration` customizers (imports, AST transforms) are those
+on the configuration the engine was constructed with, and they apply to
+that session as well as to evaluation. An import typed at the prompt is
+buffer text, so the next completion sees it as part of the script.
+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/GroovyshEmbeddingTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
index 1745eff57c..5eb6f9191b 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/GroovyshEmbeddingTest.groovy
@@ -22,6 +22,7 @@ import org.apache.groovy.groovysh.jline.GroovyEngine
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
import org.jline.reader.Candidate
+import org.jline.reader.Completer
import org.jline.reader.LineReader
import org.jline.reader.Parser
import org.jline.reader.impl.completer.AggregateCompleter
@@ -75,6 +76,27 @@ class GroovyshEmbeddingTest {
terminal?.close()
}
+ @Test
+ void startClosesAMissingSessionWhenSetupFails() {
+ def engine = new GroovyEngine() {
+ @Override
+ Completer getScriptCompleter() {
+ throw new IllegalStateException('completer')
+ }
+ }
+ def err = new ByteArrayOutputStream()
+ def old = System.err
+ System.err = new PrintStream(err)
+ int rc
+ try {
+ rc = Main.start(options().engine(engine).build(), '-e', '1')
+ } finally {
+ System.err = old
+ }
+ assert rc == 1
+ assert err.toString().contains('completer')
+ }
+
@Test
void startWithHelpAndVersionStillWork() {
assert Main.start('--help') == 0
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..eee31ed3a4
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy
@@ -0,0 +1,364 @@
+/*
+ * 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.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+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 diagnosticsHonourEngineCompilerConfiguration() {
+ def configuration = new CompilerConfiguration()
+ def imports = new ImportCustomizer()
+ imports.addImports('java.util.concurrent.ConcurrentHashMap')
+ configuration.addCompilationCustomizers(imports)
+ groovy = new GroovyEngine(configuration)
+ session = new GroovyshLanguageSession(groovy)
+ def hits = session.diagnosticsFor('ConcurrentHashMap m')
+ assert !hits.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ session.close()
+ groovy = new GroovyEngine()
+ session = new GroovyshLanguageSession(groovy)
+ def without = session.diagnosticsFor('ConcurrentHashMap m')
+ assert without.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ }
+
+ @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 GroovyLanguageEngine.flattenSnippetLine('class ${1:Name} {\n $0\n}') == 'class Name {'
+ assert GroovyLanguageEngine.flattenSnippetLine(null) == ''
+ assert GroovyLanguageEngine.flattenSnippetLine('') == ''
+ assert GroovyLanguageEngine.flattenSnippetLine('$0') == ''
+ assert GroovyLanguageEngine.flattenSnippetLine('${1:foo}$2bar') == 'foobar'
+ assert GroovyLanguageEngine.flattenSnippetLine('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
+ }
+
+ @Test
+ void scriptDescriptionUsesEngineSignaturesOnTheComposedLine() {
+ groovy = new GroovyEngine()
+ groovy.execute('''\
+ class Box {
+ static void ping(String name) {}
+ static void ping(String name, int count) {}
+ }
+ '''.stripIndent())
+ session = new GroovyshLanguageSession(groovy)
+ assert session.compose('Box.ping(').contains('class Box')
+ def described = session.scriptDescription(
+ new CmdLine('Box.ping(', 'Box.ping', '', ['Box'], CmdLine.DescriptionType.METHOD))
+ if (described?.mainDesc) {
+ assert described.mainDesc.any { it.toString().contains('ping') || it.toString().contains('Box') }
+ }
+ session.close()
+ groovy = new GroovyEngine()
+ session = new GroovyshLanguageSession(groovy)
+ def src = '''\
+ class Box {
+ static void ping(String name) {}
+ static void ping(String name, int count) {}
+ static void use() { Box.ping(1) }
+ }
+ '''.stripIndent()
+ int cursor = src.indexOf('Box.ping(') + 'Box.ping('.length()
+ def withArgs = session.signaturesFor(src, cursor)
+ assert withArgs.signatures().any { it.label()?.contains('ping') }
+ assert GroovyshLanguageSession.describeSignatures(null) == null
+ assert GroovyshLanguageSession.describeSignatures(new GroovyLanguageEngine.SignatureSet(0, 0, [])) == null
+ def labeled = GroovyshLanguageSession.describeSignatures(new GroovyLanguageEngine.SignatureSet(0, 0, [
+ new GroovyLanguageEngine.Signature('ping(String name)', '', ['name']),
+ new GroovyLanguageEngine.Signature(null, '', [])
+ ]))
+ assert labeled.mainDesc.size() == 2
+ assert labeled.mainDesc[0].toString().contains('ping')
+ assert session.signaturesFor('Box.ping(', -1) != null
+ assert session.signaturesFor('Box.ping(', 99) != 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')
+ assert session.diagnosticsFor(null) != null
+ assert session.completeLine(null) != null
+ assert session.scriptDescription(new CmdLine(null, '', null, [], CmdLine.DescriptionType.SYNTAX)) == null
+ session.scriptDescription(new CmdLine(null, null, null, [], CmdLine.DescriptionType.METHOD))
+ def candidates = []
+ session.complete(null, new Line(null), candidates)
+ session.compose(null)
+ groovy.put('again', 'x')
+ assert session.compose('1').contains('again')
+ }
+
+ 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 == null ? 0 : text.length())
+ }
+
+ Line(String text, int cursor) {
+ this.text = text
+ this.cursor = cursor
+ }
+
+ @Override
+ String word() { text }
+
+ @Override
+ int wordCursor() { text == null ? 0 : 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..e84fbfcbf1 100644
--- a/subprojects/groovy-lsp/AGENTS.md
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -37,8 +37,16 @@ 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`.
+Live highlighting consumes `lexicalTokens(text)` or
+`lexicalTokens(text, lexerTokens)` (legend type names, no compile; the
+two-argument form reuses a walk from `lexerTokens(text)`). Hosts do not
+call `GroovySourceTokens` or `GroovyKeywords`. `tokens(URI)` is
+the compiled overlay for hosts that distinguish name kinds; Console
+paints diagnostics only.
+Hosts do not import `GroovyLexer` constants or `SemanticTokensService`
+for colouring. Brace matching may still read ANTLR tokens.
+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..982f9f767f 100644
--- a/subprojects/groovy-lsp/ARCHITECTURE.md
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -189,9 +189,33 @@ call. Wrong edits are worse for an agent than a refused rename.
workspace folders, so constructing it never compiles a checkout root.
Completions, semantic tokens and signature help are URI queries on an
open buffer. Semantic tokens on that path are absolute spans; the
- protocol delta-encoding stays in `SemanticTokensService.encode`. `setParentLoader`, `setClasspath` and `putExtra` supply
- the parent `ClassLoader`, the compile classpath, and an opaque
- fingerprint input after that loader changes in place. Workspace symbol
+ protocol delta-encoding stays in `SemanticTokensService.encode`.
+ In-process highlighting reuse is that semantic-token product, not the
+ lexer. Hosts that cannot speak JSON-RPC call
+ `GroovyLanguageEngine.lexicalTokens(text)` (or `lexicalTokens(text, alreadyTokenized)`)
+ and paint `Token` records (legend type names, absolute UTF-16 spans).
+ A host that also needs the raw tokens calls `lexerTokens(text)` and passes
+ that list in. It does not call `GroovySourceTokens`. `keywords()` is the
+ lexer vocabulary for hosts. `lexicalTokens` is the live layer: server-owned
+ lenient classification
+ (`tokenizeLenient` + `lexicalType`), no compile, safe on the Swing EDT,
+ still paints incomplete input; identifiers are `variable`. The legend is
+ append-only and includes `operator` (`COMMA`/`SEMI`) and `decorator` (`AT`)
+ so Console does not keep a second ANTLR extras pass.
+ `tokens(URI)` is the compiled overlay (SEMANTIC_ANALYSIS) for hosts whose
+ theme distinguishes name kinds. Console's theme maps every name kind to
+ `ident`, so `ConsoleLanguageHost` debounce paints diagnostics only and
+ does not apply overlay. Brace matching remains a local lexer walk
+ (`getLatestTokenList()`). groovysh keeps nanorc.
+ `setParentLoader`, `setClasspath`, `setHostConfiguration` and `putExtra` supply
+ the parent `ClassLoader`, the compile classpath, Console/groovysh
+ `CompilerConfiguration` (import and AST customizers), and an opaque
+ fingerprint input. In-place `addURL` is visible because the fingerprint
+ includes that loader's URLs. `setHostConfiguration` drops the cached
+ compile on every call, including a repeat of the same instance, so hosts
+ call it when the configuration is replaced or edited and not before
+ every query. `updateBuffer` keeps the document version when the text is
+ unchanged, and the next query reuses the snapshot. Workspace symbol
search accepts CamelHumps (`ASF` → `AbstractServerFactory`) as well
as a case-insensitive substring. Hover Groovydoc is Markdown
(`{@code}`, `{@link}`, `@param`).
@@ -215,9 +239,15 @@ call. Wrong edits are worse for an agent than a refused rename.
supports it. References skip files that do not contain the
identifier as a word.
- **Compile cache.** A fingerprint of open buffers, extra-file mtimes,
- settings and parent-loader identity skips a full recompile when
- nothing changed. In-place `addURL` on the same loader still needs
- `putExtra`. Compile
+ settings, parent-loader identity and URLs, host
+ `CompilerConfiguration` identity, script base class, and each
+ compilation customizer's class and identity skips a full recompile
+ when nothing changed. Editing a customizer in place does not change
+ that key. `updateBuffer` does not bump the document version
+ when the text is unchanged, so that key can match.
+ `setHostConfiguration` drops the cached key even for the same
+ instance, which is how an in-place customizer edit is compiled.
+ Compile
runs off the session lock so document sync is not blocked; a
generation counter drops superseded compiles. When the client omits
classpath and source paths, `WorkspaceLayout` uses `src/main/groovy`
@@ -245,7 +275,11 @@ call. Wrong edits are worse for an agent than a refused rename.
- **Semantic tokens.** The advertised legend is append-only. The
service collects absolute spans. `encode` delta-encodes them for
the protocol. Lexer tokens (`GroovySourceTokens`) fill `keyword` /
- `string` / `number` / `comment` even when the AST is missing.
+ `string` / `number` / `comment` / `variable` even when the AST is
+ missing. Identifiers are `variable` until a declaration or use-site
+ overlay replaces the same span (`dedupeSameSpan` keeps
+ `declaration`). Live highlighters call `lexicalTokenSpans` (lenient);
+ compiled `tokenSpans` still uses a fail-closed `tokenize`.
Declarations and use-site method/property names overlay from the
module. Multiline comments and triple-quoted strings are split per
line (`multilineTokenSupport` is not advertised).
@@ -280,8 +314,16 @@ 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. Hosts call `diagnostics(uri, text)` and the
+matching query-with-text overloads rather than `updateBuffer` plus a
+second call. Snippet flattening and UTF-16 span math live on the
+engine (`flattenSnippet`, `spanOf`). 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..94ec259d44 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
@@ -31,6 +31,8 @@ import org.apache.groovy.lsp.internal.protocol.ClientFeatures;
import org.apache.groovy.lsp.internal.util.Uris;
import org.apache.groovy.lsp.internal.workspace.DocumentStore;
import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.control.customizers.CompilationCustomizer;
import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
import org.eclipse.lsp4j.WorkspaceEdit;
@@ -45,6 +47,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;
@@ -53,6 +57,7 @@ import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -70,6 +75,8 @@ public final class LanguageServerContext {
private final WorkspaceScanner scanner = new WorkspaceScanner();
private final DiagnosticConverter diagnostics = new DiagnosticConverter();
private final Object compileLock = new Object();
+ /** Serializes diagnostic notifications so an older snapshot cannot overwrite a newer one. */
+ private final Object publishLock = new Object();
private final AtomicReference<LanguageClient> client = new AtomicReference<>();
private final AtomicReference<ClientCapabilities> clientCapabilities = new AtomicReference<>();
@@ -77,6 +84,7 @@ public final class LanguageServerContext {
private final AtomicReference<CompilerSettings> settings = new AtomicReference<>(CompilerSettings.defaults());
private final AtomicReference<CompilationSnapshot> snapshot = new AtomicReference<>(CompilationSnapshot.EMPTY);
private final AtomicReference<ClassLoader> parentLoader = new AtomicReference<>();
+ private final AtomicReference<CompilerConfiguration> hostConfiguration = new AtomicReference<>();
private volatile boolean initialized;
private volatile boolean shutdown;
private volatile Integer exitCode;
@@ -90,6 +98,9 @@ public final class LanguageServerContext {
});
private ScheduledFuture<?> pendingCompile;
private long compileGeneration;
+ /** Generation of the snapshot currently stored. Publishes at or below {@link #publishedGeneration} are dropped. */
+ private long snapshotGeneration;
+ private long publishedGeneration = -1;
private String compileFingerprint;
/**
@@ -128,7 +139,7 @@ public final class LanguageServerContext {
extra = scanner.scan(folders, effective.getSourcePaths(), effective.getIgnoredDirectories());
open = documents.snapshot();
loader = compileParentLoader();
- fingerprint = fingerprint(open, extra, effective, loader);
+ fingerprint = fingerprint(open, extra, effective, loader, hostConfiguration.get());
if (fingerprint.equals(compileFingerprint) && snapshot.get() != CompilationSnapshot.EMPTY) {
// Another compile already committed this snapshot but may
// not have published yet. Fall through to publishDiagnostics.
@@ -144,7 +155,7 @@ public final class LanguageServerContext {
beginProgress(token, "Compiling Groovy");
CompilationSnapshot compiled;
try {
- compiled = compiler.compile(open, extra, effective, loader);
+ compiled = compiler.compile(open, extra, effective, loader, hostConfiguration.get());
} finally {
endProgress(token);
}
@@ -152,10 +163,13 @@ public final class LanguageServerContext {
if (generation == compileGeneration) {
compileFingerprint = fingerprint;
snapshot.set(compiled);
+ snapshotGeneration = generation;
}
}
}
}
+ // A superseded compile still publishes whatever snapshot is current.
+ // publishDiagnostics drops that call when a newer snapshot was already sent.
publishDiagnostics();
return snapshot.get();
}
@@ -178,32 +192,46 @@ public final class LanguageServerContext {
/**
* Publishes diagnostics for every compiled document and clears
* diagnostics for URIs that dropped out of the snapshot.
+ * Notifications are sent in snapshot order. A publish that observed
+ * an older snapshot does not overwrite a newer one, which is what
+ * happens when a debounced compile finishes after a later compile.
*/
public void publishDiagnostics() {
LanguageClient languageClient = client.get();
if (languageClient == null) {
return;
}
- List<CompiledDocument> documents;
- Set<URI> dropped;
+ final long generation;
+ final CompilationSnapshot committed;
synchronized (this) {
- documents = List.copyOf(snapshot.get().documents());
+ generation = snapshotGeneration;
+ committed = snapshot.get();
+ }
+ if (committed == null) {
+ return;
+ }
+ synchronized (publishLock) {
+ if (generation <= publishedGeneration) {
+ return;
+ }
+ List<CompiledDocument> documents = List.copyOf(committed.documents());
Set<URI> currentUris = new LinkedHashSet<>();
for (CompiledDocument document : documents) {
if (document.getUri() != null) {
currentUris.add(document.getUri());
}
}
- dropped = new LinkedHashSet<>(publishedDiagnosticUris);
+ Set<URI> dropped = new LinkedHashSet<>(publishedDiagnosticUris);
dropped.removeAll(currentUris);
publishedDiagnosticUris.clear();
publishedDiagnosticUris.addAll(currentUris);
- }
- for (URI uri : dropped) {
- languageClient.publishDiagnostics(new PublishDiagnosticsParams(uri.toString(), List.of()));
- }
- for (CompiledDocument document : documents) {
- languageClient.publishDiagnostics(paramsFor(document));
+ publishedGeneration = generation;
+ for (URI uri : dropped) {
+ languageClient.publishDiagnostics(new PublishDiagnosticsParams(uri.toString(), List.of()));
+ }
+ for (CompiledDocument document : documents) {
+ languageClient.publishDiagnostics(paramsFor(document));
+ }
}
}
@@ -401,8 +429,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}
*/
@@ -420,6 +449,27 @@ public final class LanguageServerContext {
return parentLoader.get();
}
+ /**
+ * Compiler configuration from Console or groovysh. {@code null} uses
+ * a fresh tooling configuration. Every call drops the cached compile,
+ * including a repeat with the same instance, so an in-place customizer
+ * edit is visible on the next compile. Callers that query in a loop
+ * invoke this only when the configuration was replaced or edited.
+ *
+ * @param configuration host configuration, or {@code null}
+ */
+ public synchronized void setHostConfiguration(final CompilerConfiguration configuration) {
+ this.hostConfiguration.set(configuration);
+ compileFingerprint = null;
+ }
+
+ /**
+ * @return the host configuration, or {@code null}
+ */
+ public synchronized CompilerConfiguration getHostConfiguration() {
+ return hostConfiguration.get();
+ }
+
private ClassLoader compileParentLoader() {
ClassLoader loader = parentLoader.get();
return loader == null ? getClass().getClassLoader() : loader;
@@ -497,8 +547,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
@@ -508,6 +559,23 @@ public final class LanguageServerContext {
*/
public static String fingerprint(final Collection<TextDocument> open, final Collection<Path> extra,
final CompilerSettings settings, final ClassLoader parentLoader) {
+ return fingerprint(open, extra, settings, parentLoader, null);
+ }
+
+ /**
+ * Stable key of the settings, parent-loader identity,
+ * {@code URLClassLoader} URLs, host {@link CompilerConfiguration}
+ * identity, script base class, and each compilation customizer's class
+ * and identity. Editing a customizer in place does not change this key.
+ * {@link #setHostConfiguration} drops the cached compile so that edit
+ * is picked up.
+ *
+ * @param hostConfiguration console or groovysh configuration, or {@code null}
+ * @return a fingerprint
+ */
+ public static String fingerprint(final Collection<TextDocument> open, final Collection<Path> extra,
+ final CompilerSettings settings, final ClassLoader parentLoader,
+ final CompilerConfiguration hostConfiguration) {
StringBuilder builder = new StringBuilder();
builder.append(settings.getClasspath()).append('|')
.append(settings.getSourcePaths()).append('|')
@@ -516,7 +584,16 @@ public final class LanguageServerContext {
.append(settings.isAstTestEnabled()).append('|')
.append(settings.getThroughPhase()).append('|')
.append(settings.getExtra()).append('|')
- .append(System.identityHashCode(parentLoader));
+ .append(System.identityHashCode(parentLoader)).append('|');
+ appendHostConfiguration(builder, hostConfiguration);
+ 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(':')
@@ -539,4 +616,18 @@ public final class LanguageServerContext {
}
return builder.toString();
}
+
+ private static void appendHostConfiguration(final StringBuilder builder,
+ final CompilerConfiguration host) {
+ if (host == null) {
+ builder.append('0');
+ return;
+ }
+ builder.append(System.identityHashCode(host)).append('|')
+ .append(Objects.toString(host.getScriptBaseClass(), ""));
+ for (CompilationCustomizer customizer : host.getCompilationCustomizers()) {
+ builder.append('|').append(customizer.getClass().getName())
+ .append('@').append(System.identityHashCode(customizer));
+ }
+ }
}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
index 2c60e10ca4..f4b18b0630 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
@@ -20,6 +20,7 @@ package org.apache.groovy.lsp.internal.compile;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.Phases;
+import org.codehaus.groovy.control.customizers.CompilationCustomizer;
import java.io.File;
import java.net.MalformedURLException;
@@ -34,7 +35,6 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
-
/**
* Compilation options for the language server. Grape and {@code @ASTTest}
* are off by default so opening a workspace does not execute or download
@@ -241,7 +241,28 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
* @return the configuration
*/
public CompilerConfiguration toConfiguration() {
+ return toConfiguration(null);
+ }
+
+ /**
+ * Builds a tooling configuration, copying semantic customizers from
+ * {@code host} when present (imports, AST transforms, script base class).
+ * Grape and {@code @ASTTest} still follow these settings.
+ *
+ * @param host console or groovysh configuration, or {@code null}
+ * @return the configuration
+ */
+ public CompilerConfiguration toConfiguration(final CompilerConfiguration host) {
CompilerConfiguration configuration = new CompilerConfiguration();
+ if (host != null) {
+ if (host.getScriptBaseClass() != null) {
+ configuration.setScriptBaseClass(host.getScriptBaseClass());
+ }
+ List<CompilationCustomizer> customizers = host.getCompilationCustomizers();
+ if (!customizers.isEmpty()) {
+ configuration.addCompilationCustomizers(customizers.toArray(CompilationCustomizer[]::new));
+ }
+ }
configuration.setTolerance(0);
configuration.setTargetBytecode(CompilerConfiguration.JDK17);
if (!classpath.isEmpty()) {
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
index 1a626fc589..78cbdfc3bc 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
@@ -74,8 +74,21 @@ public final class GroovyCompiler implements AutoCloseable {
*/
public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
final CompilerSettings settings, final ClassLoader parentLoader) {
+ return compile(openDocuments, extraFiles, settings, parentLoader, null);
+ }
+
+ /**
+ * Compiles with an optional host {@link CompilerConfiguration} so import
+ * and AST customizers from Console or groovysh are visible.
+ *
+ * @param hostConfiguration console or groovysh configuration, or {@code null}
+ * @return an immutable snapshot
+ */
+ public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
+ final CompilerSettings settings, final ClassLoader parentLoader,
+ final CompilerConfiguration hostConfiguration) {
synchronized (this) {
- CompilerConfiguration configuration = settings.toConfiguration();
+ CompilerConfiguration configuration = settings.toConfiguration(hostConfiguration);
return withProcessFlags(settings, () ->
compileUnit(openDocuments, extraFiles, settings, parentLoader, configuration));
}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ScriptNames.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ScriptNames.java
new file mode 100644
index 0000000000..054f55446f
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ScriptNames.java
@@ -0,0 +1,143 @@
+/*
+ * 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.lsp.internal.compile;
+
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
+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.ClosureExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.LambdaExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.syntax.Types;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Script-level names a REPL host should not redeclare. A non-script class
+ * contributes its simple name when that name is non-empty. A script
+ * contributes methods other than the generated no-arg {@code run} and a
+ * static {@code main} whose single parameter is an array, plus names
+ * assigned in the {@code run} body. The walk enters {@code if} and
+ * {@code try}, and a tuple on the left of an assignment. It does not
+ * enter a closure or a lambda. An empty name is skipped.
+ */
+public final class ScriptNames {
+
+ private ScriptNames() {
+ }
+
+ /**
+ * @param module compiled module, or {@code null}
+ * @return names, never {@code null}
+ */
+ public static Set<String> of(final ModuleNode module) {
+ if (module == null) {
+ return Set.of();
+ }
+ Set<String> names = new LinkedHashSet<>();
+ 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;
+ }
+
+ 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;
+ }
+ if ("main".equals(name) && method.isStatic()) {
+ Parameter[] parameters = method.getParameters();
+ if (parameters.length == 1 && parameters[0].getType() != null && parameters[0].getType().isArray()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ static void addScriptAssignments(final ClassNode script, final Set<String> names) {
+ if (script == null || names == null) {
+ return;
+ }
+ MethodNode run = script.getMethod("run", Parameter.EMPTY_ARRAY);
+ if (run == null || run.getCode() == null) {
+ return;
+ }
+ run.getCode().visit(new CodeVisitorSupport() {
+ @Override
+ public void visitClosureExpression(final ClosureExpression expression) {
+ }
+
+ @Override
+ public void visitLambdaExpression(final LambdaExpression expression) {
+ }
+
+ @Override
+ public void visitDeclarationExpression(final DeclarationExpression expression) {
+ addLeftNames(expression.getLeftExpression(), names);
+ }
+
+ @Override
+ public void visitBinaryExpression(final BinaryExpression expression) {
+ if (expression.getOperation() != null && expression.getOperation().getType() == Types.ASSIGN) {
+ addLeftNames(expression.getLeftExpression(), names);
+ }
+ super.visitBinaryExpression(expression);
+ }
+ });
+ }
+
+ 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);
+ }
+ }
+ }
+}
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..fba8df0d1a 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
@@ -21,13 +21,17 @@ package org.apache.groovy.lsp.internal.engine;
import org.apache.groovy.lsp.internal.LanguageServerContext;
import org.apache.groovy.lsp.internal.compile.CompiledDocument;
import org.apache.groovy.lsp.internal.compile.CompilerSettings;
+import org.apache.groovy.lsp.internal.compile.ScriptNames;
import org.apache.groovy.lsp.internal.diagnostic.DiagnosticConverter;
import org.apache.groovy.lsp.internal.feature.LanguageFeatures;
import org.apache.groovy.lsp.internal.feature.SemanticTokensService;
import org.apache.groovy.lsp.internal.position.PositionEncoding;
import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.GroovyKeywords;
+import org.apache.groovy.lsp.internal.util.GroovySourceTokens;
import org.apache.groovy.lsp.internal.util.Uris;
import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.control.CompilerConfiguration;
import org.eclipse.lsp4j.CompletionItem;
import org.eclipse.lsp4j.CompletionList;
import org.eclipse.lsp4j.Diagnostic;
@@ -55,6 +59,8 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
/**
* In-process facade over the same compile snapshot the LSP handlers use.
@@ -64,11 +70,25 @@ import java.util.Objects;
* A non-{@code file:} URI keeps the compiler from treating the buffer as
* a path. The engine does not scan workspace folders and does not reserve
* buffer names.
+ * <p>
+ * Query methods that take the buffer text replace it first. The same text
+ * keeps the document version, so the snapshot can be reused.
+ * {@link #setHostConfiguration} drops that cache on every call. Hosts
+ * invoke it when the configuration is replaced or edited, not before
+ * every query. {@link #lexicalTokens(String)} is the live highlighter:
+ * no compile, safe on the Swing EDT. {@link #tokens(URI)} needs a
+ * compiled snapshot.
*/
public final class GroovyLanguageEngine implements AutoCloseable {
private static final int SNIPPET_CONTEXT_LINES = 3;
private static final String LANGUAGE_GROOVY = "groovy";
+ private static final Pattern SNIPPET_PLACEHOLDER = Pattern.compile("\\$\\{\\d+:([^}]*)}");
+ private static final Pattern SNIPPET_TABSTOP = Pattern.compile("\\$\\d+");
+ /**
+ * Semantic-token legend names. Same list the protocol advertises.
+ */
+ public static final List<String> TOKEN_TYPES = SemanticTokensService.TOKEN_TYPES;
private final LanguageServerContext context;
private final LanguageFeatures features;
@@ -100,7 +120,7 @@ public final class GroovyLanguageEngine implements AutoCloseable {
/**
* @return the session this engine drives
*/
- public LanguageServerContext context() {
+ LanguageServerContext context() {
return context;
}
@@ -150,21 +170,25 @@ public final class GroovyLanguageEngine implements AutoCloseable {
}
/**
- * Replaces buffer text without compiling. The next query compiles when
- * the open version does not match the snapshot; {@link #compileNow()}
- * and {@link #scheduleCompile(long)} remain available for callers that
- * debounce.
+ * Replaces buffer text without compiling. The same text keeps the
+ * current version, so the next query can reuse the snapshot.
+ * {@link #compileNow()} and {@link #scheduleCompile(long)} remain
+ * available for callers that debounce.
*
* @param uri buffer identity
- * @param text source text
+ * @param text source text, or {@code null} for empty
*/
public void updateBuffer(final URI uri, final String text) {
URI normalized = requireUri(uri);
TextDocument existing = context.getDocuments().get(normalized);
+ String next = text == null ? "" : text;
+ if (existing != null && next.equals(existing.getText())) {
+ return;
+ }
int version = existing == null ? 1 : existing.getVersion() + 1;
- String language = existing == null || existing.getLanguageId() == null ? LANGUAGE_GROOVY : existing.getLanguageId();
- context.getDocuments().open(new TextDocumentItem(normalized.toString(), language, version,
- text == null ? "" : text));
+ String language = existing == null || existing.getLanguageId() == null || existing.getLanguageId().isEmpty()
+ ? LANGUAGE_GROOVY : existing.getLanguageId();
+ context.getDocuments().open(new TextDocumentItem(normalized.toString(), language, version, next));
}
/**
@@ -197,7 +221,9 @@ public final class GroovyLanguageEngine implements AutoCloseable {
}
/**
- * Parent loader for the next compile.
+ * Parent loader for the next compile. A different loader drops the
+ * cached compile. An in-place {@code URLClassLoader#addURL} stays
+ * visible because the fingerprint includes that loader's URLs.
*
* @param parentLoader loader, or {@code null} for the session default
*/
@@ -205,6 +231,19 @@ public final class GroovyLanguageEngine implements AutoCloseable {
context.setParentLoader(parentLoader);
}
+ /**
+ * Compiler configuration from Console or groovysh. Import customizers
+ * and AST transforms are copied into the tooling compile. Every call
+ * drops the cached compile, including a repeat with the same instance,
+ * so an in-place customizer edit is visible on the next query. Do not
+ * call this before every query.
+ *
+ * @param configuration host configuration, or {@code null}
+ */
+ public void setHostConfiguration(final CompilerConfiguration configuration) {
+ context.setHostConfiguration(configuration);
+ }
+
/**
* Replaces the compile classpath. Empty or {@code null} clears it.
*
@@ -215,9 +254,9 @@ public final class GroovyLanguageEngine implements AutoCloseable {
}
/**
- * Stores an opaque extra setting so the compile fingerprint changes
- * after a parent loader is mutated in place. Loader identity alone
- * does not see those mutations.
+ * Stores an opaque extra setting that participates in the compile
+ * fingerprint. In-place {@code addURL} is already visible through
+ * the loader URL list.
*
* @param key setting name
* @param value setting value, or {@code null} to remove
@@ -254,6 +293,47 @@ public final class GroovyLanguageEngine implements AutoCloseable {
.toList();
}
+ /**
+ * Replaces the buffer text, then returns diagnostics.
+ *
+ * @param uri buffer identity
+ * @param text source text
+ * @return diagnostics, never {@code null}
+ */
+ public List<Hit> diagnostics(final URI uri, final String text) {
+ updateBuffer(uri, text);
+ return diagnostics(uri);
+ }
+
+ /**
+ * Script-level names the compiler already introduced in an open buffer.
+ * See {@link ScriptNames}. REPL hosts use this 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();
+ }
+ return ScriptNames.of(compiled.getModule());
+ }
+
+ /**
+ * Replaces the buffer text, then returns script-level names.
+ *
+ * @param uri buffer identity
+ * @param text source text
+ * @return names, never {@code null}
+ */
+ public Set<String> declaredNames(final URI uri, final String text) {
+ updateBuffer(uri, text);
+ return declaredNames(uri);
+ }
+
/**
* Hover markdown for an open buffer.
*
@@ -268,6 +348,20 @@ public final class GroovyLanguageEngine implements AutoCloseable {
new Position(line, character), encoding());
}
+ /**
+ * Replaces the buffer text, then returns hover markdown.
+ *
+ * @param uri buffer identity
+ * @param text source text
+ * @param line 0-based line
+ * @param character 0-based character
+ * @return markdown, never {@code null}
+ */
+ public String describe(final URI uri, final String text, final int line, final int character) {
+ updateBuffer(uri, text);
+ return describe(uri, line, character);
+ }
+
/**
* Definition targets in an open buffer.
*
@@ -355,8 +449,7 @@ public final class GroovyLanguageEngine implements AutoCloseable {
TextDocument document = documentAt(uri);
WorkspaceEdit edit = features.rename().rename(document, context.getSnapshot(),
new Position(line, character), newName, encoding());
- Map<String, List<TextEdit>> changes = edit == null || edit.getChanges() == null
- ? Map.of() : edit.getChanges();
+ Map<String, List<TextEdit>> changes = edit.getChanges();
List<Change> result = new ArrayList<>();
int count = 0;
for (Map.Entry<String, List<TextEdit>> entry : changes.entrySet()) {
@@ -384,17 +477,76 @@ public final class GroovyLanguageEngine implements AutoCloseable {
TextDocument document = documentAt(uri);
CompletionList list = features.completions().complete(document, context.getSnapshot(),
new Position(line, character), encoding());
- if (list == null || list.getItems() == null) {
- return List.of();
- }
- return list.getItems().stream()
+ List<CompletionItem> items = list.getItems();
+ return items.stream()
.filter(Objects::nonNull)
.map(GroovyLanguageEngine::toCandidate)
.toList();
}
+ /**
+ * Replaces the buffer text, then returns completions.
+ *
+ * @param uri buffer identity
+ * @param text source text
+ * @param line 0-based line
+ * @param character 0-based character
+ * @return candidates, never {@code null}
+ */
+ public List<Candidate> complete(final URI uri, final String text, final int line, final int character) {
+ updateBuffer(uri, text);
+ return complete(uri, line, character);
+ }
+
+ /**
+ * Lenient lexical semantic tokens. No session and no compile, so a live
+ * highlighter can call this on the EDT. Incomplete input still tokenizes.
+ * Identifiers are {@code variable} until {@link #tokens(URI)} overlays
+ * declaration and use-site kinds.
+ *
+ * @param text source text
+ * @return tokens, never {@code null}
+ */
+ public static List<Token> lexicalTokens(final String text) {
+ return lexicalTokens(text, lexerTokens(text));
+ }
+
+ /**
+ * Lenient lexer walk behind {@link #lexicalTokens(String)}. Hosts that
+ * also need raw tokens (brace matching, string delimiters) call this
+ * once and pass the list to {@link #lexicalTokens(String, List)}.
+ *
+ * @param text source text
+ * @return ANTLR tokens, never {@code null}
+ */
+ public static List<org.antlr.v4.runtime.Token> lexerTokens(final String text) {
+ return GroovySourceTokens.tokenizeLenient(text);
+ }
+
+ /**
+ * Sorted lexer keywords plus primitive type names, {@code true},
+ * {@code false} and {@code null}.
+ *
+ * @return keywords, never {@code null}
+ */
+ public static List<String> keywords() {
+ return GroovyKeywords.KEYWORDS;
+ }
+
+ /**
+ * Lenient lexical semantic tokens from an already-tokenized walk.
+ *
+ * @param text source text
+ * @param lexerTokens ANTLR tokens, possibly {@code null}
+ * @return tokens, never {@code null}
+ */
+ public static List<Token> lexicalTokens(final String text, final List<org.antlr.v4.runtime.Token> lexerTokens) {
+ return toEngineTokens(SemanticTokensService.lexicalTokenSpans(text, lexerTokens, PositionEncoding.UTF16));
+ }
+
/**
* Semantic tokens as absolute spans (not LSP delta encoding).
+ * Needs a compiled snapshot; do not call this on the Swing EDT.
*
* @param uri buffer identity
* @return tokens, never {@code null}
@@ -402,11 +554,17 @@ public final class GroovyLanguageEngine implements AutoCloseable {
public List<Token> tokens(final URI uri) {
TextDocument document = documentAt(uri);
CompiledDocument compiled = context.getSnapshot().get(document);
- List<Token> tokens = new ArrayList<>();
- for (SemanticTokensService.TokenSpan span : features.semanticTokens().tokenSpans(compiled, encoding())) {
+ if (compiled == null || compiled.getModule() == null) {
+ return List.of();
+ }
+ return toEngineTokens(features.semanticTokens().tokenSpans(compiled, encoding()));
+ }
+
+ private static List<Token> toEngineTokens(final List<SemanticTokensService.TokenSpan> spans) {
+ List<Token> tokens = new ArrayList<>(spans.size());
+ for (SemanticTokensService.TokenSpan span : spans) {
int type = span.type();
- String typeName = type >= 0 && type < SemanticTokensService.TOKEN_TYPES.size()
- ? SemanticTokensService.TOKEN_TYPES.get(type) : "";
+ String typeName = TOKEN_TYPES.get(type);
tokens.add(new Token(span.line(), span.character(), span.length(), typeName, span.modifiers()));
}
return tokens;
@@ -435,6 +593,20 @@ public final class GroovyLanguageEngine implements AutoCloseable {
return new SignatureSet(activeSignature, activeParameter, List.copyOf(signatures));
}
+ /**
+ * Replaces the buffer text, then returns signatures.
+ *
+ * @param uri buffer identity
+ * @param text source text
+ * @param line 0-based line
+ * @param character 0-based character
+ * @return signatures, never {@code null}
+ */
+ public SignatureSet signatures(final URI uri, final String text, final int line, final int character) {
+ updateBuffer(uri, text);
+ return signatures(uri, line, character);
+ }
+
/**
* Indent-only format of an open buffer.
*
@@ -480,10 +652,11 @@ public final class GroovyLanguageEngine implements AutoCloseable {
}
private void ensureFresh(final TextDocument document) {
- CompiledDocument compiled = context.getSnapshot().get(document);
- if (compiled == null || compiled.getVersion() != document.getVersion()) {
- context.recompile();
- }
+ Objects.requireNonNull(document);
+ // recompile() no-ops when the fingerprint matches the cached key.
+ // updateBuffer keeps that key when the text is unchanged.
+ // setHostConfiguration drops it.
+ context.recompile();
}
private static URI requireUri(final URI uri) {
@@ -493,6 +666,117 @@ public final class GroovyLanguageEngine implements AutoCloseable {
return Uris.normalize(uri);
}
+ /**
+ * Maps a UTF-16 document offset to a 0-based line and character.
+ * CR, LF, and CRLF are line breaks.
+ *
+ * @param text document text
+ * @param offset UTF-16 offset
+ * @return {@code {line, character}}
+ */
+ public static int[] positionOf(final String text, final int offset) {
+ return TextDocument.positionOf(text, offset);
+ }
+
+ /**
+ * Maps a 0-based line and character to a UTF-16 document offset.
+ * The character is clamped to the requested line. CR, LF, and CRLF
+ * are line breaks.
+ *
+ * @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) {
+ return TextDocument.offsetOf(text, new Position(Math.max(line, 0), Math.max(character, 0)),
+ PositionEncoding.UTF16);
+ }
+
+ /**
+ * Strips LSP snippet placeholders from completion insert text.
+ *
+ * @param insert snippet or plain insert text
+ * @return flattened text, never {@code null}
+ */
+ public static String flattenSnippet(final String insert) {
+ if (insert == null || insert.isEmpty()) {
+ return "";
+ }
+ return SNIPPET_TABSTOP.matcher(SNIPPET_PLACEHOLDER.matcher(insert).replaceAll("$1")).replaceAll("");
+ }
+
+ /**
+ * {@link #flattenSnippet(String)} truncated at the first newline.
+ * Line-oriented hosts (groovysh) use this.
+ *
+ * @param insert snippet or plain insert text
+ * @return one line, never {@code null}
+ */
+ public static String flattenSnippetLine(final String insert) {
+ String flattened = flattenSnippet(insert);
+ int nl = flattened.indexOf('\n');
+ return nl < 0 ? flattened : flattened.substring(0, nl);
+ }
+
+ /**
+ * Maps a diagnostic range onto UTF-16 offsets. A zero-width or inverted
+ * range becomes one character so a highlighter has something to paint.
+ * A range at EOF paints the last character of a non-empty document.
+ *
+ * @param text document text
+ * @param startLine 0-based start line
+ * @param startCharacter 0-based start character
+ * @param endLine 0-based end line
+ * @param endCharacter 0-based end character
+ * @return {@code {startOffset, endOffset}}
+ */
+ public static int[] spanOf(final String text, final int startLine, final int startCharacter,
+ final int endLine, final int endCharacter) {
+ int start = offsetOf(text, startLine, startCharacter);
+ int end = offsetOf(text, endLine, endCharacter);
+ int length = text == null ? 0 : text.length();
+ if (end <= start) {
+ if (length > 0 && start >= length) {
+ start = length - 1;
+ end = length;
+ } else {
+ end = Math.min(start + 1, length);
+ }
+ }
+ return new int[] {start, end};
+ }
+
+ /**
+ * @param text document text
+ * @param hit diagnostic
+ * @return {@code {startOffset, endOffset}}
+ */
+ public static int[] spanOf(final String text, final Hit hit) {
+ if (hit == null) {
+ return new int[] {0, 0};
+ }
+ return spanOf(text, hit.startLine(), hit.startCharacter(), hit.endLine(), hit.endCharacter());
+ }
+
+ /**
+ * Maps a semantic token onto UTF-16 offsets. Tokens are single-line
+ * (multiline comments and strings are already split).
+ *
+ * @param text document text
+ * @param token semantic token
+ * @return {@code {startOffset, endOffset}}
+ */
+ public static int[] spanOf(final String text, final Token token) {
+ if (token == null) {
+ return new int[] {0, 0};
+ }
+ int start = offsetOf(text, token.line(), token.character());
+ int length = text == null ? 0 : text.length();
+ int end = Math.min(start + Math.max(token.length(), 0), length);
+ return new int[] {start, end};
+ }
+
private PositionEncoding encoding() {
return context.getPositionEncoding();
}
@@ -520,7 +804,7 @@ public final class GroovyLanguageEngine implements AutoCloseable {
String insert = item.getInsertText() == null ? item.getLabel() : item.getInsertText();
return new Candidate(item.getLabel() == null ? "" : item.getLabel(), kind,
item.getDetail() == null ? "" : item.getDetail(),
- insert == null ? "" : insert,
+ flattenSnippet(insert),
markupOrString(item.getDocumentation()));
}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SemanticTokensService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SemanticTokensService.java
index 1ab6cb66df..26932e2368 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SemanticTokensService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SemanticTokensService.java
@@ -46,6 +46,7 @@ import org.eclipse.lsp4j.SemanticTokens;
import org.eclipse.lsp4j.SemanticTokensLegend;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
/**
@@ -55,7 +56,8 @@ public final class SemanticTokensService {
public static final List<String> TOKEN_TYPES = List.of(
"namespace", "class", "enum", "interface", "method", "property",
- "variable", "parameter", "keyword", "string", "number", "comment"
+ "variable", "parameter", "keyword", "string", "number", "comment",
+ "operator", "decorator"
);
public static final List<String> TOKEN_MODIFIERS = List.of("declaration", "static", "deprecated");
@@ -71,9 +73,20 @@ public final class SemanticTokensService {
private static final int TOKEN_STRING = 9;
private static final int TOKEN_NUMBER = 10;
private static final int TOKEN_COMMENT = 11;
+ private static final int TOKEN_OPERATOR = 12;
+ private static final int TOKEN_DECORATOR = 13;
private static final int MOD_DECLARATION = 1;
private static final int MOD_STATIC = 2;
private static final int MOD_DEPRECATED = 4;
+ private static final Comparator<TokenSpan> SPAN_ORDER = (left, right) -> {
+ if (left.line() != right.line()) {
+ return Integer.compare(left.line(), right.line());
+ }
+ if (left.character() != right.character()) {
+ return Integer.compare(left.character(), right.character());
+ }
+ return Integer.compare(right.length(), left.length());
+ };
/**
* One absolute semantic-token span. {@code line} and {@code character}
@@ -127,19 +140,48 @@ public final class SemanticTokensService {
addUseSiteTokens(tokens, document, encoding, range, module);
}
addLexicalTokens(document.getText(), tokens, encoding, range);
- tokens.sort((left, right) -> {
- if (left.line() != right.line()) {
- return Integer.compare(left.line(), right.line());
- }
- if (left.character() != right.character()) {
- return Integer.compare(left.character(), right.character());
- }
- return Integer.compare(right.length(), left.length());
- });
+ tokens.sort(SPAN_ORDER);
dedupeSameSpan(tokens);
return List.copyOf(tokens);
}
+ /**
+ * Lenient lexical spans for a live highlighter. Incomplete input still
+ * tokenizes. No AST and no compile. Identifiers are {@code variable}
+ * until a compiled overlay upgrades them.
+ *
+ * @param text document text
+ * @param encoding negotiated encoding
+ * @return spans, never {@code null}
+ */
+ public static List<TokenSpan> lexicalTokenSpans(final String text, final PositionEncoding encoding) {
+ if (text == null || text.isEmpty()) {
+ return List.of();
+ }
+ return lexicalTokenSpans(text, GroovySourceTokens.tokenizeLenient(text), encoding);
+ }
+
+ /**
+ * Lenient lexical spans from an already-tokenized walk so a live
+ * highlighter does not lex the buffer twice.
+ *
+ * @param text document text
+ * @param lexerTokens ANTLR tokens, possibly {@code null}
+ * @param encoding negotiated encoding
+ * @return spans, never {@code null}
+ */
+ public static List<TokenSpan> lexicalTokenSpans(final String text,
+ final List<Token> lexerTokens,
+ final PositionEncoding encoding) {
+ if (text == null || text.isEmpty() || lexerTokens == null || lexerTokens.isEmpty()) {
+ return List.of();
+ }
+ List<TokenSpan> tokens = new ArrayList<>();
+ addLexicalTokens(text, lexerTokens, tokens, encoding, null);
+ tokens.sort(SPAN_ORDER);
+ return List.copyOf(tokens);
+ }
+
/**
* Delta-encodes absolute spans the way LSP {@code semanticTokens} requires.
* A span that would move backwards is dropped.
@@ -237,7 +279,13 @@ public final class SemanticTokensService {
if (text == null || text.isEmpty()) {
return;
}
- for (Token token : GroovySourceTokens.tokenize(text)) {
+ addLexicalTokens(text, GroovySourceTokens.tokenize(text), tokens, encoding, range);
+ }
+
+ private static void addLexicalTokens(final String text, final List<Token> lexerTokens,
+ final List<TokenSpan> tokens, final PositionEncoding encoding,
+ final Range range) {
+ for (Token token : lexerTokens) {
if (token != null) {
int type = lexicalType(token);
if (type >= 0) {
@@ -263,10 +311,18 @@ public final class SemanticTokensService {
}
return -1;
}
- if (type == GroovyLexer.NOT_IN || type == GroovyLexer.NOT_INSTANCEOF
- || GroovyKeywords.isKeyword(token.getText())) {
+ if (type == GroovyLexer.AT) {
+ return TOKEN_DECORATOR;
+ }
+ if (type == GroovyLexer.COMMA || type == GroovyLexer.SEMI) {
+ return TOKEN_OPERATOR;
+ }
+ if (GroovyKeywords.isKeywordType(type)) {
return TOKEN_KEYWORD;
}
+ if (type == GroovyLexer.Identifier || type == GroovyLexer.CapitalizedIdentifier) {
+ return TOKEN_VARIABLE;
+ }
return -1;
}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
index 8019d187fc..020a0f1dab 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
@@ -50,6 +50,21 @@ public final class GroovyKeywords {
return word != null && KEYWORDS.contains(word);
}
+ /**
+ * @param type a {@link GroovyLexer} token type
+ * @return {@code true} when {@code type} is painted as a reserved word
+ */
+ public static boolean isKeywordType(final int type) {
+ return KEYWORD_TOKEN_TYPES.contains(type);
+ }
+
+ /**
+ * Lexer token types painted as reserved words: vocabulary word
+ * keywords, {@code !in}/{@code !instanceof}, primitives, {@code true},
+ * {@code false} and {@code null}.
+ */
+ public static final List<Integer> KEYWORD_TOKEN_TYPES = List.copyOf(keywordTokenTypes());
+
/**
* Declaration modifiers, using the lexer token types so the spelling
* still comes from the vocabulary.
@@ -63,6 +78,27 @@ public final class GroovyKeywords {
private GroovyKeywords() {
}
+ private static List<Integer> keywordTokenTypes() {
+ List<Integer> types = new ArrayList<>();
+ Vocabulary vocabulary = GroovyLexer.VOCABULARY;
+ for (int type = 1; type <= vocabulary.getMaxTokenType(); type++) {
+ if (isKeywordTokenType(vocabulary, type)) {
+ types.add(type);
+ }
+ }
+ return types;
+ }
+
+ private static boolean isKeywordTokenType(final Vocabulary vocabulary, final int type) {
+ if (type == GroovyLexer.NOT_IN || type == GroovyLexer.NOT_INSTANCEOF
+ || type == GroovyLexer.BuiltInPrimitiveType
+ || type == GroovyLexer.BooleanLiteral
+ || type == GroovyLexer.NullLiteral) {
+ return true;
+ }
+ return isWordKeyword(unquote(vocabulary.getLiteralName(type)));
+ }
+
private static TreeSet<String> keywords() {
TreeSet<String> words = new TreeSet<>();
Vocabulary vocabulary = GroovyLexer.VOCABULARY;
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java
index d960b9c971..ab81e52d53 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java
@@ -27,9 +27,10 @@ import java.util.ArrayList;
import java.util.List;
/**
- * Live {@link GroovyLexer} walks over a buffer. Used as a fail-closed
- * gate for on-type {@code '}'}', comment-based folds, and lexical
- * semantic tokens (string / number / comment).
+ * Live {@link GroovyLexer} walks over a buffer. Strict walks are a
+ * fail-closed gate for an on-type closing brace, comment-based folds, and
+ * lexical semantic tokens. Lenient walks ({@link #tokenizeLenient})
+ * keep tokenising incomplete input for a host highlighter.
*/
public final class GroovySourceTokens {
@@ -48,7 +49,7 @@ public final class GroovySourceTokens {
return null;
}
try {
- GroovyLexer lexer = lexer(text);
+ GroovyLexer lexer = lexer(text, false);
for (Token token = lexer.nextToken(); token.getType() != Token.EOF; token = lexer.nextToken()) {
if (token.getStartIndex() <= utf16Offset && utf16Offset <= token.getStopIndex()) {
return token;
@@ -82,30 +83,47 @@ public final class GroovySourceTokens {
}
/**
- * Full token walk including hidden-channel tokens.
+ * Full token walk including hidden-channel tokens. Lexical errors
+ * abort the walk so on-type formatting can refuse to guess.
*
* @param text document text
* @return tokens, never {@code null}
*/
public static List<Token> tokenize(final String text) {
+ return tokenize(text, false);
+ }
+
+ /**
+ * Token walk for a live highlighter. Incomplete input still yields
+ * tokens ({@code errorIgnored}).
+ *
+ * @param text document text
+ * @return tokens, never {@code null}
+ */
+ public static List<Token> tokenizeLenient(final String text) {
+ return tokenize(text, true);
+ }
+
+ private static List<Token> tokenize(final String text, final boolean errorIgnored) {
List<Token> tokens = new ArrayList<>();
if (text == null || text.isEmpty()) {
return tokens;
}
try {
- GroovyLexer lexer = lexer(text);
+ GroovyLexer lexer = lexer(text, errorIgnored);
for (Token token = lexer.nextToken(); token.getType() != Token.EOF; token = lexer.nextToken()) {
tokens.add(token);
}
} catch (GroovySyntaxError | RuntimeException ignored) {
- return List.of();
+ return errorIgnored ? tokens : List.of();
}
return tokens;
}
- private static GroovyLexer lexer(final String text) {
+ private static GroovyLexer lexer(final String text, final boolean errorIgnored) {
GroovyLexer lexer = new GroovyLexer(CharStreams.fromString(text));
lexer.removeErrorListeners();
+ lexer.setErrorIgnored(errorIgnored);
return lexer;
}
}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
index 99c628d0a4..0f7d0ce7f9 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
@@ -119,6 +119,40 @@ public record TextDocument(URI uri, String languageId, int version, String text)
return current.substring(0, start) + insert + current.substring(end);
}
+ /**
+ * Maps a UTF-16 document offset to a 0-based line and character.
+ * CR, LF, and CRLF are line breaks. The character is a UTF-16 unit
+ * count on that line.
+ *
+ * @param document full text
+ * @param offset UTF-16 offset
+ * @return {@code {line, character}}
+ */
+ public static int[] positionOf(final String document, final int offset) {
+ if (document == null || document.isEmpty() || offset <= 0) {
+ return new int[] {0, 0};
+ }
+ int capped = Math.min(offset, document.length());
+ int line = 0;
+ int character = 0;
+ for (int i = 0; i < capped; i++) {
+ char c = document.charAt(i);
+ if (c == '\n') {
+ line++;
+ character = 0;
+ } else if (c == '\r') {
+ if (i + 1 < document.length() && document.charAt(i + 1) == '\n') {
+ i++;
+ }
+ line++;
+ character = 0;
+ } else {
+ character++;
+ }
+ }
+ return new int[] {line, character};
+ }
+
/**
* Converts an LSP position to a UTF-16 offset in {@code document}.
*
diff --git a/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc b/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
index e1bfd93c67..ff2d62f441 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.
@@ -528,14 +528,27 @@ It does not append the `_inferred_` line that the protocol hover adds.
`symbols` matches workspace symbols, including CamelHumps.
`rename` returns edits and does not write them.
`format` and `organizeImports` also return edits.
-`tokens` returns absolute semantic-token spans for a host highlighter.
+`tokens` returns absolute compiled semantic-token spans for a host highlighter.
+Those spans need a compiled snapshot, so do not call `tokens(URI)` on the Swing EDT.
+A live editor highlighter calls `GroovyLanguageEngine.lexicalTokens(text)`, or `lexicalTokens(text, lexerTokens)` after `lexerTokens(text)` when it also needs the raw tokens — the same legend type names, with incomplete input still tokenized.
+Identifiers stay `variable` until a compiled `tokens(uri)` overlay replaces them.
`compileNow` compiles immediately.
`scheduleCompile` compiles after the delay you pass.
-`setParentLoader`, `setClasspath`, and `putExtra` are the session controls.
+`diagnostics`, `complete`, `describe`, `signatures`, and `declaredNames` each have a form that takes the buffer text, replaces it, then queries.
+The same text keeps the document version, so the snapshot is reused.
+`flattenSnippet` strips LSP snippet placeholders.
+`flattenSnippetLine` keeps the first line.
+`positionOf`, `offsetOf`, and `spanOf` convert 0-based UTF-16 positions.
+CR, LF, and CRLF are line breaks. A character past the end of a line stays on that line.
+A zero-width range at the end of the text paints the last character.
+`setParentLoader`, `setClasspath`, `setHostConfiguration`, 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.
+`setHostConfiguration` copies the script base class and compilation customizers from a Console or groovysh `CompilerConfiguration` into the tooling compile.
+Every call drops the cached compile, including a repeat with the same instance, so call it after an in-place customizer edit and not before every query.
`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/GroovyLanguageServerTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
index c5c5d2b489..a61016ffd4 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
@@ -60,9 +60,12 @@ import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
import org.apache.groovy.lsp.internal.compile.CompilationSnapshot
import org.apache.groovy.lsp.internal.feature.LanguageFeatures
import org.apache.groovy.lsp.internal.position.PositionEncoding
@@ -258,6 +261,74 @@ final class GroovyLanguageServerTest {
assert fixture.diagnostics[brokenUri] == []
}
+ @Test
+ void publishDiagnosticsSkipsANullSnapshot() {
+ fixture = new LspFixture()
+ int before = fixture.publishedParams.size()
+ def field = LanguageServerContext.getDeclaredField('snapshot')
+ field.accessible = true
+ (field.get(fixture.server.context) as AtomicReference).set(null)
+ fixture.server.context.publishDiagnostics()
+ assert fixture.publishedParams.size() == before
+ }
+
+ /**
+ * A compile that already observed a deleted file must not publish its
+ * diagnostics after a later compile has cleared that URI.
+ */
+ @Test
+ void staleCompileDoesNotRepublishDiagnosticsForARemovedFile() {
+ def entered = new CountDownLatch(1)
+ def release = new CountDownLatch(1)
+ def held = new AtomicBoolean()
+ def brokenUri = new AtomicReference<String>()
+ fixture = new LspFixture() {
+ @Override
+ void publishDiagnostics(PublishDiagnosticsParams params) {
+ if (params?.uri?.endsWith('Broken.groovy') && params.diagnostics
+ && held.compareAndSet(false, true)) {
+ brokenUri.set(params.uri)
+ entered.countDown()
+ assert release.await(10, TimeUnit.SECONDS)
+ }
+ super.publishDiagnostics(params)
+ }
+ }
+ def folder = Files.createTempDirectory('groovy-lsp-stale')
+ def broken = folder.resolve('Broken.groovy')
+ Files.writeString(broken, 'class Broken {')
+ def added = new WorkspaceFolder(folder.toUri().toString(), 'tmp')
+ def publisher = Thread.start {
+ fixture.server.workspaceService.didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(
+ new WorkspaceFoldersChangeEvent([added], [])))
+ fixture.server.context.recompile()
+ }
+ Thread clearer = null
+ try {
+ assert entered.await(10, TimeUnit.SECONDS)
+ Files.delete(broken)
+ fixture.server.workspaceService.didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(
+ new WorkspaceFoldersChangeEvent([], [added])))
+ clearer = Thread.start {
+ fixture.server.context.recompile()
+ }
+ // The held notification owns the publish lock. Give the clearer time
+ // to finish compiling and wait behind that lock before the hold ends.
+ Thread.sleep(300)
+ release.countDown()
+ publisher.join(10_000)
+ clearer.join(10_000)
+ assert !publisher.alive
+ assert !clearer.alive
+ assert fixture.diagnostics[brokenUri.get()] == []
+ } finally {
+ release.countDown()
+ publisher.join(10_000)
+ clearer?.join(10_000)
+ Files.deleteIfExists(broken)
+ }
+ }
+
@Test
void shutdownThenExitSetsZero() {
fixture = new LspFixture()
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/ScriptNamesTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/ScriptNamesTest.groovy
new file mode 100644
index 0000000000..da2b4e8f2d
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/ScriptNamesTest.groovy
@@ -0,0 +1,124 @@
+/*
+ * 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.lsp.internal.compile
+
+import org.codehaus.groovy.ast.CompileUnit
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.Parameter
+import org.codehaus.groovy.ast.expr.BinaryExpression
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.DeclarationExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.LambdaExpression
+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.EmptyStatement
+import org.codehaus.groovy.ast.stmt.ExpressionStatement
+import org.codehaus.groovy.syntax.Token
+import org.codehaus.groovy.syntax.Types
+import org.junit.jupiter.api.Test
+
+import static org.objectweb.asm.Opcodes.ACC_PUBLIC
+import static org.objectweb.asm.Opcodes.ACC_STATIC
+
+final class ScriptNamesTest {
+
+ @Test
+ void ofNullModuleIsEmpty() {
+ assert ScriptNames.of(null).isEmpty()
+ }
+
+ @Test
+ void scriptLevelMethodRejectsSyntheticAndGeneratedNames() {
+ assert !ScriptNames.scriptLevelMethod(null)
+ def synthetic = new MethodNode('ping', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null)
+ synthetic.synthetic = true
+ assert !ScriptNames.scriptLevelMethod(synthetic)
+ assert !ScriptNames.scriptLevelMethod(new MethodNode(null, ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ assert !ScriptNames.scriptLevelMethod(new MethodNode('', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ assert !ScriptNames.scriptLevelMethod(new MethodNode('<init>', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ assert !ScriptNames.scriptLevelMethod(new MethodNode('<clinit>', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ assert !ScriptNames.scriptLevelMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ assert ScriptNames.scriptLevelMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.VOID_TYPE,
+ [new Parameter(ClassHelper.OBJECT_TYPE, 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null))
+ assert ScriptNames.scriptLevelMethod(new MethodNode('main', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ def generatedMain = new MethodNode('main', ACC_PUBLIC | ACC_STATIC, ClassHelper.VOID_TYPE,
+ [new Parameter(ClassHelper.STRING_TYPE.makeArray(), 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null)
+ assert !ScriptNames.scriptLevelMethod(generatedMain)
+ assert ScriptNames.scriptLevelMethod(new MethodNode('main', ACC_PUBLIC | ACC_STATIC, ClassHelper.VOID_TYPE,
+ [new Parameter(null, 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null))
+ assert ScriptNames.scriptLevelMethod(new MethodNode('main', ACC_PUBLIC | ACC_STATIC, ClassHelper.VOID_TYPE,
+ [new Parameter(ClassHelper.STRING_TYPE, 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null))
+ }
+
+ @Test
+ void addScriptAssignmentsSkipsMissingRunAndNullCode() {
+ def collected = new LinkedHashSet<String>()
+ ScriptNames.addScriptAssignments(null, collected)
+ ScriptNames.addScriptAssignments(new ClassNode('NoRun', ACC_PUBLIC, ClassHelper.OBJECT_TYPE), collected)
+ assert collected.isEmpty()
+ def script = new ClassNode('EmptyRun', ACC_PUBLIC, ClassHelper.SCRIPT_TYPE)
+ script.addMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+ ScriptNames.addScriptAssignments(script, collected)
+ assert collected.isEmpty()
+ ScriptNames.addScriptAssignments(script, null)
+ }
+
+ @Test
+ void assignmentsVisitClosuresLambdasAndBlankNames() {
+ def names = new LinkedHashSet<String>()
+ def module = new ModuleNode((CompileUnit) null)
+ module.addClass(new ClassNode('', ACC_PUBLIC, ClassHelper.OBJECT_TYPE))
+ assert ScriptNames.of(module).isEmpty()
+
+ def script = new ClassNode('Walk', ACC_PUBLIC, ClassHelper.SCRIPT_TYPE)
+ def block = new BlockStatement()
+ block.addStatement(expression(assign('c', new ClosureExpression(Parameter.EMPTY_ARRAY, EmptyStatement.INSTANCE))))
+ block.addStatement(expression(assign('l', new LambdaExpression(Parameter.EMPTY_ARRAY, EmptyStatement.INSTANCE))))
+ block.addStatement(expression(new BinaryExpression(new ConstantExpression(1), Token.newSymbol(Types.PLUS, -1, -1), new ConstantExpression(2))))
+ def noOperation = new BinaryExpression(new VariableExpression('q'), Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)
+ BinaryExpression.getDeclaredField('operation').with {
+ accessible = true
+ set(noOperation, null)
+ }
+ block.addStatement(expression(noOperation))
+ block.addStatement(expression(new DeclarationExpression(new VariableExpression(''), Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)))
+ block.addStatement(expression(new DeclarationExpression(new VariableExpression((String) null), Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)))
+ block.addStatement(expression(new DeclarationExpression(new TupleExpression([new VariableExpression('t')]), Token.newSymbol(Types.ASSIGN, -1, -1), new TupleExpression())))
+ script.addMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, block))
+ ScriptNames.addScriptAssignments(script, names)
+ assert names.contains('c')
+ assert names.contains('l')
+ assert names.contains('t')
+ assert !names.contains('')
+ }
+
+ private static ExpressionStatement expression(Expression expression) {
+ new ExpressionStatement(expression)
+ }
+
+ private static BinaryExpression assign(String name, Expression right) {
+ new BinaryExpression(new VariableExpression(name), Token.newSymbol(Types.ASSIGN, -1, -1), right)
+ }
+}
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..f69cffe1c0 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
@@ -19,8 +19,16 @@
package org.apache.groovy.lsp.internal.engine
import org.apache.groovy.lsp.internal.LanguageServerContext
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot
import org.apache.groovy.lsp.internal.compile.CompilerSettings
import org.apache.groovy.lsp.internal.feature.LanguageFeatures
+import org.apache.groovy.lsp.internal.feature.SemanticTokensService
+import org.apache.groovy.lsp.internal.position.PositionEncoding
+import org.apache.groovy.lsp.internal.workspace.DocumentStore
+import org.apache.groovy.lsp.internal.workspace.TextDocument
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.eclipse.lsp4j.TextDocumentItem
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
@@ -120,6 +128,137 @@ 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
+ def host = new CompilerConfiguration()
+ def withHost = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, host)
+ def otherHost = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, new CompilerConfiguration())
+ assert withHost != otherHost
+ assert withHost == LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, host)
+ def mutated = new CompilerConfiguration()
+ def beforeCustomizers = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, mutated)
+ mutated.addCompilationCustomizers(new ImportCustomizer())
+ def afterCustomizers = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, mutated)
+ assert beforeCustomizers != afterCustomizers
+ }
+
+ @Test
+ void hostCompilerConfigurationImportsAreVisibleOnTheNextQuery() {
+ engine = new GroovyLanguageEngine()
+ engine.openBuffer(BUFFER, 'groovy', 'ConcurrentHashMap m\n')
+ def before = engine.diagnostics(BUFFER)
+ assert before.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ def configuration = new CompilerConfiguration()
+ def imports = new ImportCustomizer()
+ imports.addImports('java.util.concurrent.ConcurrentHashMap')
+ configuration.addCompilationCustomizers(imports)
+ engine.setHostConfiguration(configuration)
+ assert engine.context().hostConfiguration.is(configuration)
+ configuration.scriptBaseClass = 'groovy.lang.Script'
+ def copied = CompilerSettings.defaults().toConfiguration(configuration)
+ assert copied.compilationCustomizers.contains(imports)
+ assert copied.scriptBaseClass == 'groovy.lang.Script'
+ assert copied.tolerance == 0
+ def after = engine.diagnostics(BUFFER)
+ assert !after.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ engine.setHostConfiguration(null)
+ def reset = engine.diagnostics(BUFFER)
+ assert reset.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ }
+
+ @Test
+ void sameTextKeepsTheBufferVersion() {
+ engine = new GroovyLanguageEngine()
+ engine.openBuffer(BUFFER, 'groovy', 'class A {}')
+ int version = engine.context().documents.get(BUFFER).version
+ engine.updateBuffer(BUFFER, 'class A {}')
+ assert engine.context().documents.get(BUFFER).version == version
+ engine.diagnostics(BUFFER, 'class A {}')
+ assert engine.context().documents.get(BUFFER).version == version
+ engine.updateBuffer(BUFFER, '')
+ assert engine.context().documents.get(BUFFER).version == version + 1
+ engine.updateBuffer(BUFFER, null)
+ assert engine.context().documents.get(BUFFER).text == ''
+ assert engine.context().documents.get(BUFFER).version == version + 1
+ }
+
+ @Test
+ void repeatedHostConfigurationPublishesInPlaceImportEdits() {
+ engine = new GroovyLanguageEngine()
+ engine.openBuffer(BUFFER, 'groovy', 'ConcurrentHashMap m\n')
+ def configuration = new CompilerConfiguration()
+ def imports = new ImportCustomizer()
+ configuration.addCompilationCustomizers(imports)
+ engine.setHostConfiguration(configuration)
+ assert engine.diagnostics(BUFFER).any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ imports.addImports('java.util.concurrent.ConcurrentHashMap')
+ assert engine.diagnostics(BUFFER).any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ engine.setHostConfiguration(configuration)
+ assert !engine.diagnostics(BUFFER).any { (it.message() ?: '').contains('ConcurrentHashMap') }
+ }
+
+ @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 !names.contains('run')
+ assert !names.contains('main')
+ engine.updateBuffer(BUFFER, '''\
+ def (a, b) = [1, 2]
+ if (true) {
+ z = 3
+ }
+ try {
+ w = 4
+ } catch (Exception e) {
+ }
+ def skip = { hidden = 1 }
+ Runnable r = () -> { alsoHidden = 2 }
+ '''.stripIndent())
+ def destructured = engine.declaredNames(BUFFER)
+ assert destructured.contains('a')
+ assert destructured.contains('b')
+ assert destructured.contains('z')
+ assert destructured.contains('w')
+ assert !destructured.contains('hidden')
+ assert !destructured.contains('alsoHidden')
+ assert GroovyLanguageEngine.positionOf('ab\ncd', 4) == [1, 1] as int[]
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 1, 1) == 4
+ assert GroovyLanguageEngine.offsetOf('ab\ncd', 0, 99) == 2
+ assert GroovyLanguageEngine.positionOf('ab\r\ncd', 4) == [1, 0] as int[]
+ assert GroovyLanguageEngine.offsetOf('ab\r\ncd', 1, 0) == 4
+ assert GroovyLanguageEngine.positionOf(null, 1) == [0, 0] as int[]
+ assert GroovyLanguageEngine.offsetOf('', 0, 0) == 0
+ assert GroovyLanguageEngine.flattenSnippet('class ${1:Name} {\n $0\n}') == 'class Name {\n \n}'
+ assert GroovyLanguageEngine.flattenSnippetLine('class ${1:Name} {\n $0\n}') == 'class Name {'
+ assert GroovyLanguageEngine.spanOf('ab', 0, 0, 0, 0) == [0, 1] as int[]
+ assert GroovyLanguageEngine.spanOf('abc', 0, 3, 0, 3) == [2, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('abc', 9, 0, 9, 0) == [2, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('ab\r', 0, 3, 0, 3) == [2, 3] as int[]
+ engine.updateBuffer(BUFFER, 'class Broken { def x = }')
+ assert engine.diagnostics(BUFFER, 'class Broken { def x = }').any { it.message() }
+ assert engine.describe(BUFFER, 'class Sample {}', 0, 6).contains('Sample')
+ assert engine.complete(BUFFER, 'class Sample { def ping() { ping() } }', 0, 6) != null
+ assert engine.signatures(BUFFER, 'class Sample { def ping() { ping() } }', 0, 6) != null
+ assert engine.declaredNames(BUFFER, 'class Box {}').contains('Box')
}
@Test
@@ -151,6 +290,71 @@ final class GroovyLanguageEngineBufferTest {
assert set.signatures().any { it.label()?.contains('ping') } || set.signatures() != null
}
+ @Test
+ void lexicalTokensNeedNoSessionAndKeepIncompleteInput() {
+ assert GroovyLanguageEngine.lexicalTokens(null).isEmpty()
+ assert GroovyLanguageEngine.lexicalTokens('').isEmpty()
+ def tokens = GroovyLanguageEngine.lexicalTokens('def n = 1 // c')
+ assert tokens.any { it.type() == 'keyword' && it.length() == 3 }
+ assert tokens.any { it.type() == 'variable' }
+ assert tokens.any { it.type() == 'number' }
+ assert tokens.any { it.type() == 'comment' }
+ def incomplete = GroovyLanguageEngine.lexicalTokens('def x =')
+ assert incomplete.any { it.type() == 'keyword' }
+ assert incomplete.any { it.type() == 'variable' }
+ assert GroovyLanguageEngine.TOKEN_TYPES.contains('keyword')
+ assert GroovyLanguageEngine.spanOf('def n = 1', new GroovyLanguageEngine.Token(0, 0, 3, 'keyword', 0)) == [0, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('ab', null as GroovyLanguageEngine.Token) == [0, 0] as int[]
+ assert GroovyLanguageEngine.spanOf('ab', new GroovyLanguageEngine.Token(0, 0, -3, 'keyword', 0)) == [0, 0] as int[]
+ assert GroovyLanguageEngine.spanOf(null, new GroovyLanguageEngine.Token(0, 0, 3, 'keyword', 0)) == [0, 0] as int[]
+ assert GroovyLanguageEngine.flattenSnippet(null) == ''
+ assert GroovyLanguageEngine.flattenSnippet('') == ''
+ def hit = new GroovyLanguageEngine.Hit(URI.create('groovy-buffer:t'), 0, 2, 0, 0, 'x', 'Error')
+ assert GroovyLanguageEngine.spanOf('ab\ncd', hit) == [2, 3] as int[]
+ assert GroovyLanguageEngine.spanOf('ab', null as GroovyLanguageEngine.Hit) == [0, 0] as int[]
+ def slashy = GroovyLanguageEngine.lexicalTokens('def s = /z/\n$/y/$\n')
+ assert slashy.any { it.type() == 'string' }
+ def gstring = GroovyLanguageEngine.lexicalTokens('def s = "n:${x}"\n')
+ assert gstring.any { it.type() == 'string' }
+ }
+
+ @Test
+ void tokensWithTextCompileAndUpgradeClassNames() {
+ engine = new GroovyLanguageEngine()
+ engine.openBuffer(BUFFER, 'groovy', '')
+ def src = 'class Sample {}\n'
+ engine.updateBuffer(BUFFER, src)
+ def overlay = engine.tokens(BUFFER)
+ assert overlay.any { it.type() == 'class' && it.length() == 6 }
+ assert overlay.any { it.type() == 'keyword' }
+ engine.updateBuffer(BUFFER, '''\
+ class Box {
+ def ping(int n) { n }
+ }
+ '''.stripIndent())
+ def members = engine.tokens(BUFFER)
+ assert members.any { it.type() == 'method' }
+ assert members.any { it.type() == 'parameter' }
+ engine.updateBuffer(BUFFER, '')
+ engine.tokens(BUFFER)
+ engine.updateBuffer(BUFFER, 'package p\nimport java.util.List\nenum E { A }\ninterface I {}\n')
+ def javaish = engine.tokens(BUFFER)
+ assert javaish.any { it.type() == 'keyword' }
+ assert engine.declaredNames(BUFFER) != null
+ assert GroovyLanguageEngine.lexicalTokens('def x', null).isEmpty()
+ assert GroovyLanguageEngine.TOKEN_TYPES.contains('decorator')
+ assert GroovyLanguageEngine.TOKEN_TYPES.contains('operator')
+ }
+
+ @Test
+ void javaBufferWithoutAModuleHasNoCompiledTokens() {
+ engine = new GroovyLanguageEngine()
+ engine.openBuffer(BUFFER, 'java', 'class A { int n; }')
+ assert engine.context().snapshot.get(BUFFER).module == null
+ assert engine.declaredNames(BUFFER).isEmpty()
+ assert engine.tokens(BUFFER).isEmpty()
+ }
+
@Test
void constructorsTolerateNullContextAndFeatures() {
engine = new GroovyLanguageEngine(null, null)
@@ -269,4 +473,56 @@ final class GroovyLanguageEngineBufferTest {
assert renamed.changeCount() >= 1
assert renamed.changes().every { it.newText() == 'Renamed' }
}
+
+ @Test
+ void bufferEdgesAndHostFingerprintBranches() {
+ engine = new GroovyLanguageEngine()
+ def fresh = URI.create('groovy-buffer:fresh')
+ engine.updateBuffer(fresh, 'class Z {}')
+ assert engine.context().documents.get(fresh).version == 1
+ engine.context().documents.open(new TextDocumentItem(fresh.toString(), '', 3, 'class Z {}'))
+ engine.updateBuffer(fresh, 'class Y {}')
+ assert engine.context().documents.get(fresh).languageId == 'groovy'
+ def store = engine.context().documents
+ def documents = DocumentStore.getDeclaredField('documents')
+ documents.accessible = true
+ documents.get(store).put(fresh, new TextDocument(fresh, null, 8, 'class Y {}'))
+ engine.updateBuffer(fresh, 'class X {}')
+ assert engine.context().documents.get(fresh).languageId == 'groovy'
+
+ def bare = CompilerSettings.defaults().toConfiguration(new CompilerConfiguration())
+ assert bare.compilationCustomizers.empty
+ assert bare.scriptBaseClass == null
+
+ assert TextDocument.positionOf(null, 3) == [0, 0] as int[]
+ assert TextDocument.positionOf('', 4) == [0, 0] as int[]
+ assert TextDocument.positionOf('ab', -3) == [0, 0] as int[]
+ assert TextDocument.positionOf('ab\rcd', 3) == [1, 0] as int[]
+ assert TextDocument.positionOf('ab\r', 3) == [1, 0] as int[]
+ assert GroovyLanguageEngine.spanOf('', 0, 0, 0, 0) == [0, 0] as int[]
+ assert GroovyLanguageEngine.spanOf('ab', 0, 0, 0, 2) == [0, 2] as int[]
+ assert GroovyLanguageEngine.flattenSnippetLine('plain') == 'plain'
+
+ def nullList = new URLClassLoader(new URL[0], GroovyLanguageEngineBufferTest.classLoader) {
+ @Override
+ URL[] getURLs() { null }
+ }
+ def nullElement = new URLClassLoader(new URL[0], GroovyLanguageEngineBufferTest.classLoader) {
+ @Override
+ URL[] getURLs() { [null, new File('/tmp/lsp-edge').toURI().toURL()] as URL[] }
+ }
+ def missing = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), nullList)
+ def listed = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), nullElement)
+ assert missing != listed
+ assert listed.contains('file:')
+
+ assert SemanticTokensService.lexicalTokenSpans('def x', [], PositionEncoding.UTF16).isEmpty()
+ assert SemanticTokensService.lexicalTokenSpans('def x', [null], PositionEncoding.UTF16).isEmpty()
+
+ engine.openBuffer(BUFFER, 'groovy', 'class A {}')
+ engine.context().setSnapshot(CompilationSnapshot.EMPTY)
+ engine.context().setShutdown(true)
+ assert engine.declaredNames(BUFFER).isEmpty()
+ assert engine.tokens(BUFFER).isEmpty()
+ }
}
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
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SemanticTokensTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SemanticTokensTest.groovy
index 6a8b7ece71..6655bc6831 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SemanticTokensTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SemanticTokensTest.groovy
@@ -64,4 +64,38 @@ final class SemanticTokensTest {
int at = text.indexOf('; def') + 2
assert spans.any { it.line() == 0 && it.character() == at && it.length() == 3 && it.type() == keyword }
}
+
+ @Test
+ void lexicalTokenSpansStayLenientAndPaintIdentifiersAsVariable() {
+ assert SemanticTokensService.lexicalTokenSpans(null, UTF16).isEmpty()
+ assert SemanticTokensService.lexicalTokenSpans('', UTF16).isEmpty()
+ def incomplete = SemanticTokensService.lexicalTokenSpans('def x =', UTF16)
+ int keyword = SemanticTokensService.TOKEN_TYPES.indexOf('keyword')
+ int variable = SemanticTokensService.TOKEN_TYPES.indexOf('variable')
+ assert incomplete.any { it.type() == keyword }
+ assert incomplete.any { it.type() == variable }
+ def brokenNumeric = SemanticTokensService.lexicalTokenSpans('1_', UTF16)
+ assert brokenNumeric.any { it.length() > 0 }
+ def mixed = SemanticTokensService.lexicalTokenSpans('def n = 1 // c\ndef s = "x"\ndef t = /z/\n!in\n@Foo x, y;\n', UTF16)
+ int string = SemanticTokensService.TOKEN_TYPES.indexOf('string')
+ int number = SemanticTokensService.TOKEN_TYPES.indexOf('number')
+ int comment = SemanticTokensService.TOKEN_TYPES.indexOf('comment')
+ int decorator = SemanticTokensService.TOKEN_TYPES.indexOf('decorator')
+ int operator = SemanticTokensService.TOKEN_TYPES.indexOf('operator')
+ assert mixed.any { it.type() == string }
+ assert mixed.any { it.type() == number }
+ assert mixed.any { it.type() == comment }
+ assert mixed.any { it.type() == decorator }
+ assert mixed.any { it.type() == operator }
+ def block = SemanticTokensService.lexicalTokenSpans('/* a\nb */\n', UTF16)
+ assert block.any { it.type() == comment }
+ assert SemanticTokensService.lexicalTokenSpans('def x', null, UTF16).isEmpty()
+ assert SemanticTokensService.TOKEN_TYPES.indexOf('operator') == 12
+ assert SemanticTokensService.TOKEN_TYPES.indexOf('decorator') == 13
+ def compiled = new CompiledDocument(URI.create('file:///C.groovy'), 1, 'class C {}\n',
+ null, null, null)
+ // no AST: C is a lexical variable. With a module, declaration overlay wins.
+ def bare = new SemanticTokensService().tokenSpans(compiled, UTF16)
+ assert bare.any { it.type() == variable }
+ }
}
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SupportServicesTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SupportServicesTest.groovy
index 44c8107c52..4a4e99f1ec 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SupportServicesTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/SupportServicesTest.groovy
@@ -220,6 +220,7 @@ final class SupportServicesTest {
assert decoded.any { it.type == SemanticTokensService.TOKEN_TYPES.indexOf('number') }
assert decoded.any { it.type == SemanticTokensService.TOKEN_TYPES.indexOf('comment') }
assert decoded.any { it.type == SemanticTokensService.TOKEN_TYPES.indexOf('string') }
+ assert decoded.any { it.type == SemanticTokensService.TOKEN_TYPES.indexOf('variable') }
}
@Test
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovyKeywordsTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovyKeywordsTest.groovy
index 0c1808e304..3f44cd6f7d 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovyKeywordsTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovyKeywordsTest.groovy
@@ -18,6 +18,7 @@
*/
package org.apache.groovy.lsp.internal.util
+import org.apache.groovy.lsp.internal.engine.GroovyLanguageEngine
import org.apache.groovy.parser.antlr4.GroovyLexer
import org.junit.jupiter.api.Test
@@ -37,6 +38,7 @@ final class GroovyKeywordsTest {
assert !words.contains('==')
assert !words.contains('!in')
assert GroovyLexer.VOCABULARY.getLiteralName(GroovyLexer.AS).contains('as')
+ assert GroovyLanguageEngine.keywords().is(words)
}
@Test
@@ -61,6 +63,23 @@ final class GroovyKeywordsTest {
assert !GroovyKeywords.isKeyword('notAKeyword')
}
+ @Test
+ void keywordTokenTypesCoverVocabularyWordsAndSpecials() {
+ def types = GroovyKeywords.KEYWORD_TOKEN_TYPES
+ assert types.contains(GroovyLexer.DEF)
+ assert types.contains(GroovyLexer.CLASS)
+ assert types.contains(GroovyLexer.NOT_IN)
+ assert types.contains(GroovyLexer.NOT_INSTANCEOF)
+ assert types.contains(GroovyLexer.BuiltInPrimitiveType)
+ assert types.contains(GroovyLexer.BooleanLiteral)
+ assert types.contains(GroovyLexer.NullLiteral)
+ assert types.every { it > 0 }
+ assert types.size() == types.toSet().size()
+ assert GroovyKeywords.isKeywordType(GroovyLexer.DEF)
+ assert GroovyKeywords.isKeywordType(GroovyLexer.NOT_IN)
+ assert !GroovyKeywords.isKeywordType(GroovyLexer.Identifier)
+ }
+
@Test
void privateConstructorCanBeInvoked() {
[GroovyKeywords].each { Class type ->
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovySourceTokensTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovySourceTokensTest.groovy
index e4b15547b2..f8870dd464 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovySourceTokensTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/GroovySourceTokensTest.groovy
@@ -63,4 +63,16 @@ final class GroovySourceTokensTest {
assert GroovySourceTokens.tokenize('1_').isEmpty()
assert GroovySourceTokens.covering('1_', 0) == null
}
+
+ @Test
+ void tokenizeLenientKeepsTokensOnIncompleteInput() {
+ def tokens = GroovySourceTokens.tokenizeLenient('def x =')
+ assert tokens.any { it.text == 'def' }
+ assert GroovySourceTokens.tokenizeLenient('1_').any { it.text.contains('1') }
+ assert GroovySourceTokens.tokenizeLenient(null).isEmpty()
+ assert GroovySourceTokens.tokenizeLenient('').isEmpty()
+ def gstring = GroovySourceTokens.tokenizeLenient('"foo$')
+ assert gstring.any { it.text.contains('foo') || it.text.contains('"') }
+ assert GroovySourceTokens.tokenizeLenient('#!only') != null
+ }
}