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/23 17:29:53 UTC

(groovy) branch GROOVY-12448 updated (b7d739d0d8 -> 3911ce3ca1)

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


    omit b7d739d0d8 GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
     add bfdb983141 GROOVY-12426: @Delegate generates no methods returning a nested class of a joint-compiled Java source, and breaks other implementors of the interface
     add 0a09ca602e Bump io.github.ben-manes.versions to 0.66.0
     add f8a6ba2520 GROOVY-12437: type variable reference from bytecode/reflection is resolved
     add 4be16cef7c GROOVY-12465: Bump asm to 9.11.1
     add c227066191 GROOVY-12465: Bump asm to 9.11.1 (tweaks related to ASMs new size knobs)
     add 186d9e3887 update outdated Android doco
     add 29ccbe89f6 GROOVY-12465: Bump asm to 9.11.1 (also build-logic reference)
     add 6cb3262da5 bump gradle to 9.8.2
     add dcf5462908 GROOVY-12463: groovy-contracts: postcondition and class invariant not checked on a trailing implicit return when the method also has an explicit early return
     add b9a167467f GROOVY-12436: GroovyConsole key bindings lost after runtime theme switch on JDK27
     add 3096022662 GROOVY-12453: ArrayIndexOutOfBoundsException during compilation with an assert and map-constructor-call
     add 8778bce1a0 GROOVY-12451: A record with a boolean component x and a hand-written isX() gets no x() accessor
     add 7a6f67fc61 GROOVY-12460: PurityChecker: allow @Pure on closure parameters and locals, and accept closure-taking GDK methods with pure closures
     add 434b75d05f groovy-lsp: publishDiagnostics could return before a concurrent publisher cleared removed URIs
     add 03bcbbe8ff GROOVY-12470: SC: super-method calls do not work under static compilation
     add 61949271fc GROOVY-12476: STC: toString() on an interface-typed receiver selects DGM toString(Object) over the runtime override (regression from 4.x)
     add 1260413247 GROOVY-12468: Add a @Spec annotation as an informal contract holder
     add 8d2980fd12 GROOVY-12455: Regression: NegativeArraySizeException during Groovy compilation
     add 5a81281ec6 add skill to see status of recent jira tickets
     add fa53afab4f GROOVY-12340: record the serializable proxy handlers and how to bound them
     new 3ffed3f63a GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
     new 3911ce3ca1 GROOVY-12448: keep language-host compiles off the core compiler

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   (b7d739d0d8)
            \
             N -- N -- N   refs/heads/GROOVY-12448 (3911ce3ca1)

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 2 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:
 .agents/skills/groovy-jira/SKILL.md                |  12 +-
 .agents/skills/groovy-jira/jira-recent.sh          |  91 +++++++
 COMPATIBILITY.md                                   |  16 ++
 THREAT_MODEL.md                                    |  24 ++
 build-logic/build.gradle                           |   2 +-
 build.gradle                                       |   2 +-
 gradle.properties                                  |   4 +-
 gradle/verification-metadata.xml                   |  21 +-
 gradle/wrapper/gradle-wrapper.jar                  | Bin 48749 -> 43583 bytes
 gradle/wrapper/gradle-wrapper.properties           |   4 +-
 gradlew                                            |  18 +-
 gradlew.bat                                        |  86 +++----
 .../groovy/transform/ASTTestTransformation.groovy  |  37 ++-
 src/main/java/groovy/transform/Pure.java           |   7 +-
 .../groovy/classgen/AsmClassGenerator.java         |  20 +-
 .../org/codehaus/groovy/classgen/Verifier.java     |   2 +-
 .../codehaus/groovy/control/CompilationUnit.java   |   4 +
 .../groovy/control/CompilerConfiguration.java      | 101 +++++++-
 .../codehaus/groovy/control/ResolveVisitor.java    |   1 +
 .../control/customizers/ImportCustomizer.java      |  94 +++++++
 .../codehaus/groovy/runtime/ConversionHandler.java |  15 ++
 .../org/codehaus/groovy/runtime/ConvertedMap.java  |   2 +-
 .../groovy/runtime/DefaultGroovyMethods.java       |  38 ++-
 .../groovy/runtime/StringGroovyMethods.java        |   6 +-
 .../groovy/transform/DelegateASTStubber.java       |   7 +-
 .../transform/DelegateASTTransformation.java       |  21 +-
 .../transform/stc/StaticTypeCheckingVisitor.java   |  51 ++++
 .../org/codehaus/groovy/vmplugin/v8/Java8.java     |  12 +-
 src/spec/doc/tools-groovyc.adoc                    |  95 ++++---
 src/test/groovy/bugs/Groovy12437.groovy            | 122 +++++++++
 src/test/groovy/bugs/Groovy12451.groovy            |  86 +++++++
 src/test/groovy/bugs/Groovy12470.groovy            |  65 +++++
 src/test/groovy/bugs/Groovy12476.groovy            | 128 ++++++++++
 .../groovy/gls/sizelimits/MethodSizeTest.groovy    | 165 +++++++++++++
 .../groovy/transform/stc/STCAssignmentTest.groovy  |  73 ++++++
 .../org/codehaus/groovy/ast/MethodNodeTest.groovy  |  26 ++
 .../asm/sc/StaticCompileConstructorsTest.groovy    |  26 ++
 .../groovy/control/CompilerConfigurationTest.java  |  35 ++-
 .../groovy/tools/stubgenerator/Groovy12426.groovy  |  85 +++++++
 .../main/groovy/groovy/console/ui/Console.groovy   | 107 +++++++-
 .../groovy/groovy/console/ui/ConsoleActions.groovy |  10 +
 .../groovy/console/ui/ConsoleTextEditor.java       |  13 +
 .../console/ui/language/ConsoleLanguageHost.groovy | 114 +++++++--
 .../console/ui/text/SmartDocumentFilter.java       | 148 +++++++++--
 .../groovy/console/ui/view/BasicMenuBar.groovy     |   2 +
 .../groovy/console/ui/view/MacOSXMenuBar.groovy    |   2 +
 .../src/spec/doc/groovy-console.adoc               |   9 +-
 .../console/ui/ThemeSwitchKeyBindingsTest.groovy   | 101 ++++++++
 .../ui/language/ConsoleLanguageHostTest.groovy     |  33 +++
 .../console/ui/text/SmartDocumentFilterTest.groovy |  18 ++
 .../src/main/java/groovy/contracts/Spec.java       |  94 +++++++
 .../main/java/groovy/contracts/package-info.java   |   7 +
 .../contracts/ast/SpecASTTransformation.java       | 174 +++++++++++++
 .../generation/AssertStatementCreationUtility.java |  40 ++-
 .../src/spec/doc/contracts-userguide.adoc          |  43 ++++
 .../src/spec/test/ContractsTest.groovy             |  43 ++++
 .../tests/inv/SimpleClassInvariantTests.groovy     |  26 ++
 .../groovy/contracts/tests/other/SpecTests.groovy  | 250 +++++++++++++++++++
 .../post/ImplicitReturnPostconditionTests.groovy   | 120 +++++++++
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |  13 +-
 .../apache/groovy/groovysh/jline/GroovyEngine.java |   7 +
 .../language/GroovyshLanguageSession.groovy        |  70 +++++-
 .../groovy-groovysh/src/spec/doc/groovysh.adoc     |   9 +
 .../apache/groovy/lsp/GroovyLanguageClient.groovy  | 109 +++++---
 .../groovy/lsp/internal/LanguageServerContext.java |  58 ++---
 .../lsp/internal/compile/CompilerSettings.java     |  52 +++-
 .../lsp/internal/compile/GroovyCompiler.java       |  91 ++++---
 .../lsp/internal/engine/GroovyLanguageEngine.java  |   3 +-
 .../lsp/internal/feature/CompletionService.java    |  79 +++---
 .../protocol/GroovyTextDocumentService.java        |   2 +-
 .../internal/protocol/GroovyWorkspaceService.java  |  12 +
 .../internal/util}/ReflectionParameterNames.java   |  39 ++-
 .../groovy/lsp/GroovyLanguageClientTest.groovy     |  18 ++
 .../groovy/lsp/GroovyLanguageServerTest.groovy     |  46 ++++
 .../lsp/internal/compile/CompilerSafetyTest.groovy |  10 +-
 .../internal/feature/CompletionServiceTest.groovy  |  23 +-
 .../util}/ReflectionParameterNamesTest.groovy      |  26 +-
 .../lsp/internal/util/ParameterNameLeakProbe.java  |  51 ++++
 .../groovy/typecheckers/PurityChecker.groovy       | 275 +++++++++++++++++++--
 .../src/spec/doc/typecheckers.adoc                 |  51 +++-
 .../groovy/typecheckers/PurityCheckerTest.groovy   | 248 +++++++++++++++++++
 versions.properties                                |   2 +-
 82 files changed, 3807 insertions(+), 445 deletions(-)
 create mode 100755 .agents/skills/groovy-jira/jira-recent.sh
 create mode 100644 src/test/groovy/bugs/Groovy12437.groovy
 create mode 100644 src/test/groovy/bugs/Groovy12451.groovy
 create mode 100644 src/test/groovy/bugs/Groovy12470.groovy
 create mode 100644 src/test/groovy/bugs/Groovy12476.groovy
 create mode 100644 src/test/groovy/gls/sizelimits/MethodSizeTest.groovy
 create mode 100644 src/test/groovy/org/codehaus/groovy/tools/stubgenerator/Groovy12426.groovy
 create mode 100644 subprojects/groovy-console/src/test/groovy/groovy/console/ui/ThemeSwitchKeyBindingsTest.groovy
 create mode 100644 subprojects/groovy-contracts/src/main/java/groovy/contracts/Spec.java
 create mode 100644 subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/SpecASTTransformation.java
 create mode 100644 subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/other/SpecTests.groovy
 rename {src/main/java/org/codehaus/groovy/reflection => subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util}/ReflectionParameterNames.java (82%)
 rename {src/test/groovy/org/codehaus/groovy/reflection => subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util}/ReflectionParameterNamesTest.groovy (87%)
 create mode 100644 subprojects/groovy-lsp/src/test/java/org/apache/groovy/lsp/internal/util/ParameterNameLeakProbe.java


(groovy) 01/02: 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 3ffed3f63aa124ad6ca5aa442e70b8d2ab3e4624
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 +-
 .../reflection/ReflectionParameterNames.java       |  184 +++
 .../org/codehaus/groovy/vmplugin/v8/Java8.java     |    8 +
 .../reflection/ReflectionParameterNamesTest.groovy |  119 ++
 subprojects/groovy-console/build.gradle            |    3 +
 .../main/groovy/groovy/console/ui/Console.groovy   |  145 +-
 .../groovy/groovy/console/ui/ObjectBrowser.groovy  |   14 +-
 .../groovy/groovy/console/ui/ThemeManager.groovy   |    4 +-
 .../console/ui/language/ConsoleLanguageHost.groovy | 1463 ++++++++++++++++++++
 .../groovy/groovy/console/ui/language/package.html |   27 +
 .../console/ui/text/MatchingHighlighter.java       |  111 +-
 .../console/ui/text/SmartDocumentFilter.java       |  547 +++-----
 .../src/spec/doc/groovy-console.adoc               |   36 +-
 .../console/ui/ConsoleLanguageWiringTest.groovy    |  176 +++
 .../ui/language/ConsoleLanguageHostTest.groovy     | 1166 ++++++++++++++++
 .../console/ui/text/MatchingHighlighterTest.groovy |   70 +
 .../console/ui/text/SmartDocumentFilterTest.groovy |  314 ++++-
 subprojects/groovy-groovysh/ARCHITECTURE.md        |   22 +
 subprojects/groovy-groovysh/build.gradle           |    6 +-
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |   27 +-
 .../groovy/groovysh/jline/GroovyCommands.groovy    |   19 +-
 .../apache/groovy/groovysh/jline/GroovyEngine.java |   40 +-
 .../language/GroovyshLanguageSession.groovy        |  628 +++++++++
 .../groovy/groovysh/language/package-info.groovy}  |   33 +-
 .../groovy-groovysh/src/spec/doc/groovysh.adoc     |   27 +
 .../groovy/groovysh/GroovyshEmbeddingTest.groovy   |   22 +
 .../groovysh/commands/ConsoleCommandTest.groovy    |   20 +-
 .../groovy/groovysh/commands/InspectTest.groovy    |   80 ++
 .../language/GroovyshLanguageSessionTest.groovy    |  537 +++++++
 subprojects/groovy-lsp/AGENTS.md                   |   33 +-
 subprojects/groovy-lsp/ARCHITECTURE.md             |   64 +-
 subprojects/groovy-lsp/build.gradle                |    6 +-
 .../apache/groovy/lsp/GroovyLanguageClient.groovy  |  363 +++++
 .../groovy/lsp/internal/LanguageServerContext.java |  142 +-
 .../lsp/internal/compile/CompilerSettings.java     |   64 +-
 .../lsp/internal/compile/GroovyCompiler.java       |   15 +-
 .../groovy/lsp/internal/compile/ScriptNames.java   |  149 ++
 .../lsp/internal/engine/GroovyLanguageEngine.java  |  342 ++++-
 .../lsp/internal/feature/CompletionService.java    | 1027 +++++++++++++-
 .../internal/feature/SemanticTokensService.java    |  242 +++-
 .../groovy/lsp/internal/feature/SyntaxView.java    |  136 ++
 .../protocol/GroovyTextDocumentService.java        |   11 +-
 .../internal/protocol/GroovyWorkspaceService.java  |   73 +-
 .../groovy/lsp/internal/util/GroovyKeywords.java   |   36 +
 .../lsp/internal/util/GroovySourceTokens.java      |   34 +-
 .../lsp/internal/workspace/TextDocument.java       |   36 +
 .../groovy-lsp/src/spec/doc/lsp-userguide.adoc     |   66 +-
 .../groovy/lsp/GroovyLanguageClientTest.groovy     |  166 +++
 .../groovy/lsp/GroovyLanguageServerTest.groovy     |   98 ++
 .../lsp/internal/compile/ScriptNamesTest.groovy    |  124 ++
 .../internal/compile/WorkspaceLayoutTest.groovy    |   26 +
 .../engine/GroovyLanguageEngineBufferTest.groovy   |  256 ++++
 .../internal/feature/CompletionServiceTest.groovy  |  346 +++++
 .../internal/feature/LanguageQualityTest.groovy    |  158 ++-
 .../internal/feature/NavigationIdentityTest.groovy |    2 +-
 .../lsp/internal/feature/SemanticTokensTest.groovy |   76 +
 .../internal/feature/SupportServicesTest.groovy    |    1 +
 .../lsp/internal/util/GroovyKeywordsTest.groovy    |   19 +
 .../internal/util/GroovySourceTokensTest.groovy    |   12 +
 66 files changed, 9255 insertions(+), 733 deletions(-)

diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index b0d7d73679..705b8245f6 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -637,7 +637,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/src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java b/src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java
new file mode 100644
index 0000000000..dc5b6a55fe
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java
@@ -0,0 +1,184 @@
+/*
+ *  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.codehaus.groovy.reflection;
+
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.Label;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Executable;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Formal parameter names for a reflective method or constructor.
+ * <p>
+ * {@link Parameter#getName()} is {@code arg0} unless the class was compiled
+ * with {@code -parameters}. JDK and Groovy class files still record the
+ * source names in {@code LocalVariableTable}; those names are used when the
+ * method-parameters attribute is absent.
+ */
+public final class ReflectionParameterNames {
+
+    private static final Map<Class<?>, Map<String, String[]>> DEBUG_NAMES = new ConcurrentHashMap<>();
+
+    private ReflectionParameterNames() {
+    }
+
+    private static boolean anyName(final String[] names) {
+        if (names == null) {
+            return false;
+        }
+        for (String name : names) {
+            if (name != null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @param executable method or constructor
+     * @return one name per parameter, never {@code null}; missing names are {@code argN}
+     */
+    public static String[] names(final Executable executable) {
+        int count = executable == null ? 0 : executable.getParameterCount();
+        String[] names = new String[count];
+        if (executable == null || count == 0) {
+            return names;
+        }
+        Parameter[] parameters = executable.getParameters();
+        String[] debug = null;
+        for (int i = 0; i < count; i++) {
+            if (i < parameters.length && parameters[i].isNamePresent()) {
+                names[i] = parameters[i].getName();
+            } else {
+                if (debug == null) {
+                    debug = debugNames(executable);
+                }
+                names[i] = debug != null && i < debug.length && debug[i] != null ? debug[i] : "arg" + i;
+            }
+        }
+        return names;
+    }
+
+    private static String[] debugNames(final Executable executable) {
+        String key = executable instanceof Method method
+                ? method.getName() + Type.getMethodDescriptor(method)
+                : "<init>" + Type.getConstructorDescriptor((java.lang.reflect.Constructor<?>) executable);
+        Map<String, String[]> table = DEBUG_NAMES.computeIfAbsent(executable.getDeclaringClass(), ReflectionParameterNames::readClass);
+        String[] found = table.get(key);
+        return found == null ? null : found.clone();
+    }
+
+    private static Map<String, String[]> readClass(final Class<?> type) {
+        Map<String, String[]> table = new HashMap<>();
+        if (type == null || type.isPrimitive() || type.isArray()) {
+            return table;
+        }
+        String resource = type.getName().replace('.', '/') + ".class";
+        InputStream in = type.getClassLoader() == null
+                ? ClassLoader.getSystemResourceAsStream(resource)
+                : type.getClassLoader().getResourceAsStream(resource);
+        if (in == null) {
+            return table;
+        }
+        try (in) {
+            new ClassReader(in).accept(new ParameterNameCollector(table), ClassReader.SKIP_FRAMES);
+        } catch (IOException | RuntimeException ignored) {
+            return table;
+        }
+        return table;
+    }
+
+    /**
+     * Reads {@code LocalVariableTable} names. Javac lists parameters before
+     * locals that reuse a slot. Label offsets are not resolved while the
+     * table is visited, so the first name at each parameter slot is kept.
+     */
+    private static final class ParameterNameCollector extends ClassVisitor {
+        private final Map<String, String[]> table;
+
+        private ParameterNameCollector(final Map<String, String[]> table) {
+            super(Opcodes.ASM9);
+            this.table = table;
+        }
+
+        @Override
+        public MethodVisitor visitMethod(final int access, final String name, final String descriptor,
+                                          final String signature, final String[] exceptions) {
+            return new MethodParameterNames(table, access, name, descriptor);
+        }
+    }
+
+    private static final class MethodParameterNames extends MethodVisitor {
+        private final Map<String, String[]> table;
+        private final boolean isStatic;
+        private final String name;
+        private final String descriptor;
+        private final Type[] arguments;
+        private final String[] slots;
+
+        private MethodParameterNames(final Map<String, String[]> table, final int access,
+                                     final String name, final String descriptor) {
+            super(Opcodes.ASM9);
+            this.table = table;
+            this.isStatic = (access & Opcodes.ACC_STATIC) != 0;
+            this.name = name;
+            this.descriptor = descriptor;
+            this.arguments = Type.getArgumentTypes(descriptor);
+            this.slots = new String[arguments.length];
+        }
+
+        @Override
+        public void visitLocalVariable(final String variable, final String desc, final String sig,
+                                        final Label start, final Label end, final int index) {
+            if (variable == null || "this".equals(variable)) {
+                return;
+            }
+            int slot = isStatic ? 0 : 1;
+            for (int i = 0; i < arguments.length; i++) {
+                if (slot == index && slots[i] == null) {
+                    slots[i] = variable;
+                    return;
+                }
+                slot += arguments[i].getSize();
+            }
+        }
+
+        @Override
+        public void visitEnd() {
+            if (!anyName(slots)) {
+                return;
+            }
+            String[] existing = table.get(name + descriptor);
+            if (existing == null || !anyName(existing)) {
+                table.put(name + descriptor, slots);
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java
index 3f5eedfa8d..05adb969a8 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java
@@ -39,6 +39,7 @@ import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.ListExpression;
 import org.codehaus.groovy.ast.expr.PropertyExpression;
 import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.reflection.ReflectionParameterNames;
 import org.codehaus.groovy.reflection.ReflectionUtils;
 import org.codehaus.groovy.runtime.MetaClassHelper;
 import org.codehaus.groovy.vmplugin.VMPlugin;
@@ -738,6 +739,13 @@ public class Java8 implements VMPlugin {
      */
     protected void fillParameterNames(final String[] names, final Member member) {
         try {
+            if (member instanceof Executable executable) {
+                String[] resolved = ReflectionParameterNames.names(executable);
+                for (int i = 0, n = Math.min(names.length, resolved.length); i < n; i += 1) {
+                    names[i] = resolved[i] != null ? resolved[i] : "arg" + i;
+                }
+                return;
+            }
             java.lang.reflect.Parameter[] parameters = ((Executable) member).getParameters();
             for (int i = 0, n = names.length; i < n; i += 1) {
                 names[i] = parameters[i].getName();
diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy
new file mode 100644
index 0000000000..ccede9e260
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy
@@ -0,0 +1,119 @@
+/*
+ *  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.codehaus.groovy.reflection
+
+import org.codehaus.groovy.runtime.DefaultGroovyMethods
+import org.codehaus.groovy.vmplugin.VMPluginFactory
+import org.codehaus.groovy.vmplugin.v8.Java8
+import org.junit.jupiter.api.Test
+import org.objectweb.asm.ClassWriter
+import org.objectweb.asm.Opcodes
+
+import java.lang.reflect.Member
+
+final class ReflectionParameterNamesTest {
+
+    static class Holder {
+        void ping(String person) {}
+
+        Holder(String person) {}
+    }
+
+    @Test
+    void readsNamesFromTheLocalVariableTable() {
+        def charAt = String.getMethod('charAt', int)
+        assert !charAt.parameters[0].namePresent
+        assert ReflectionParameterNames.names(charAt) == ['index'] as String[]
+
+        def copy = String.getMethod('copyValueOf', char[])
+        def copyNames = ReflectionParameterNames.names(copy)
+        assert copyNames.length == 1
+        assert copyNames[0] != 'arg0'
+
+        def removeAt = DefaultGroovyMethods.declaredMethods.find { it.name == 'removeAt' && it.parameterCount == 2 }
+        assert removeAt != null
+        assert ReflectionParameterNames.names(removeAt) == ['self', 'index'] as String[]
+
+        assert ReflectionParameterNames.names(null).length == 0
+        assert ReflectionParameterNames.names(String.getMethod('length')).length == 0
+
+        def original = String.getConstructor(String)
+        assert ReflectionParameterNames.names(original).length == 1
+
+        def ping = Holder.getDeclaredMethod('ping', String)
+        assert ReflectionParameterNames.names(ping) == ['person'] as String[]
+        def constructed = Holder.getDeclaredConstructor(String)
+        assert ReflectionParameterNames.names(constructed) == ['person'] as String[]
+
+        def fill = Java8.getDeclaredMethod('fillParameterNames', String[], Member)
+        fill.accessible = true
+        def filled = new String[1]
+        fill.invoke(VMPluginFactory.plugin, [filled, charAt] as Object[])
+        assert filled[0] == 'index'
+
+        def anyName = ReflectionParameterNames.getDeclaredMethod('anyName', String[])
+        anyName.accessible = true
+        assert anyName.invoke(null, [null] as Object[]) == false
+        assert anyName.invoke(null, [[null, null] as String[]] as Object[]) == false
+        assert anyName.invoke(null, [[null, 'person'] as String[]] as Object[]) == true
+
+        def readClass = ReflectionParameterNames.getDeclaredMethod('readClass', Class)
+        readClass.accessible = true
+        assert ((Map) readClass.invoke(null, [int] as Object[])).isEmpty()
+        assert ((Map) readClass.invoke(null, [String[]] as Object[])).isEmpty()
+        assert ((Map) readClass.invoke(null, [null] as Object[])).isEmpty()
+
+        byte[] bytes = classWithoutDebug('cov/Boom')
+        def loader = new ClassLoader(ReflectionParameterNamesTest.classLoader) {
+            @Override
+            InputStream getResourceAsStream(String name) {
+                if (name == 'cov/Boom.class') {
+                    return new ByteArrayInputStream(new byte[0])
+                }
+                super.getResourceAsStream(name)
+            }
+
+            Class<?> load(byte[] body) {
+                defineClass('cov.Boom', body, 0, body.length)
+            }
+        }
+        def boom = loader.load(bytes)
+        def boomPing = boom.getDeclaredMethod('ping', int)
+        assert ReflectionParameterNames.names(boomPing) == ['arg0'] as String[]
+    }
+
+    private static byte[] classWithoutDebug(String internalName) {
+        def writer = new ClassWriter(0)
+        writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, 'java/lang/Object', null)
+        def init = writer.visitMethod(Opcodes.ACC_PUBLIC, '<init>', '()V', null, null)
+        init.visitCode()
+        init.visitVarInsn(Opcodes.ALOAD, 0)
+        init.visitMethodInsn(Opcodes.INVOKESPECIAL, 'java/lang/Object', '<init>', '()V', false)
+        init.visitInsn(Opcodes.RETURN)
+        init.visitMaxs(1, 1)
+        init.visitEnd()
+        def ping = writer.visitMethod(Opcodes.ACC_PUBLIC, 'ping', '(I)V', null, null)
+        ping.visitCode()
+        ping.visitInsn(Opcodes.RETURN)
+        ping.visitMaxs(0, 2)
+        ping.visitEnd()
+        writer.visitEnd()
+        writer.toByteArray()
+    }
+}
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 367a379b52..d17edc2ed5 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
@@ -35,14 +36,7 @@ import groovy.transform.PackageScope
 import groovy.transform.ThreadInterrupt
 import groovy.transform.TupleConstructor
 import groovy.ui.GroovyMain
-import org.antlr.v4.gui.TreeViewer
-import org.antlr.v4.gui.Trees
-import org.antlr.v4.runtime.CharStreams
-import org.antlr.v4.runtime.CommonTokenStream
-import org.apache.groovy.antlr.LexerFrame
 import org.apache.groovy.io.StringBuilderWriter
-import org.apache.groovy.parser.antlr4.GroovyLangLexer
-import org.apache.groovy.parser.antlr4.GroovyLangParser
 import org.apache.groovy.util.JavaShell
 import org.apache.groovy.util.SystemUtil
 import org.codehaus.groovy.control.CompilerConfiguration
@@ -70,6 +64,9 @@ import javax.swing.JFrame
 import javax.swing.JLabel
 import javax.swing.JOptionPane
 import javax.swing.JScrollPane
+import javax.swing.JTextArea
+import javax.swing.JTree
+import javax.swing.tree.DefaultMutableTreeNode
 import javax.swing.JSplitPane
 import javax.swing.JTextPane
 import javax.swing.KeyStroke
@@ -99,6 +96,8 @@ import java.awt.Container
 import java.awt.Dimension
 import java.awt.EventQueue
 import java.awt.Font
+import java.awt.Frame
+import java.awt.HeadlessException
 import java.awt.Toolkit
 import java.awt.Window
 import java.awt.event.ActionEvent
@@ -106,7 +105,6 @@ import java.awt.event.ComponentEvent
 import java.awt.event.ComponentListener
 import java.awt.event.FocusEvent
 import java.awt.event.FocusListener
-import java.awt.event.WindowAdapter
 import java.awt.event.WindowEvent
 import java.awt.event.WindowFocusListener
 import java.util.logging.Logger
@@ -311,6 +309,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. */
@@ -524,6 +524,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. */
@@ -596,9 +597,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.
@@ -968,7 +980,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
@@ -1021,6 +1033,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. */
@@ -1081,6 +1094,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()
@@ -1089,8 +1104,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
                 FindReplaceUtility.dispose()
                 consoleControllers.remove(this)
                 if (!consoleControllers) {
-                    systemOutInterceptor.stop()
-                    systemErrorInterceptor.stop()
+                    systemOutInterceptor?.stop()
+                    systemErrorInterceptor?.stop()
                 }
             }
             return exit
@@ -1128,6 +1143,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         nativeFullScreenForMac(swing.consoleFrame)
         swing.consoleFrame.pack()
         swing.consoleFrame.show()
+        consoleController.installLanguageHost()
         swing.doLater swing.inputArea.&requestFocus
     }
 
@@ -1159,6 +1175,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 }
@@ -1410,43 +1427,63 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
 
     /** Opens the CST viewer for the current editor contents. */
     void inspectCst(EventObject evt = null) {
-        String text = this.inputEditor.textEditor.text
-        def charStream = CharStreams.fromReader(new StringReader(text))
-        def lexer = new GroovyLangLexer(charStream)
-        def tokens = new CommonTokenStream(lexer)
-        def parser = new GroovyLangParser(tokens)
-        def tree = parser.compilationUnit()
-        // Trees.inspect spawns its own dialog on the EDT; we wait off-EDT then
-        // hop back on to theme it — ANTLR's TreeViewer defaults to black text
-        // on white/transparent boxes, which ignores the app theme otherwise.
-        def future = Trees.inspect(tree, parser)
-        Thread.start {
-            def dialog = future.get()
-            SwingUtilities.invokeLater { themeCstDialog(dialog) }
-        }
-    }
-
-    private void themeCstDialog(JDialog dialog) {
-        TreeViewer viewer = findTreeViewer(dialog)
-        if (!viewer) return
-        applyCstColors(viewer)
-        // track theme switches so an already-open CST dialog keeps pace
-        def listener = { SwingUtilities.invokeLater { applyCstColors(viewer); viewer.repaint() } } as Runnable
-        ThemeManager.addThemeChangeListener(listener)
-        dialog.addWindowListener(new WindowAdapter() {
-            @Override void windowClosed(WindowEvent e) {
-                ThemeManager.removeThemeChangeListener(listener)
+        showStructure('CST', languageHost?.cst(inputArea?.text ?: ''))
+    }
+
+    /** Opens the lexer token inspector for the current editor contents. */
+    void inspectTokens(EventObject evt = null) {
+        def rows = languageHost?.tokens(inputArea?.text ?: '') ?: []
+        def lines = rows.collect { "${it.line}:${it.character} ${it.name} ${it.text}" }
+        showText('Token Stream', lines.join('\n'))
+    }
+
+    private void showStructure(String title, Map root) {
+        def modelRoot = structureNode(root)
+        def tree = new JTree(modelRoot)
+        showComponent(title, new JScrollPane(tree))
+    }
+
+    private static DefaultMutableTreeNode structureNode(Map node) {
+        def label = node?.name ?: ''
+        if (node?.text) {
+            label = label + ' ' + node.text
+        }
+        def treeNode = new DefaultMutableTreeNode(label)
+        (node?.children ?: []).each { child ->
+            if (child instanceof Map) {
+                treeNode.add(structureNode(child))
             }
-        })
+        }
+        treeNode
+    }
+
+    private void showText(String title, String body) {
+        def area = new JTextArea(body ?: '')
+        area.editable = false
+        area.font = new Font('Monospaced', Font.PLAIN, 12)
+        showComponent(title, new JScrollPane(area))
     }
 
-    private static void applyCstColors(TreeViewer viewer) {
+    private void showComponent(String title, Component content) {
         boolean dark = ThemeManager.isDark()
-        viewer.textColor = dark ? new Color(210, 210, 210) : Color.BLACK
-        viewer.boxColor = dark ? new Color(50, 55, 65) : Color.WHITE
-        viewer.borderColor = null  // null = no box outline around each label (TreeViewer default)
-        viewer.highlightedBoxColor = dark ? new Color(80, 120, 80) : new Color(200, 255, 200)
-        viewer.background = dark ? new Color(43, 43, 43) : Color.WHITE
+        content.background = dark ? new Color(43, 43, 43) : Color.WHITE
+        content.foreground = dark ? new Color(210, 210, 210) : Color.BLACK
+        try {
+            Frame owner = frame instanceof Frame ? (Frame) frame : null
+            def dialog = JDialog.getConstructor(Frame, String, boolean.class).newInstance(owner, title, false)
+            dialog.contentPane.add(content)
+            dialog.setSize(640, 480)
+            dialog.visible = true
+        } catch (Exception ex) {
+            // Constructor.newInstance wraps HeadlessException.
+            def cursor = ex
+            while (cursor != null && !(cursor instanceof HeadlessException)) {
+                cursor = cursor.cause
+            }
+            if (cursor == null) {
+                throw ex
+            }
+        }
     }
 
     /**
@@ -1488,24 +1525,6 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         }
     }
 
-    private static TreeViewer findTreeViewer(Container container) {
-        for (Component comp : container.components) {
-            if (comp instanceof TreeViewer) return (TreeViewer) comp
-            if (comp instanceof Container) {
-                TreeViewer found = findTreeViewer((Container) comp)
-                if (found) return found
-            }
-        }
-        null
-    }
-
-    /** Opens the lexer token inspector for the current editor contents. */
-    void inspectTokens(EventObject evt = null) {
-        def content = inputArea.getText()
-        def lf = new LexerFrame(new StringReader(content))
-        lf.visible = true
-    }
-
     /**
      * Returns whether toolbar icons follow the editor font size.
      *
@@ -1789,6 +1808,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             fc.selectedFiles?.each { file ->
                 shell.getClassLoader().addURL(file.toURL())
             }
+            languageHost?.bind(shell.classLoader, config)
         }
     }
 
@@ -1801,6 +1821,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..fbd08a4273 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.GroovyLanguageClient
 
 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 lexerWords()) {
                     content = content.replaceAll(/\b(${ln})\b/, '<b>$1</b>')
                 }
                 content = content.replace('\n', '<br/>').replace(',', '<b>,</b>').replace(':', '<b>:</b>')
@@ -451,6 +450,11 @@ class ObjectBrowser {
         def dialog = pane.createDialog(frame, 'About Groovy Object Browser')
         dialog.show()
     }
+
+    /** Keywords reported by groovy-lsp, including primitives and literals. */
+    private static List<String> lexerWords() {
+        GroovyLanguageClient.keywordList()
+    }
 }
 
 /** Tracks the currently selected card index for the browser. */
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..4facb2b920
--- /dev/null
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy
@@ -0,0 +1,1463 @@
+/*
+ *  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.GroovyLanguageClient
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.eclipse.lsp4j.CompletionItem
+import org.eclipse.lsp4j.Diagnostic
+
+import javax.swing.AbstractAction
+import javax.swing.BorderFactory
+import javax.swing.Box
+import javax.swing.DefaultListModel
+import javax.swing.JComponent
+import javax.swing.JLabel
+import javax.swing.JList
+import javax.swing.JPanel
+import javax.swing.JPopupMenu
+import javax.swing.JScrollPane
+import javax.swing.JTextPane
+import javax.swing.KeyStroke
+import javax.swing.ListCellRenderer
+import javax.swing.SwingUtilities
+import javax.swing.event.DocumentEvent
+import javax.swing.event.DocumentListener
+import javax.swing.event.PopupMenuEvent
+import javax.swing.event.PopupMenuListener
+import javax.swing.text.BadLocationException
+import javax.swing.text.DefaultHighlighter
+import javax.swing.text.Highlighter
+import javax.swing.text.JTextComponent
+import javax.swing.text.Position
+import javax.swing.text.View
+import java.awt.BasicStroke
+import java.awt.BorderLayout
+import java.awt.Color
+import java.awt.Component
+import java.awt.Container
+import java.awt.Dimension
+import java.awt.FlowLayout
+import java.awt.Font
+import java.awt.FontMetrics
+import java.awt.Graphics
+import java.awt.Graphics2D
+import java.awt.Point
+import java.awt.Rectangle
+import java.awt.RenderingHints
+import java.awt.Shape
+import java.awt.event.ActionEvent
+import java.awt.event.InputEvent
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import java.awt.event.MouseAdapter
+import java.awt.event.MouseEvent
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+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. Speaks to
+ * {@code GroovyLanguageServer} with the same LSP messages a remote
+ * editor sends. Alt+/ offers completions and does not bump the
+ * highlight generation. The constructor and {@link #bind} publish the
+ * console classpath and imports. A later query resends them only when
+ * the loader identity or the classpath URLs changed, so an unchanged
+ * buffer reuses the compiled snapshot. Typing debounces diagnostic highlights.
+ * Implementation detail of Groovy Console, not an embedding API.
+ */
+@CompileStatic
+@Internal
+final class ConsoleLanguageHost implements AutoCloseable {
+
+    /** This editor's unsaved buffer. The server does not assign the name. */
+    @PackageScope
+    static final String BUFFER_URI = 'groovy-buffer:console'
+
+    private static final int DEBOUNCE_MS = 150
+    /**
+     * Safety bound for the popup. A resolved receiver can have several
+     * hundred members once Groovy extension methods are included; the
+     * scroll pane still shows {@link #VISIBLE_ROWS} at a time.
+     */
+    private static final int MAX_COMPLETIONS = 1024
+    /** Rows kept on screen. Further entries use the vertical scrollbar. */
+    private static final int VISIBLE_ROWS = 12
+    /** {@code returnType name(Type param, Type name)}. */
+    private static final Pattern SIGNATURE = Pattern.compile(/^(.*)\s+(\S+)\((.*)\)\s*$/)
+    /** IntelliJ-style kind colors. Each is distinct from the name text. */
+    private static final Color KIND_METHOD = new Color(219, 92, 92)
+    private static final Color KIND_FIELD = new Color(110, 127, 232)
+    private static final Color KIND_PROPERTY = new Color(58, 168, 158)
+    private static final Color KIND_VARIABLE = new Color(122, 138, 153)
+    private static final Color KIND_CLASS = new Color(214, 158, 54)
+    private static final Color KIND_INTERFACE = new Color(86, 166, 104)
+    private static final Color KIND_ENUM = new Color(163, 113, 212)
+    private static final Color KIND_KEYWORD = new Color(138, 122, 90)
+    private static final Color KIND_SNIPPET = new Color(76, 141, 255)
+    private static final Color KIND_OTHER = new Color(154, 160, 166)
+
+    private final GroovyLanguageClient language
+    private final JTextPane input
+    private final DocumentListener documentListener
+    private final KeyAdapter completionKeys
+    private final DefaultHighlighter.DefaultHighlightPainter errorPainter = new WavyUnderlinePainter()
+    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
+    /** True after the current loader identity has been sent. */
+    private boolean loaderPublished
+    private ClassLoader publishedLoader
+    private List<String> publishedClasspath
+    private ScheduledFuture<?> pendingHighlight
+    private ScheduledFuture<?> pendingCompletion
+    /** Candidates from the last Alt+/, before the typed prefix narrows them. */
+    private final List<Candidate> completionAll = []
+    private JPopupMenu completionPopup
+    private JList<Candidate> completionList
+    private DefaultListModel<Candidate> completionModel
+    private boolean completionOpen
+    /** Document offset where the name being completed starts. */
+    private int completionAnchor = -1
+    /** Document offset just after the name being completed. */
+    private int completionEnd = -1
+
+    private ConsoleLanguageHost(JTextPane input, ClassLoader loader, CompilerConfiguration configuration) {
+        this.input = input
+        this.parent = loader
+        this.configuration = configuration
+        this.language = GroovyLanguageClient.open(BUFFER_URI, textOf(input), true)
+        publishSettings()
+        this.documentListener = new DocumentListener() {
+            @Override
+            void insertUpdate(DocumentEvent e) {
+                onDocumentEdit(e)
+                schedule()
+            }
+
+            @Override
+            void removeUpdate(DocumentEvent e) {
+                onDocumentEdit(e)
+                schedule()
+            }
+
+            @Override
+            void changedUpdate(DocumentEvent e) { }
+        }
+        this.completionKeys = new KeyAdapter() {
+            @Override
+            void keyPressed(KeyEvent e) {
+                if (completionOpen && handleCompletionKey(e)) {
+                    return
+                }
+                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
+     */
+    /**
+     * Lenient lexer tokens for the token viewer.
+     *
+     * @param text buffer text
+     * @return token maps, or empty when the host is closed
+     */
+    List<Map<String, Object>> tokens(String text) {
+        if (closed) {
+            return []
+        }
+        language.tokens(text)
+    }
+
+    /**
+     * Concrete syntax tree for the CST viewer.
+     *
+     * @param text buffer text
+     * @return a tree map, or empty when the host is closed
+     */
+    Map<String, Object> cst(String text) {
+        if (closed) {
+            return [name: '', text: '', children: []]
+        }
+        language.cst(text)
+    }
+
+    @PackageScope
+    List<Hit> compileAndDiagnostics(String text) {
+        if (closed) {
+            return []
+        }
+        publishLoader()
+        language.compile(text).collect { Hit.from(it) }
+    }
+
+    /**
+     * 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<Candidate> complete(String text, int line, int character) {
+        if (closed) {
+            return []
+        }
+        publishLoader()
+        language.complete(text, line, character).collect { Candidate.from(it) }
+    }
+
+    /**
+     * 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) {
+        if (closed) {
+            return ''
+        }
+        publishLoader()
+        language.hover(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
+        publishSettings()
+        schedule()
+    }
+
+    /** Classpath URLs only, and only when they or the loader identity changed. */
+    private void publishLoader() {
+        publishParent()
+        List<String> classpath = GroovyLanguageClient.classpathOf(parent)
+        if (classpath != publishedClasspath) {
+            language.settings([classpath: classpath])
+            publishedClasspath = classpath
+        }
+    }
+
+    private void publishSettings() {
+        publishParent()
+        List<String> classpath = GroovyLanguageClient.classpathOf(parent)
+        language.settings([
+                classpath: classpath,
+                imports: GroovyLanguageClient.importsOf(configuration),
+                starImports: GroovyLanguageClient.starImportsOf(configuration),
+                scriptBaseClass: configuration?.scriptBaseClass ?: ''
+        ])
+        publishedClasspath = classpath
+    }
+
+    private void publishParent() {
+        if (loaderPublished && sameLoader(publishedLoader, parent)) {
+            return
+        }
+        language.parentLoader(parent)
+        publishedLoader = parent
+        loaderPublished = true
+    }
+
+    private static boolean sameLoader(ClassLoader published, ClassLoader current) {
+        if (published == null) {
+            return current == null
+        }
+        return published.is(current)
+    }
+
+    @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)
+            hideCompletion()
+        }
+        debounce.shutdownNow()
+        try {
+            debounce.awaitTermination(1, TimeUnit.SECONDS)
+        } catch (InterruptedException ignored) {
+            Thread.currentThread().interrupt()
+        }
+        language.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<Hit> hits) {
+        def highlighter = input?.highlighter
+        if (highlighter == null) {
+            return
+        }
+        highlights.each { highlighter.removeHighlight(it) }
+        highlights.clear()
+        hits.each { hit ->
+            int[] span = spanOf(text, hit.startLine, hit.startCharacter, hit.endLine, hit.endCharacter)
+            if (span[0] >= span[1]) {
+                return
+            }
+            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. Methods show their parameter types and
+     * names in gray, and the return type in its own column at the right.
+     * The list keeps {@link #VISIBLE_ROWS} on screen and scrolls for the rest.
+     * Focus stays in the editor so later keystrokes narrow this list.
+     * Moving the pointer over a row selects it, with the same background the
+     * up and down keys use.
+     */
+    @PackageScope
+    JPopupMenu completionMenu(List<Candidate> items) {
+        List<Candidate> source = items == null ? new ArrayList<Candidate>() : items
+        List<Candidate> shown = new ArrayList<Candidate>(source.take(MAX_COMPLETIONS))
+        DefaultListModel<Candidate> model = new DefaultListModel<Candidate>()
+        for (Candidate item : shown) {
+            model.addElement(item)
+        }
+        JList<Candidate> list = new JList<Candidate>(model)
+        list.visibleRowCount = Math.min(VISIBLE_ROWS, Math.max(shown.size(), 1))
+        list.cellRenderer = new CompletionRenderer()
+        list.focusable = false
+        // Hover selects a row. Autoscroll would move that row out from under
+        // the pointer and deliver another move, which never returns.
+        list.autoscrolls = false
+        JScrollPane scroll = new JScrollPane(list)
+        scroll.border = null
+        scroll.focusable = false
+        scroll.verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
+        scroll.horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_NEVER
+        // Both fixed sizes are required. JList walks every row through the
+        // renderer when either one is missing, and a receiver such as String
+        // has several hundred members. That walk runs on the editor thread
+        // while the popup opens.
+        int[] cell = completionCellSize(list, shown)
+        int bar = (int) scroll.verticalScrollBar.preferredSize.width
+        int popupWidth = Math.min(cell[0] + bar, 560)
+        list.fixedCellWidth = Math.max(popupWidth - bar, 1)
+        list.fixedCellHeight = cell[1]
+        scroll.preferredSize = new Dimension(popupWidth, cell[1] * list.visibleRowCount)
+        JPopupMenu menu = new JPopupMenu()
+        menu.focusable = false
+        menu.add(scroll)
+        menu.addPopupMenuListener(new PopupMenuListener() {
+            @Override
+            void popupMenuWillBecomeVisible(PopupMenuEvent event) {
+            }
+
+            @Override
+            void popupMenuWillBecomeInvisible(PopupMenuEvent event) {
+                completionOpen = false
+            }
+
+            @Override
+            void popupMenuCanceled(PopupMenuEvent event) {
+                completionOpen = false
+            }
+        })
+        boolean[] selecting = new boolean[1]
+        MouseAdapter pointer = new MouseAdapter() {
+            @Override
+            void mousePressed(MouseEvent event) {
+                int index = completionRowAt(list, event.point)
+                if (index < 0) {
+                    return
+                }
+                list.selectedIndex = index
+                Candidate item = list.model.getElementAt(index)
+                hideCompletion()
+                acceptCompletion(item)
+            }
+
+            @Override
+            void mouseMoved(MouseEvent event) {
+                if (selecting[0]) {
+                    return
+                }
+                int index = completionRowAt(list, event.point)
+                if (index < 0 || index == list.selectedIndex) {
+                    return
+                }
+                selecting[0] = true
+                try {
+                    list.selectedIndex = index
+                } finally {
+                    selecting[0] = false
+                }
+            }
+        }
+        list.addMouseListener(pointer)
+        list.addMouseMotionListener(pointer)
+        list.inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), 'choose')
+        list.actionMap.put('choose', new AbstractAction() {
+            @Override
+            void actionPerformed(ActionEvent event) {
+                int index = list.selectedIndex
+                if (index < 0 || index >= list.model.size) {
+                    return
+                }
+                Candidate item = list.model.getElementAt(index)
+                hideCompletion()
+                acceptCompletion(item)
+            }
+        })
+        if (model.size() > 0) {
+            list.selectedIndex = 0
+        }
+        menu
+    }
+
+    /**
+     * Remembers {@code items} and narrows them as the editor text changes.
+     * {@code anchor} is the document offset of the name being typed.
+     *
+     * @return the list, so tests can read the filtered model
+     */
+    @PackageScope
+    JList<Candidate> armCompletion(List<Candidate> items, int anchor) {
+        hideCompletion()
+        JPopupMenu menu = completionMenu(items)
+        completionPopup = menu
+        JScrollPane scroll = (JScrollPane) menu.getComponent(0)
+        completionList = (JList<Candidate>) scroll.viewport.view
+        completionModel = (DefaultListModel<Candidate>) completionList.model
+        completionAll.clear()
+        for (int i = 0; i < completionModel.size(); i++) {
+            completionAll.add(completionModel.getElementAt(i))
+        }
+        completionAnchor = Math.max(anchor, 0)
+        String text = textOf(input)
+        int caret = input == null ? completionAnchor : Math.min(Math.max(input.caretPosition, 0), text.length())
+        completionEnd = Math.max(caret, completionAnchor)
+        completionOpen = true
+        refillCompletion()
+        completionList
+    }
+
+    @PackageScope
+    boolean completionShowing() {
+        completionOpen
+    }
+
+    /**
+     * Width and height of one completion row, measured from the text.
+     * Building a component for every candidate makes the editor thread walk
+     * the whole list again when the popup opens.
+     *
+     * @return {@code [width, height]}
+     */
+    @PackageScope
+    static int[] completionCellSize(JList<Candidate> list, List<Candidate> items) {
+        Font font = list?.font
+        if (font == null) {
+            font = new Font(Font.SANS_SERIF, Font.PLAIN, 12)
+        }
+        FontMetrics metrics = list == null ? null : list.getFontMetrics(font)
+        int text = metrics == null ? 16 : metrics.height
+        int height = Math.max(text + 4, 20)
+        int width = 280
+        if (items != null && metrics != null) {
+            for (Candidate item : items) {
+                String name = item?.label() ?: ''
+                SignatureParts parts = SignatureParts.of(item)
+                String params = parts == null ? '' : '(' + parts.parameters + ')'
+                String returns = parts?.returns ?: ''
+                // Borders, kind disc, gap, and the return column's left padding.
+                int row = 16 + 16 + 8 + 32
+                row += metrics.stringWidth(name)
+                row += metrics.stringWidth(params)
+                row += metrics.stringWidth(returns)
+                if (row > width) {
+                    width = row
+                }
+            }
+        }
+        new int[] { width, height }
+    }
+
+    /**
+     * Row under {@code point}, or {@code -1} when the point is outside every
+     * cell. {@link JList#locationToIndex} reports the nearest row even in the
+     * empty area below the last one, so the cell bounds are checked as well.
+     */
+    @PackageScope
+    static int completionRowAt(JList<?> list, Point point) {
+        if (list == null || point == null || list.model == null) {
+            return -1
+        }
+        int index = list.locationToIndex(point)
+        if (index < 0 || index >= list.model.size) {
+            return -1
+        }
+        Rectangle bounds = list.getCellBounds(index, index)
+        if (bounds == null || !bounds.contains(point)) {
+            return -1
+        }
+        index
+    }
+
+    /**
+     * Up and down move the selection. Enter inserts it. Escape closes the
+     * list. Other keys are left for the editor.
+     *
+     * @return {@code true} when the event was consumed
+     */
+    private boolean handleCompletionKey(KeyEvent event) {
+        int code = event.keyCode
+        if (code == KeyEvent.VK_ESCAPE) {
+            hideCompletion()
+            event.consume()
+            return true
+        }
+        if (code == KeyEvent.VK_ENTER) {
+            chooseArmed()
+            event.consume()
+            return true
+        }
+        if (code == KeyEvent.VK_DOWN) {
+            moveSelection(1)
+            event.consume()
+            return true
+        }
+        if (code == KeyEvent.VK_UP) {
+            moveSelection(-1)
+            event.consume()
+            return true
+        }
+        false
+    }
+
+    private void moveSelection(int delta) {
+        if (completionList == null || completionModel == null || completionModel.size() == 0) {
+            return
+        }
+        int next = completionList.selectedIndex + delta
+        if (next < 0) {
+            next = 0
+        }
+        if (next >= completionModel.size()) {
+            next = completionModel.size() - 1
+        }
+        completionList.selectedIndex = next
+        completionList.ensureIndexIsVisible(next)
+    }
+
+    private void chooseArmed() {
+        if (completionList == null || completionModel == null) {
+            return
+        }
+        int index = completionList.selectedIndex
+        if (index < 0 || index >= completionModel.size()) {
+            return
+        }
+        Candidate item = completionModel.getElementAt(index)
+        int end = completionEnd
+        hideCompletion()
+        if (input != null && end >= 0) {
+            int length = textOf(input).length()
+            input.caretPosition = Math.min(Math.max(end, 0), length)
+        }
+        acceptCompletion(item)
+    }
+
+    private void hideCompletion() {
+        completionOpen = false
+        completionAnchor = -1
+        completionEnd = -1
+        completionAll.clear()
+        JPopupMenu menu = completionPopup
+        completionPopup = null
+        completionList = null
+        completionModel = null
+        if (menu != null && menu.visible) {
+            menu.visible = false
+        }
+    }
+
+    private void onDocumentEdit(DocumentEvent event) {
+        if (!completionOpen || event == null) {
+            return
+        }
+        int length = event.length
+        int offset = event.offset
+        if (event.type == DocumentEvent.EventType.INSERT) {
+            if (offset < completionAnchor) {
+                completionAnchor += length
+                completionEnd += length
+            } else if (offset <= completionEnd) {
+                completionEnd += length
+            } else {
+                hideCompletion()
+                return
+            }
+        } else if (event.type == DocumentEvent.EventType.REMOVE) {
+            int end = offset + length
+            if (offset < completionAnchor && end > completionAnchor) {
+                hideCompletion()
+                return
+            }
+            if (end <= completionAnchor) {
+                completionAnchor -= length
+                completionEnd -= length
+            } else if (offset < completionEnd) {
+                completionEnd -= Math.min(end, completionEnd) - offset
+                if (completionEnd < completionAnchor) {
+                    completionEnd = completionAnchor
+                }
+            } else {
+                hideCompletion()
+                return
+            }
+        }
+        refillCompletion()
+    }
+
+    private void refillCompletion() {
+        if (!completionOpen || completionList == null || completionModel == null || input == null) {
+            return
+        }
+        String text = textOf(input)
+        if (completionAnchor < 0 || completionEnd < completionAnchor || completionEnd > text.length()) {
+            hideCompletion()
+            return
+        }
+        String prefix = text.substring(completionAnchor, completionEnd)
+        for (int i = 0; i < prefix.length(); i++) {
+            if (!Character.isJavaIdentifierPart(prefix.charAt(i))) {
+                hideCompletion()
+                return
+            }
+        }
+        completionModel.clear()
+        for (Candidate item : completionAll) {
+            if (matchesPrefix(item?.label(), prefix)) {
+                completionModel.addElement(item)
+            }
+        }
+        int rows = Math.min(VISIBLE_ROWS, Math.max(completionModel.size(), 1))
+        completionList.visibleRowCount = rows
+        if (completionModel.size() > 0) {
+            completionList.selectedIndex = 0
+            completionList.ensureIndexIsVisible(0)
+        }
+        Container view = completionList.parent
+        if (view?.parent instanceof JScrollPane) {
+            JScrollPane scroll = (JScrollPane) view.parent
+            int rowHeight = completionList.fixedCellHeight > 0 ? completionList.fixedCellHeight : 18
+            Dimension current = scroll.preferredSize
+            scroll.preferredSize = new Dimension((int) current.width, rowHeight * rows)
+        }
+    }
+
+    /**
+     * Start of the identifier that touches {@code caret}, or {@code caret}
+     * when the caret is not inside a name.
+     */
+    @PackageScope
+    static int prefixStart(String text, int caret) {
+        if (!text || caret <= 0) {
+            return 0
+        }
+        int start = Math.min(caret, text.length())
+        while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - 1))) {
+            start--
+        }
+        start
+    }
+
+    /**
+     * Case-insensitive prefix match. An empty prefix matches every label.
+     */
+    @PackageScope
+    static boolean matchesPrefix(String label, String prefix) {
+        if (!prefix) {
+            return true
+        }
+        if (!label || label.length() < prefix.length()) {
+            return false
+        }
+        label.regionMatches(true, 0, prefix, 0, prefix.length())
+    }
+
+    /**
+     * Text written into the editor. A method contributes its name.
+     * Parameter placeholders stay in the list and are not inserted.
+     * Snippets still expand.
+     */
+    @PackageScope
+    static String completionInsert(Candidate item) {
+        if (item == null) {
+            return ''
+        }
+        String kind = item.kind()?.toLowerCase(Locale.ROOT)
+        if (kind == 'method' || kind == 'function') {
+            return item.label() ?: ''
+        }
+        flattenSnippet(item.insertText ?: item.label)
+    }
+
+    @PackageScope
+    void acceptCompletion(Candidate item) {
+        insertAtCaret(input, completionInsert(item))
+    }
+
+    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 = 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<Candidate> items) {
+        if (input == null || !items) {
+            hideCompletion()
+            return
+        }
+        String text = textOf(input)
+        int caret = Math.min(Math.max(input.caretPosition, 0), text.length())
+        JList<Candidate> list = armCompletion(items, prefixStart(text, caret))
+        JPopupMenu menu = completionPopup
+        if (list == null || menu == null) {
+            return
+        }
+        try {
+            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) {
+        }
+    }
+
+    /**
+     * Document text, not {@link JTextPane#getText()}. The editor kit
+     * rewrites line breaks using the document's end-of-line property, so
+     * the returned string can be longer than the document. Caret offsets
+     * are document offsets; using the rewritten text shifts the caret
+     * backward by one character per break and makes {@code a.} complete
+     * as the prefix {@code a}.
+     */
+    @PackageScope
+    static String textOf(JTextPane pane) {
+        if (pane?.document == null) {
+            return ''
+        }
+        try {
+            return pane.document.getText(0, pane.document.length) ?: ''
+        } catch (BadLocationException ignored) {
+            return ''
+        }
+    }
+
+    /**
+     * 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
+    }
+
+    @PackageScope
+    static int[] positionOf(String text, int offset) {
+        if (!text || offset <= 0) {
+            return [0, 0] as int[]
+        }
+        int capped = Math.min(offset, text.length())
+        int line = 0
+        int character = 0
+        for (int i = 0; i < capped; i++) {
+            char c = text.charAt(i)
+            if (c == '\n' as char) {
+                line++
+                character = 0
+            } else if (c == '\r' as char) {
+                if (i + 1 < text.length() && text.charAt(i + 1) == '\n' as char) {
+                    i++
+                }
+                line++
+                character = 0
+            } else {
+                character++
+            }
+        }
+        [line, character] as int[]
+    }
+
+    @PackageScope
+    static int offsetOf(String text, int line, int character) {
+        if (!text) {
+            return 0
+        }
+        int currentLine = 0
+        int i = 0
+        int target = Math.max(line, 0)
+        while (i < text.length() && currentLine < target) {
+            char c = text.charAt(i)
+            if (c == '\n' as char) {
+                currentLine++
+            } else if (c == '\r' as char) {
+                if (i + 1 < text.length() && text.charAt(i + 1) == '\n' as char) {
+                    i++
+                }
+                currentLine++
+            }
+            i++
+        }
+        if (currentLine < target) {
+            return text.length()
+        }
+        int column = Math.max(character, 0)
+        int end = i
+        while (end < text.length()) {
+            char c = text.charAt(end)
+            if (c == '\n' as char || c == '\r' as char) {
+                break
+            }
+            end++
+        }
+        Math.min(i + column, end)
+    }
+
+    @PackageScope
+    static int[] spanOf(String text, int startLine, int startCharacter, int endLine, 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 (start >= length) {
+                // The range is an insertion point after the buffer, typical of
+                // "expected more input". Painting the previous character makes
+                // that character look like the error.
+                return [length, length] as int[]
+            }
+            end = Math.min(start + 1, length)
+        }
+        [start, end] as int[]
+    }
+
+    @PackageScope
+    static String flattenSnippet(String insert) {
+        if (!insert) {
+            return ''
+        }
+        insert.replaceAll(/\$\{\d+:([^}]*)}/, '$1').replaceAll(/\$\d+/, '')
+    }
+
+    @PackageScope
+    static final class Hit {
+        final int startLine
+        final int startCharacter
+        final int endLine
+        final int endCharacter
+        final String message
+
+        private Hit(int startLine, int startCharacter, int endLine, int endCharacter, String message) {
+            this.startLine = startLine
+            this.startCharacter = startCharacter
+            this.endLine = endLine
+            this.endCharacter = endCharacter
+            this.message = message
+        }
+
+        String message() { message }
+
+        static Hit from(Diagnostic diagnostic) {
+            def range = diagnostic?.range
+            def start = range?.start
+            def end = range?.end
+            new Hit((start?.line ?: 0) as int, (start?.character ?: 0) as int,
+                    (end?.line ?: 0) as int, (end?.character ?: 0) as int,
+                    GroovyLanguageClient.diagnosticText(diagnostic))
+        }
+    }
+
+    @PackageScope
+    static final class Candidate {
+        final String label
+        final String kind
+        final String detail
+        final String insertText
+
+        private Candidate(String label, String kind, String detail, String insertText) {
+            this.label = label
+            this.kind = kind
+            this.detail = detail
+            this.insertText = insertText
+        }
+
+        String label() { label }
+
+        String kind() { kind }
+
+        String detail() { detail }
+
+        String insertText() { insertText }
+
+        static Candidate from(CompletionItem item) {
+            new Candidate(item?.label, item?.kind?.name(), item?.detail, item?.insertText ?: item?.label)
+        }
+
+        static Candidate of(String label, String kind, String detail, String insertText) {
+            new Candidate(label, kind, detail, insertText)
+        }
+    }
+
+    /**
+     * IntelliJ's one-letter kind mark. Methods and static methods are {@code m}.
+     * An unknown kind is {@code ?}.
+     */
+    @PackageScope
+    static String kindMark(String kind) {
+        switch (kind == null ? '' : kind.toLowerCase(Locale.ROOT)) {
+            case 'method':
+            case 'function':
+                return 'm'
+            case 'field':
+                return 'f'
+            case 'property':
+                return 'p'
+            case 'variable':
+                return 'v'
+            case 'class':
+                return 'c'
+            case 'interface':
+                return 'i'
+            case 'enum':
+                return 'e'
+            case 'keyword':
+                return 'k'
+            case 'snippet':
+                return 's'
+            default:
+                return '?'
+        }
+    }
+
+    /**
+     * Fill color for {@link #kindMark}. Kinds that share a letter still share
+     * a color; every kind color differs from ordinary name text.
+     */
+    @PackageScope
+    static Color kindColor(String kind) {
+        switch (kind == null ? '' : kind.toLowerCase(Locale.ROOT)) {
+            case 'method':
+            case 'function':
+                return KIND_METHOD
+            case 'field':
+                return KIND_FIELD
+            case 'property':
+                return KIND_PROPERTY
+            case 'variable':
+                return KIND_VARIABLE
+            case 'class':
+                return KIND_CLASS
+            case 'interface':
+                return KIND_INTERFACE
+            case 'enum':
+                return KIND_ENUM
+            case 'keyword':
+                return KIND_KEYWORD
+            case 'snippet':
+                return KIND_SNIPPET
+            default:
+                return KIND_OTHER
+        }
+    }
+
+    /**
+     * Parameter list and return type parsed from a method signature such as
+     * {@code boolean add(Object e)}.
+     */
+    @PackageScope
+    static final class SignatureParts {
+        final String parameters
+        final String returns
+
+        private SignatureParts(String parameters, String returns) {
+            this.parameters = parameters
+            this.returns = returns
+        }
+
+        static SignatureParts of(Candidate item) {
+            String detail = item?.detail()
+            if (!detail) {
+                return null
+            }
+            Matcher matcher = SIGNATURE.matcher(detail)
+            if (!matcher.matches()) {
+                return null
+            }
+            new SignatureParts(matcher.group(3), matcher.group(1).trim())
+        }
+    }
+
+    /**
+     * One completion row: the name in the list color, the parameter types and
+     * names in gray, and the return type in gray at the right.
+     */
+    @CompileStatic
+    private static final class CompletionRenderer implements ListCellRenderer<Candidate> {
+        @Override
+        Component getListCellRendererComponent(JList<? extends Candidate> list, Candidate value, int index,
+                                               boolean selected, boolean focus) {
+            SignatureParts parts = SignatureParts.of(value)
+            JPanel row = new JPanel(new BorderLayout(0, 0))
+            row.border = BorderFactory.createEmptyBorder(2, 8, 2, 8)
+            row.opaque = true
+            Color rowBackground = selected ? list.selectionBackground : list.background
+            Color rowForeground = selected ? list.selectionForeground : list.foreground
+            row.background = rowBackground
+            JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0))
+            left.opaque = false
+            String kind = value == null ? null : value.kind()
+            KindBadge badge = new KindBadge(ConsoleLanguageHost.kindMark(kind), ConsoleLanguageHost.kindColor(kind))
+            left.add(badge)
+            left.add(Box.createHorizontalStrut(8))
+            JLabel name = new JLabel(value?.label() ?: '')
+            name.name = 'completion-name'
+            name.foreground = rowForeground
+            left.add(name)
+            // A fixed light gray disappears on a light selection. Mix the row's
+            // own foreground into its background so parameters and the return
+            // type stay visible, and still quieter than the name.
+            Color muted = secondary(rowForeground, rowBackground)
+            if (parts != null) {
+                JLabel parameters = new JLabel('(' + parts.parameters + ')')
+                parameters.name = 'completion-parameters'
+                parameters.foreground = muted
+                left.add(parameters)
+            }
+            row.add(left, BorderLayout.CENTER)
+            JLabel returns = new JLabel(parts?.returns ?: '')
+            returns.name = 'completion-return'
+            returns.horizontalAlignment = JLabel.RIGHT
+            returns.border = BorderFactory.createEmptyBorder(0, 32, 0, 0)
+            returns.foreground = muted
+            row.add(returns, BorderLayout.EAST)
+            row
+        }
+
+        /** Foreground pulled part way toward the row background. */
+        private static Color secondary(Color foreground, Color background) {
+            Color ink = foreground == null ? Color.BLACK : foreground
+            Color paper = background == null ? Color.WHITE : background
+            int towardPaper = 45
+            int keep = 100 - towardPaper
+            int r = (ink.red * keep + paper.red * towardPaper).intdiv(100)
+            int g = (ink.green * keep + paper.green * towardPaper).intdiv(100)
+            int b = (ink.blue * keep + paper.blue * towardPaper).intdiv(100)
+            new Color(r, g, b)
+        }
+    }
+
+    /**
+     * Colored disc with one letter. The disc, not the glyph color, carries
+     * the kind, so the mark stays separate from the name beside it.
+     */
+    @CompileStatic
+    private static final class KindBadge extends JComponent {
+        private final String letter
+        private final Color fill
+
+        KindBadge(String letter, Color fill) {
+            this.letter = letter
+            this.fill = fill
+            name = 'completion-kind'
+            putClientProperty('mark', letter)
+            putClientProperty('fill', fill)
+            toolTipText = letter
+            font = new Font(Font.SANS_SERIF, Font.BOLD, 10)
+            opaque = false
+        }
+
+        @Override
+        Dimension getPreferredSize() {
+            new Dimension(16, 16)
+        }
+
+        @Override
+        Dimension getMinimumSize() {
+            getPreferredSize()
+        }
+
+        @Override
+        void paintComponent(Graphics graphics) {
+            Graphics2D g = (Graphics2D) graphics.create()
+            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
+            g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
+            g.color = fill
+            g.fillOval(0, 0, 15, 15)
+            g.color = Color.WHITE
+            g.font = font
+            int textWidth = g.fontMetrics.stringWidth(letter)
+            int x = ((15 - textWidth) / 2) as int
+            int y = (((15 - g.fontMetrics.height) / 2) as int) + g.fontMetrics.ascent
+            g.drawString(letter, x, y)
+            g.dispose()
+        }
+    }
+
+    /**
+     * Thin red wave under a diagnostic span. A filled background hides the token.
+     */
+    private static final class WavyUnderlinePainter extends DefaultHighlighter.DefaultHighlightPainter {
+
+        private static final Color WAVE = new Color(220, 38, 38)
+        /** Half a period, in pixels. Two steps make one tight wave. */
+        private static final int STEP = 2
+        /** How far the wave rises and falls from its center. */
+        private static final int AMPLITUDE = 1
+
+        WavyUnderlinePainter() {
+            super(WAVE)
+        }
+
+        @Override
+        void paint(Graphics g, int offs0, int offs1, Shape bounds, JTextComponent component) {
+            try {
+                Rectangle start = component.UI.modelToView(component, offs0)
+                Rectangle end = component.UI.modelToView(component, offs1)
+                if (start == null || end == null) {
+                    return
+                }
+                Rectangle alloc = rectangleOf(bounds)
+                int startX = (int) start.x
+                int startY = (int) start.y
+                int row = (int) start.height
+                int endX = (int) end.x
+                int endY = (int) end.y
+                if (startY == endY) {
+                    wave(g, startX, startY + row - 2, Math.max(endX - startX, STEP))
+                } else {
+                    int left = (int) alloc.x
+                    int span = (int) alloc.width
+                    wave(g, startX, startY + row - 2, Math.max(left + span - startX, STEP))
+                    int y = startY + row
+                    while (y < endY) {
+                        wave(g, left, y + row - 2, Math.max(span, STEP))
+                        y += row
+                    }
+                    wave(g, left, endY + (int) end.height - 2, Math.max(endX - left, STEP))
+                }
+            } catch (BadLocationException ignored) {
+            }
+        }
+
+        @Override
+        Shape paintLayer(Graphics g, int offs0, int offs1, Shape bounds, JTextComponent component, View view) {
+            Rectangle area = span(offs0, offs1, bounds, view)
+            if (area == null) {
+                return null
+            }
+            wave(g, (int) area.x, (int) area.y + (int) area.height - 2, Math.max((int) area.width, STEP))
+            return area
+        }
+
+        private static Rectangle span(int offs0, int offs1, Shape bounds, View view) {
+            if (offs0 == view.startOffset && offs1 == view.endOffset) {
+                return rectangleOf(bounds)
+            }
+            try {
+                Shape shape = view.modelToView(offs0, Position.Bias.Forward, offs1, Position.Bias.Backward, bounds)
+                return shape == null ? null : rectangleOf(shape)
+            } catch (BadLocationException ignored) {
+                return null
+            }
+        }
+
+        private static Rectangle rectangleOf(Shape shape) {
+            if (shape instanceof Rectangle) {
+                return (Rectangle) shape
+            }
+            return shape.bounds
+        }
+
+        private void wave(Graphics g, int x, int y, int width) {
+            Graphics2D g2 = (Graphics2D) g.create()
+            try {
+                g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
+                g2.color = WAVE
+                g2.stroke = new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
+                int x0 = x
+                int end = x + width
+                boolean down = true
+                while (x0 < end) {
+                    int x1 = Math.min(x0 + STEP, end)
+                    int y0 = y + (down ? AMPLITUDE : -AMPLITUDE)
+                    int y1 = y + (down ? -AMPLITUDE : AMPLITUDE)
+                    g2.drawLine(x0, y0, x1, y1)
+                    x0 = x1
+                    down = !down
+                }
+            } finally {
+                g2.dispose()
+            }
+        }
+    }
+
+}
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..8531451cb4 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
@@ -18,9 +18,8 @@
  */
 package groovy.console.ui.text;
 
+import groovy.console.ui.text.SmartDocumentFilter.Delimiter;
 import groovy.lang.Tuple2;
-import groovy.lang.Tuple3;
-import org.antlr.v4.runtime.Token;
 import org.apache.groovy.util.Maps;
 
 import javax.swing.JTextPane;
@@ -35,21 +34,13 @@ import javax.swing.text.StyleConstants;
 import javax.swing.text.StyleContext;
 import java.awt.Color;
 import java.util.ArrayDeque;
-import java.util.Arrays;
-import java.util.Collections;
 import java.util.Deque;
 import java.util.List;
 import java.util.ListIterator;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static groovy.lang.Tuple.tuple;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.LBRACE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.LBRACK;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.LPAREN;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.RBRACE;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.RBRACK;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.RPAREN;
-import static org.apache.groovy.parser.antlr4.GroovyLexer.SAFE_INDEX;
 
 /**
  * Represents highlighter to highlight matched parentheses, brackets and curly braces when caret touching them
@@ -60,16 +51,19 @@ public class MatchingHighlighter implements CaretListener {
     private final SmartDocumentFilter smartDocumentFilter;
     private final JTextPane textEditor;
     private final DefaultStyledDocument doc;
-    private static final Map<String, Tuple3<Integer, List<Integer>, Boolean>> PAREN_MAP = Maps.of(
-            "(", tuple(LPAREN, Collections.singletonList(RPAREN), true),
-            ")", tuple(RPAREN, Collections.singletonList(LPAREN), false),
-            "?[", tuple(SAFE_INDEX, Collections.singletonList(RBRACK), true),
-            "[", tuple(LBRACK, Collections.singletonList(RBRACK), true),
-            "]", tuple(RBRACK, Arrays.asList(LBRACK, SAFE_INDEX), false),
-            "{", tuple(LBRACE, Collections.singletonList(RBRACE), true),
-            "}", tuple(RBRACE, Collections.singletonList(LBRACE), false)
+    /** Matched bracket texts, and whether the scan runs forward from the opener. */
+    private static final Map<String, Tuple2<List<String>, Boolean>> PAREN_MAP = Maps.of(
+            "(", tuple(List.of(")"), true),
+            ")", tuple(List.of("("), false),
+            "?[", tuple(List.of("]"), true),
+            "[", tuple(List.of("]"), true),
+            "]", tuple(List.of("[", "?["), false),
+            "{", tuple(List.of("}"), true),
+            "}", tuple(List.of("{"), false)
     );
-    private volatile List<Tuple3<Integer, Position, Integer>> highlightedTokenInfoList = Collections.emptyList();
+    /** Replaced as a whole. The list itself is never mutated after publication. */
+    private final AtomicReference<List<Tuple2<Position, Integer>>> highlightedTokenInfoList =
+            new AtomicReference<>(List.of());
 
     /**
      * Creates a highlighter bound to the supplied document filter and editor.
@@ -151,28 +145,24 @@ public class MatchingHighlighter implements CaretListener {
     }
 
     private void highlightMatched(int offset, String p) {
-        List<Token> latestTokenList = smartDocumentFilter.getLatestTokenList();
-        Tuple3<Integer, List<Integer>, Boolean> tokenTypeTuple = PAREN_MAP.get(p);
-        int triggerTokenType = tokenTypeTuple.getV1();
-        List<Integer> matchedTokenTypeList = tokenTypeTuple.getV2();
-        boolean normalOrder = tokenTypeTuple.getV3();
-        Deque<Tuple2<Token, Boolean>> stack = new ArrayDeque<>();
+        List<Delimiter> latestTokenList = smartDocumentFilter.getLatestTokenList();
+        Tuple2<List<String>, Boolean> pair = PAREN_MAP.get(p);
+        List<String> matchedText = pair.getV1();
+        boolean normalOrder = pair.getV2();
+        Deque<Tuple2<Delimiter, Boolean>> stack = new ArrayDeque<>();
 
-        Token triggerToken = null;
-        Token matchedToken = null;
+        Delimiter triggerToken = null;
+        Delimiter matchedToken = null;
 
-        for (ListIterator<Token> iterator = latestTokenList.listIterator(normalOrder ? 0 : latestTokenList.size());
+        for (ListIterator<Delimiter> iterator = latestTokenList.listIterator(normalOrder ? 0 : latestTokenList.size());
              normalOrder ? iterator.hasNext() : iterator.hasPrevious(); ) {
-            Token token = normalOrder ? iterator.next() : iterator.previous();
+            Delimiter token = normalOrder ? iterator.next() : iterator.previous();
 
-            int tokenType = token.getType();
-            if (tokenType == triggerTokenType) {
-                Boolean triggerFlag = offset == token.getStartIndex();
-
-                stack.push(tuple(token, triggerFlag));
-            } else if (matchedTokenTypeList.contains(tokenType)) {
+            if (p.equals(token.getText())) {
+                stack.push(tuple(token, offset == token.getOffset()));
+            } else if (matchedText.contains(token.getText())) {
                 if (!stack.isEmpty()) {
-                    Tuple2<Token, Boolean> tokenAndTriggerFlagTuple = stack.pop();
+                    Tuple2<Delimiter, Boolean> tokenAndTriggerFlagTuple = stack.pop();
                     if (tokenAndTriggerFlagTuple.getV2()) {
                         triggerToken = tokenAndTriggerFlagTuple.getV1();
                         matchedToken = token;
@@ -186,10 +176,10 @@ public class MatchingHighlighter implements CaretListener {
             highlightToken(p, triggerToken);
             highlightToken(p, matchedToken);
             try {
-                highlightedTokenInfoList = Arrays.asList(
-                        tuple(triggerToken.getType(), doc.createPosition(triggerToken.getStartIndex()), triggerToken.getText().length()),
-                        tuple(matchedToken.getType(), doc.createPosition(matchedToken.getStartIndex()), matchedToken.getText().length())
-                );
+                highlightedTokenInfoList.set(List.of(
+                        tuple(doc.createPosition(triggerToken.getOffset()), triggerToken.getLength()),
+                        tuple(doc.createPosition(matchedToken.getOffset()), matchedToken.getLength())
+                ));
             } catch (BadLocationException e) {
                 e.printStackTrace();
             }
@@ -204,7 +194,7 @@ public class MatchingHighlighter implements CaretListener {
     private final Style defaultStyle = styleContext.getStyle(StyleContext.DEFAULT_STYLE);
 
     private void createHighlightedStyleByParen(String p) {
-        Style style = StyleContext.getDefaultStyleContext().addStyle(highlightedStyleName(p), findStyleByTokenType(PAREN_MAP.get(p).getV1()));
+        Style style = StyleContext.getDefaultStyleContext().addStyle(highlightedStyleName(p), defaultStyle);
         StyleConstants.setForeground(style, Color.YELLOW.darker());
         StyleConstants.setBold(style, true);
     }
@@ -219,32 +209,29 @@ public class MatchingHighlighter implements CaretListener {
         return null == style ? defaultStyle : style;
     }
 
-    private Style findStyleByTokenType(int tokenType) {
-        Style style = styleContext.getStyle(String.valueOf(tokenType));
-
-        return null == style ? defaultStyle : style;
-    }
-
-    private void highlightToken(String p, final Token tokenToHighlight) {
+    private void highlightToken(String p, final Delimiter tokenToHighlight) {
         Style style = findHighlightedStyleByParen(p);
-        doc.setCharacterAttributes(tokenToHighlight.getStartIndex(),
-                tokenToHighlight.getText().length(),
+        doc.setCharacterAttributes(tokenToHighlight.getOffset(),
+                tokenToHighlight.getLength(),
                 style,
                 true);
     }
 
     private void clearHighlighted() {
-        if (!highlightedTokenInfoList.isEmpty()) {
-            for (Tuple3<Integer, Position, Integer> highlightedTokenInfo : highlightedTokenInfoList) {
-                doc.setCharacterAttributes(
-                        highlightedTokenInfo.getV2().getOffset(),
-                        highlightedTokenInfo.getV3(),
-                        findStyleByTokenType(highlightedTokenInfo.getV1()),
-                        true
-                );
-            }
-
-            highlightedTokenInfoList = Collections.emptyList();
+        List<Tuple2<Position, Integer>> highlighted = highlightedTokenInfoList.get();
+        if (highlighted.isEmpty()) {
+            return;
+        }
+        for (Tuple2<Position, Integer> highlightedTokenInfo : highlighted) {
+            // Delimiters keep the default style. A saved attribute set can
+            // land on the next token after the matched brace is deleted.
+            doc.setCharacterAttributes(
+                    highlightedTokenInfo.getV1().getOffset(),
+                    highlightedTokenInfo.getV2(),
+                    defaultStyle,
+                    true
+            );
         }
+        highlightedTokenInfoList.set(List.of());
     }
 }
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..65d2fc4978 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,9 @@
  */
 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.GroovyLanguageClient;
 
 import javax.swing.text.AttributeSet;
 import javax.swing.text.BadLocationException;
@@ -39,115 +30,57 @@ 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.ArrayList;
 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;
-
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
- * 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 ({@code textDocument/semanticTokens}). The request does not
+ * 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 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. Structural brackets,
+ * including {@code ?[}, are {@code delimiter} tokens on that same
+ * response. {@link #getLatestTokenList()} exposes them for brace
+ * matching. The filter does not lex the buffer itself.
  *
  * @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 static final int TYPE_KEYWORD = 8;
+    private static final int TYPE_STRING = 9;
+    private static final int TYPE_NUMBER = 10;
+    private static final int TYPE_COMMENT = 11;
+    private static final int TYPE_OPERATOR = 12;
+    private static final int TYPE_DECORATOR = 13;
+    private static final int TYPE_DELIMITER = 14;
+    private static final int MOD_SLASHY = 8;
+    private static final int MOD_SINGLE = 16;
+
     private final DefaultStyledDocument styledDocument;
     private final StyleContext styleContext;
     private final Style defaultStyle;
+    private final GroovyLanguageClient language;
 
     /**
      * Creates a filter that styles the supplied document.
@@ -162,6 +95,7 @@ public class SmartDocumentFilter extends DocumentFilter {
         StyleConstants.setFontFamily(this.defaultStyle, MONOSPACED);
 
         initStyles();
+        this.language = GroovyLanguageClient.open("groovy-buffer:highlight-" + System.identityHashCode(this), "", false);
     }
 
     /**
@@ -170,9 +104,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 +115,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 +126,133 @@ 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;
+        String text = styledDocument.getText(0, styledDocument.getLength());
+        List<Integer> data = language.semanticTokens(text);
+        int[] dirty = dirtyRange(text);
         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;
-        try {
-            tokenListToRender = findTokensToRender(tokenList);
+            this.latestTokenList.set(List.copyOf(paintSemantic(text, data, dirty[0], dirty[1])));
+            this.latest = true;
         } 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;
-            }
-
-            styledDocument.setCharacterAttributes(tokenStartIndex,
-                    tokenLength,
-                    isAnnotationPart ? findStyleByTokenType(AT) : findStyleByTokenType(tokenType),
-                    true);
-
-            if (GStringBegin == tokenType || GStringPart == tokenType) {
-                styledDocument.setCharacterAttributes(
-                        tokenStartIndex + tokenLength - 1,
-                        1,
-                        defaultStyle,
-                        true);
-            }
-        }
-
-        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 int[] dirtyRange(String text) {
+        int last = text.length() - 1;
+        if (last < 0) {
+            return new int[] {0, -1};
         }
-
-        List<Token> tmpLatestTokenList = filterNewlines(this.latestTokenList);
-        int latestTokenListSize = tmpLatestTokenList.size();
-        if (0 == latestTokenListSize) {
-            return tokenList;
+        Tuple2<Integer, Integer> range = getRenderRange();
+        if (range == null) {
+            return new int[] {0, last};
         }
-
-        List<Token> tmpTokenList = filterNewlines(tokenList);
-        int tmpTokenListSize = tmpTokenList.size();
-        if (0 == tmpTokenListSize) {
-            return tokenList;
+        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};
+    }
 
-        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;
+    private List<Delimiter> paintSemantic(String text, List<Integer> data, int dirtyStart, int dirtyStop) {
+        List<Delimiter> delimiters = new ArrayList<>();
+        if (!text.isEmpty() && dirtyStart <= dirtyStop) {
+            styledDocument.setCharacterAttributes(dirtyStart, dirtyStop - dirtyStart + 1, defaultStyle, true);
+        }
+        int line = 0;
+        int character = 0;
+        for (int i = 0; i + 4 < data.size(); i += 5) {
+            int deltaLine = data.get(i);
+            line += deltaLine;
+            character = deltaLine == 0 ? character + data.get(i + 1) : data.get(i + 1);
+            int length = data.get(i + 2);
+            int start = offsetOf(text, line, character);
+            int end = Math.min(start + Math.max(length, 0), text.length());
+            if (end > start) {
+                int type = data.get(i + 3);
+                if (type == TYPE_DELIMITER) {
+                    delimiters.add(new Delimiter(start, end - start, text.substring(start, end)));
+                } else if (end > dirtyStart && start <= dirtyStop) {
+                    Style style = styleFor(type, data.get(i + 4));
+                    styledDocument.setCharacterAttributes(start, end - start, style, true);
+                }
             }
-
-            startTokenIndex = i;
-            break;
         }
+        return delimiters;
+    }
 
-        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;
+    private Style styleFor(int type, int modifiers) {
+        if (type == TYPE_KEYWORD) {
+            return styleNamed(STYLE_RESERVED);
+        }
+        if (type == TYPE_STRING) {
+            if ((modifiers & MOD_SLASHY) != 0) {
+                return styleNamed(STYLE_SLASHY_QUOTES);
             }
-
-            stopTokenIndex = tmpTokenListSize - i;
-            break;
+            if ((modifiers & MOD_SINGLE) != 0) {
+                return styleNamed(STYLE_SINGLE_QUOTES);
+            }
+            return styleNamed(STYLE_QUOTES);
         }
-
-        if (startTokenIndex == stopTokenIndex) {
-            return tmpTokenListSize != latestTokenListSize ? tokenList : Collections.emptyList();
-        } else if (startTokenIndex < stopTokenIndex) {
-            return tmpTokenList.subList(startTokenIndex, stopTokenIndex);
+        if (type == TYPE_NUMBER) {
+            return styleNamed(STYLE_DIGIT);
         }
-
-        return tokenList; // should never reach here. If unexpected error occurred, it's better to render all tokens
-    }
-
-    /**
-     * Returns line number starting with 1
-     */
-    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);
+        if (type == TYPE_COMMENT) {
+            return styleNamed(STYLE_COMMENT);
         }
-
-        return result;
+        if (type == TYPE_DECORATOR) {
+            return styleNamed(STYLE_ANNOTATION);
+        }
+        if (type == TYPE_OPERATOR) {
+            return styleNamed(STYLE_OPERATION);
+        }
+        return styleNamed(STYLE_IDENT);
     }
 
-    private List<Token> filterNewlines(List<Token> tokenList) {
-        return tokenList.stream()
-                .filter(e -> !(NL == e.getType() && e.getText().trim().isEmpty()))
-                .collect(Collectors.toList());
+    private static int offsetOf(String text, int line, int character) {
+        int currentLine = 0;
+        int i = 0;
+        while (i < text.length() && currentLine < line) {
+            char c = text.charAt(i);
+            if (c == '\n') {
+                currentLine++;
+            } else if (c == '\r') {
+                if (i + 1 < text.length() && text.charAt(i + 1) == '\n') {
+                    i++;
+                }
+                currentLine++;
+            }
+            i++;
+        }
+        return Math.min(i + Math.max(character, 0), text.length());
     }
 
-    private Style findStyleByTokenType(int tokenType) {
-        Style style = styleContext.getStyle(String.valueOf(tokenType));
-
+    private Style styleNamed(String themeKey) {
+        Style style = styleContext.getStyle(themeKey);
         return null == style ? defaultStyle : style;
     }
 
-    private Style createDefaultStyleByTokenType(int tokenType) {
-        return styleContext.addStyle(String.valueOf(tokenType), defaultStyle);
-    }
-
-    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 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();
     }
 
@@ -435,7 +264,7 @@ public class SmartDocumentFilter extends DocumentFilter {
      * @since 6.0.0
      */
     public void reparseDocument() {
-        this.latestTokenList = Collections.emptyList();
+        this.latestTokenList.set(List.of());
         try {
             parseDocument();
         } catch (BadLocationException e) {
@@ -446,48 +275,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,16 +305,27 @@ 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 color) {
+            StyleConstants.setForeground(style, color);
+        }
         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 color) {
+            StyleConstants.setBackground(style, color);
+        }
+        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;
-    private volatile List<Token> latestTokenList = Collections.emptyList();
+    /** Replaced as a whole. Callers receive an unmodifiable snapshot. */
+    private final AtomicReference<List<Delimiter>> latestTokenList = new AtomicReference<>(List.of());
     private volatile Tuple2<Integer, Integer> renderRange;
 
     /**
@@ -517,12 +338,13 @@ public class SmartDocumentFilter extends DocumentFilter {
     }
 
     /**
-     * Returns the tokens produced by the most recent successful parse.
+     * Structural brackets from the most recent semantic-token response.
+     * Brackets inside strings and comments are absent.
      *
-     * @return the latest token list
+     * @return delimiter spans, never {@code null}
      */
-    public List<Token> getLatestTokenList() {
-        return latestTokenList;
+    public List<Delimiter> getLatestTokenList() {
+        return latestTokenList.get();
     }
 
     /**
@@ -542,4 +364,31 @@ public class SmartDocumentFilter extends DocumentFilter {
     public Tuple2<Integer, Integer> getRenderRange() {
         return renderRange;
     }
+
+    /**
+     * One structural bracket from {@code textDocument/semanticTokens}.
+     */
+    public static final class Delimiter {
+        private final int offset;
+        private final int length;
+        private final String text;
+
+        Delimiter(int offset, int length, String text) {
+            this.offset = offset;
+            this.length = length;
+            this.text = text;
+        }
+
+        public int getOffset() {
+            return offset;
+        }
+
+        public int getLength() {
+            return length;
+        }
+
+        public String getText() {
+            return text;
+        }
+    }
 }
diff --git a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
index 871f99e0f5..747033807a 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -161,13 +161,45 @@ 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
+(`textDocument/semanticTokens` on each edit, without compiling). 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.
+Matching brackets, including `?[`, come from the same semantic tokens, so a bracket inside a string or comment is not paired.
+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
-* Clicking the line and column of a compilation error, or a stack-trace
+* Error hyperlinking from the output area when a compilation error is
+expected or when an exception is thrown. Clicking the line and column of a compilation error, or a stack-trace
 reference to the script being edited, moves the caret to that position
 without selecting any characters
+* The editor compiles the unsaved buffer in the background and marks
+compiler diagnostics with a thin red wavy underline. A diagnostic that only marks the end of the buffer,
+such as unfinished input (`a.` or `a?.`), does not paint the operator or the
+character before the caret.
+`Alt+/` offers compiler-backed completions
+(`Ctrl+Space` is left free for the system input method).
+After `.`, `?.`, or `*.`, the list is the methods, fields, and properties
+that receiver can access, including when the statement is not finished yet.
+A method is shown with its parameter types and names, and its return type
+at the right of the row, apart from the name and the parameters.
+The list stays open while you type. Entries narrow to names that start
+with the characters after the list opened. Up and down move the selection,
+and moving the pointer over a row selects it the same way.
+Enter inserts the selected method name only. Parameter names stay in the
+list and are not written into the buffer. Escape closes the list.
+Each entry starts with a colored initial for its kind (`m` method, `f` field,
+`p` property, `v` variable, `c` class, `i` interface, `e` enum, `k` keyword,
+`s` snippet), drawn in its own disc so the mark is not part of the name.
+The list scrolls when there are more entries than fit.
+Accepting an entry inserts the whole name at the caret. The characters
+already typed after the dot are the only prefix that is replaced.
+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
@@ -259,7 +291,7 @@ image:{reldir_console}/assets/img/astbrowser_bytecode.png[AST Browser]
 [[GroovyConsole-CSTbrowser]]
 == Advanced debugging: CST browser
 
-Groovy Console can visualize the CST (Concrete Syntax Tree) representing
+Groovy Console can visualize the CST (Concrete Syntax Tree) that groovy-lsp builds for the current buffer, representing
 the initial parsing of the script. This is mainly useful for parsing gurus.
 
 image:{reldir_console}/assets/img/cstbrowser.png[CST Browser]
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..26abb55409
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleLanguageWiringTest.groovy
@@ -0,0 +1,176 @@
+/*
+ *  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.JLabel
+import javax.swing.JPanel
+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')
+        console.inspectCst(null)
+        console.inspectTokens(null)
+
+        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.
+        }
+        def show = Console.getDeclaredMethod('showComponent', String, Component)
+        show.accessible = true
+        show.invoke(console, 'Tokens', new JLabel('x'))
+        try {
+            console.fileNewWindow(null)
+        } catch (HeadlessException | ExceptionInInitializerError | NoClassDefFoundError ignored) {
+            // A second window needs a display.
+        }
+        try {
+            console.run()
+        } catch (HeadlessException | ExceptionInInitializerError | NoClassDefFoundError ignored) {
+            // run() shows the frame before attaching the host.
+        }
+        def panel = new JPanel()
+        panel.metaClass.pack = { -> }
+        panel.metaClass.show = { -> }
+        panel.metaClass.getTitle = { -> 'GroovyConsole' }
+        panel.metaClass.setTitle = { String value -> }
+        def saved = Console.frameConsoleDelegates
+        try {
+            Console.frameConsoleDelegates = [
+                    rootContainerDelegate: { panel },
+                    menuBarDelegate: saved.menuBarDelegate,
+            ]
+            console.metaClass.nativeFullScreenForMac = { frame -> }
+            console.fileNewWindow(null)
+        } catch (Throwable ignored) {
+            // The stand-in frame still cannot satisfy every view binding.
+        } finally {
+            Console.frameConsoleDelegates = saved
+        }
+        try {
+            console.run(
+                    rootContainerDelegate: { panel },
+                    menuBarDelegate: saved.menuBarDelegate)
+        } catch (Throwable ignored) {
+            // installLanguageHost follows the window block when the frame is not a Window.
+        }
+        console.systemOutInterceptor?.stop()
+        console.systemErrorInterceptor?.stop()
+    }
+
+    @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..0f5d700cc0
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy
@@ -0,0 +1,1166 @@
+/*
+ *  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.GroovyLanguageClient
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.junit.jupiter.api.Test
+
+import javax.swing.Box
+import javax.swing.JLabel
+import javax.swing.JList
+import javax.swing.JPopupMenu
+import javax.swing.JScrollPane
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+import javax.swing.event.DocumentEvent
+import javax.swing.text.AttributeSet
+import javax.swing.text.BadLocationException
+import javax.swing.text.DefaultEditorKit
+import javax.swing.text.DefaultHighlighter
+import javax.swing.text.DefaultStyledDocument
+import javax.swing.text.Element
+import javax.swing.text.Highlighter
+import java.awt.BorderLayout
+import java.awt.Color
+import java.awt.Font
+import java.awt.Component
+import java.awt.Container
+import java.awt.Point
+import java.awt.Rectangle
+import java.awt.geom.Rectangle2D
+import java.awt.image.BufferedImage
+import java.awt.event.ActionEvent
+import java.awt.event.InputEvent
+import java.awt.event.KeyEvent
+import java.awt.event.MouseEvent
+
+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')
+        imports.addStarImports('java.nio.file')
+        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 ConsoleLanguageHost.offsetOf(null, 0, 0) == 0
+        assert ConsoleLanguageHost.offsetOf('', 0, 0) == 0
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 0, 0) == 0
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 1, 1) == 4
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 9, 0) == 5
+        assert ConsoleLanguageHost.offsetOf('ab', -1, 99) == 2
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 1, -3) == 3
+        assert ConsoleLanguageHost.offsetOf('ab\ncd', 0, 99) == 2
+        assert ConsoleLanguageHost.offsetOf('ab\rcd', 0, 99) == 2
+        assert ConsoleLanguageHost.positionOf(null, 0) == [0, 0] as int[]
+        assert ConsoleLanguageHost.positionOf('', 1) == [0, 0] as int[]
+        assert ConsoleLanguageHost.positionOf('ab\ncd', 0) == [0, 0] as int[]
+        assert ConsoleLanguageHost.positionOf('ab\ncd', 4) == [1, 1] as int[]
+        assert ConsoleLanguageHost.positionOf('ab\ncd', 99) == [1, 2] as int[]
+        assert ConsoleLanguageHost.positionOf('ab\r\ncd', 4) == [1, 0] as int[]
+        assert ConsoleLanguageHost.positionOf('ab\rcd', 3) == [1, 0] as int[]
+        assert ConsoleLanguageHost.offsetOf('ab\r\ncd', 1, 0) == 4
+        assert ConsoleLanguageHost.offsetOf('ab\rcd', 1, 0) == 3
+        assert ConsoleLanguageHost.flattenSnippet(null) == ''
+        assert ConsoleLanguageHost.flattenSnippet('') == ''
+        assert ConsoleLanguageHost.flattenSnippet('length') == 'length'
+        assert ConsoleLanguageHost.flattenSnippet('class ${1:Name} {\n    $0\n}\n') == 'class Name {\n    \n}\n'
+        assert ConsoleLanguageHost.flattenSnippet('$0') == ''
+        assert ConsoleLanguageHost.flattenSnippet('${1:foo}$2') == 'foo'
+        assert GroovyLanguageClient.importsOf(null) == ''
+        assert GroovyLanguageClient.starImportsOf(null) == ''
+        def starOnly = new ImportCustomizer()
+        starOnly.addStarImports('java.io')
+        def importsField = ImportCustomizer.getDeclaredField('imports')
+        importsField.accessible = true
+        def starImport = importsField.get(starOnly)[0]
+        def starField = starImport.getClass().getDeclaredField('star')
+        starField.accessible = true
+        starField.set(starImport, null)
+        def starConfig = new CompilerConfiguration()
+        starConfig.addCompilationCustomizers(starOnly)
+        assert GroovyLanguageClient.starImportsOf(starConfig) == ''
+        assert GroovyLanguageClient.classpathOf(null).empty
+        assert GroovyLanguageClient.classpathOf(new ClassLoader() {}).empty
+        def http = new URLClassLoader([URI.create('http://example.invalid/a').toURL()] as URL[])
+        assert GroovyLanguageClient.classpathOf(http).empty
+        http.close()
+        def file = new URLClassLoader([new File('.').toURI().toURL()] as URL[])
+        assert GroovyLanguageClient.classpathOf(file).size() == 1
+        file.close()
+        assert ConsoleLanguageHost.Hit.from(null).message() == ''
+        assert ConsoleLanguageHost.Candidate.from(null).label() == null
+        assert ConsoleLanguageHost.BUFFER_URI.toString() == 'groovy-buffer:console'
+    }
+
+    @Test
+    void highlightSpanClampsInvertedAndZeroWidth() {
+        assert ConsoleLanguageHost.spanOf('ab\ncd', 0, 2, 0, 0) == [2, 3] as int[]
+        assert ConsoleLanguageHost.spanOf('x', 0, 0, 0, 0) == [0, 1] as int[]
+        assert ConsoleLanguageHost.spanOf('', 0, 0, 0, 0) == [0, 0] as int[]
+        assert ConsoleLanguageHost.spanOf(null, 0, 0, 0, 0) == [0, 0] as int[]
+        assert ConsoleLanguageHost.spanOf('ab', 0, 0, 0, 2) == [0, 2] as int[]
+        assert ConsoleLanguageHost.spanOf('abc', 0, 3, 0, 3) == [3, 3] as int[]
+        assert ConsoleLanguageHost.spanOf('abc', 9, 0, 9, 0) == [3, 3] as int[]
+    }
+
+    @Test
+    void diagnosticMarkIsAThinRedWaveUnderTheSpan() {
+        def pane = new JTextPane()
+        pane.background = Color.WHITE
+        pane.foreground = Color.BLACK
+        pane.font = new Font(Font.MONOSPACED, Font.PLAIN, 16)
+        pane.text = "def a = '"
+        pane.setSize(360, 48)
+        pane.UI.getRootView(pane).setSize(360, 48)
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            host.refresh()
+            def marks = pane.highlighter.highlights
+            assert marks.length > 0
+            def painter = marks[0].painter
+            assert painter instanceof DefaultHighlighter.DefaultHighlightPainter
+            assert ((DefaultHighlighter.DefaultHighlightPainter) painter).color == new Color(220, 38, 38)
+            def image = new BufferedImage(360, 48, BufferedImage.TYPE_INT_RGB)
+            def graphics = image.createGraphics()
+            graphics.color = Color.WHITE
+            graphics.fillRect(0, 0, image.width, image.height)
+            pane.paint(graphics)
+            graphics.dispose()
+            int pink = 0
+            int red = 0
+            for (int y = 0; y < image.height; y++) {
+                for (int x = 0; x < image.width; x++) {
+                    int rgb = image.getRGB(x, y)
+                    int r = (rgb >> 16) & 0xff
+                    int g = (rgb >> 8) & 0xff
+                    int b = rgb & 0xff
+                    if (r > 240 && g > 150 && g < 210 && b > 150 && b < 210) {
+                        pink++
+                    }
+                    if (r > 180 && g < 90 && b < 90) {
+                        red++
+                    }
+                }
+            }
+            // A filled cell is hundreds of pixels of the old pink. Antialiasing
+            // the thin wave against white leaves only a few pink-looking pixels.
+            assert pink < 40
+            assert red > 0
+            assert red < 80
+            def wave = image.createGraphics()
+            wave.color = Color.WHITE
+            wave.fillRect(0, 0, image.width, image.height)
+            painter.paint(wave, marks[0].startOffset, marks[0].endOffset, new Rectangle(0, 0, 360, 48), pane)
+            pane.text = "a\nb\nc"
+            pane.UI.getRootView(pane).setSize(360, 80)
+            painter.paint(wave, 0, pane.document.length, new Rectangle(0, 0, 360, 80), pane)
+            def root = pane.UI.getRootView(pane)
+            painter.paintLayer(wave, root.startOffset, root.endOffset, new Rectangle2D.Double(1, 2, 30, 18), pane, root)
+            painter.paintLayer(wave, -1, -1, new Rectangle(0, 0, 10, 10), pane, root)
+            def bare = new JTextPane()
+            painter.paint(wave, 0, 0, new Rectangle(0, 0, 10, 10), bare)
+            wave.dispose()
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void incompleteInputDoesNotPaintTheCharacterBeforeTheCaret() {
+        ['def', 'def ', 'def a ='].each { source ->
+            def pane = new JTextPane()
+            pane.text = source
+            def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+            try {
+                host.refresh()
+                assert pane.highlighter.highlights.every { it.startOffset >= source.length() }
+            } finally {
+                host.close()
+            }
+        }
+    }
+
+    @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()
+        assert host.compileAndDiagnostics('1').empty
+        assert host.complete('1', 0, 0).empty
+        assert host.describe('1', 0, 0) == ''
+        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 == '{' }
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void dotCompletionListsMembersAndInsertsTheWholeName() {
+        def pane = new JTextPane()
+        pane.text = 'def a = new ArrayList()\ndef a.'
+        pane.document.putProperty(DefaultEditorKit.EndOfLineStringProperty, '\r\n')
+        pane.caretPosition = pane.document.length
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            String text = ConsoleLanguageHost.textOf(pane)
+            int caret = Math.min(pane.caretPosition, text.length())
+            int[] pos = ConsoleLanguageHost.positionOf(text, caret)
+            assert pos[0] == 1
+            assert pos[1] == 'def a.'.length()
+            def labels = host.complete(text, pos[0], pos[1]).collect { it.label() }
+            assert 'add' in labels
+            assert 'get' in labels
+            assert 'size' in labels
+            assert !('abstract' in labels)
+
+            def partial = 'def a = new ArrayList()\ndef a.ad'
+            def partialLabels = host.complete(partial, 1, 'def a.ad'.length()).collect { it.label() }
+            assert 'add' in partialLabels
+            assert !('size' in partialLabels)
+            assert !('abstract' in partialLabels)
+
+            host.acceptCompletion(ConsoleLanguageHost.Candidate.of('add', 'Method', 'boolean add(Object element)', 'add(${1:element})'))
+            assert pane.document.getText(0, pane.document.length).endsWith('a.add')
+            assert !pane.document.getText(0, pane.document.length).contains('element')
+        } finally {
+            host.close()
+        }
+        def keyword = new JTextPane()
+        keyword.text = 'def a = new ArrayList()\ndef a.'
+        keyword.document.putProperty(DefaultEditorKit.EndOfLineStringProperty, '\r\n')
+        keyword.caretPosition = keyword.document.length
+        ConsoleLanguageHost.insertAtCaret(keyword, 'abstract')
+        assert keyword.document.getText(0, keyword.document.length).endsWith('a.abstract')
+        def replaced = new JTextPane()
+        replaced.text = 'def a = new ArrayList()\ndef a.ad'
+        replaced.caretPosition = replaced.document.length
+        ConsoleLanguageHost.insertAtCaret(replaced, 'add')
+        assert replaced.document.getText(0, replaced.document.length).endsWith('a.add')
+    }
+
+    @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 = ConsoleLanguageHost.Candidate.of('xyz', 'method', 'String xyz(int n)', 'xyz')
+            host.completionMenu([ConsoleLanguageHost.Candidate.of(null, 'method', '', 'insert')])
+            host.acceptCompletion(ConsoleLanguageHost.Candidate.of('x', 'method', '', null))
+            def menu = host.completionMenu([candidate])
+            def list = (JList) ((JScrollPane) menu.getComponent(0)).viewport.view
+            assert list.model.size == 1
+            list.selectedIndex = 0
+            list.actionMap.get('choose').actionPerformed(new ActionEvent(list, 0, 'choose'))
+            host.acceptCompletion(candidate)
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void stringCompletionMenuStaysResponsive() {
+        def pane = new JTextPane()
+        def text = "def a = ''\na."
+        pane.text = text
+        pane.caretPosition = text.length()
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            long started = System.nanoTime()
+            def items = host.complete(text, 1, 'a.'.length())
+            long listed = (System.nanoTime() - started).intdiv(1_000_000L)
+            def labels = items.collect { it.label() }
+            assert 'splitEachLine' in labels
+            assert 'sprintf' in labels
+            started = System.nanoTime()
+            def menu = host.completionMenu(items)
+            long shown = (System.nanoTime() - started).intdiv(1_000_000L)
+            def built = (JList) ((JScrollPane) menu.getComponent(0)).viewport.view
+            assert built.fixedCellWidth > 0
+            assert built.fixedCellHeight > 0
+            assert !built.autoscrolls
+            assert listed < 5_000L : "complete ${listed} ms, ${items.size()} items"
+            assert shown < 500L : "menu ${shown} ms, ${items.size()} items"
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void completionMenuShowsParameterTypesAndNamesAndScrolls() {
+        def host = ConsoleLanguageHost.attach(new JTextPane(), ConsoleLanguageHostTest.classLoader)
+        try {
+            def indexed = ConsoleLanguageHost.Candidate.of('add', 'Method',
+                    'void add(int index, Object element)', 'add(${1:index}, ${2:element})')
+            def plain = ConsoleLanguageHost.Candidate.of('add', 'Method', 'boolean add(Object e)', 'add(${1:e})')
+            def clear = ConsoleLanguageHost.Candidate.of('clear', 'Method', 'void clear()', 'clear()')
+            def keyword = ConsoleLanguageHost.Candidate.of('abstract', 'Keyword', null, null)
+            assert ConsoleLanguageHost.SignatureParts.of(indexed).parameters == 'int index, Object element'
+            assert ConsoleLanguageHost.SignatureParts.of(indexed).returns == 'void'
+            assert ConsoleLanguageHost.SignatureParts.of(plain).parameters == 'Object e'
+            assert ConsoleLanguageHost.SignatureParts.of(plain).returns == 'boolean'
+            assert ConsoleLanguageHost.SignatureParts.of(clear).parameters == ''
+            assert ConsoleLanguageHost.SignatureParts.of(keyword) == null
+            def many = [indexed, plain, clear]
+            20.times { many << ConsoleLanguageHost.Candidate.of("m$it", 'Method', "void m$it()", "m$it()") }
+            def menu = host.completionMenu(many)
+            def scroll = (JScrollPane) menu.getComponent(0)
+            assert scroll.verticalScrollBarPolicy == JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
+            def list = (JList) scroll.viewport.view
+            assert list.model.size == many.size()
+            assert list.visibleRowCount < list.model.size
+            def row = list.cellRenderer.getListCellRendererComponent(list, indexed, 0, false, false)
+            def parameters = findLabel(row, 'completion-parameters')
+            def returns = findLabel(row, 'completion-return')
+            def name = findLabel(row, 'completion-name')
+            assert name.text == 'add'
+            def badge = findNamed(row, 'completion-kind')
+            assert badge.getClientProperty('mark') == 'm'
+            assert badge.getClientProperty('fill') == new Color(219, 92, 92)
+            assert badge.parent.getComponent(0).name == 'completion-kind'
+            assert badge.parent.getComponent(1) instanceof Box.Filler
+            assert badge.parent.getComponent(1).preferredSize.width == 8
+            assert badge.parent.getComponent(2).name == 'completion-name'
+            assert badge.preferredSize.width == 16
+            assert badge.preferredSize.height == 16
+            assert badge.minimumSize == badge.preferredSize
+            badge.setSize(16, 16)
+            def image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB)
+            def graphics = image.createGraphics()
+            badge.paint(graphics)
+            graphics.dispose()
+            assert painted(image, 219, 92, 92)
+            assert ConsoleLanguageHost.kindMark('Field') == 'f'
+            assert ConsoleLanguageHost.kindMark('Property') == 'p'
+            assert ConsoleLanguageHost.kindMark('Variable') == 'v'
+            assert ConsoleLanguageHost.kindMark('Class') == 'c'
+            assert ConsoleLanguageHost.kindMark('Interface') == 'i'
+            assert ConsoleLanguageHost.kindMark('Enum') == 'e'
+            assert ConsoleLanguageHost.kindMark('Keyword') == 'k'
+            assert ConsoleLanguageHost.kindMark('Snippet') == 's'
+            assert ConsoleLanguageHost.kindMark('Function') == 'm'
+            assert ConsoleLanguageHost.kindMark(null) == '?'
+            assert ConsoleLanguageHost.kindMark('') == '?'
+            assert ConsoleLanguageHost.kindColor('Method') == new Color(219, 92, 92)
+            assert ConsoleLanguageHost.kindColor('Function') == new Color(219, 92, 92)
+            assert ConsoleLanguageHost.kindColor('Field') == new Color(110, 127, 232)
+            assert ConsoleLanguageHost.kindColor('Property') == new Color(58, 168, 158)
+            assert ConsoleLanguageHost.kindColor('Variable') == new Color(122, 138, 153)
+            assert ConsoleLanguageHost.kindColor('Class') == new Color(214, 158, 54)
+            assert ConsoleLanguageHost.kindColor('Interface') == new Color(86, 166, 104)
+            assert ConsoleLanguageHost.kindColor('Enum') == new Color(163, 113, 212)
+            assert ConsoleLanguageHost.kindColor('Keyword') == new Color(138, 122, 90)
+            assert ConsoleLanguageHost.kindColor('Snippet') == new Color(76, 141, 255)
+            assert ConsoleLanguageHost.kindColor(null) == new Color(154, 160, 166)
+            def keywordRow = list.cellRenderer.getListCellRendererComponent(list, keyword, 2, false, false)
+            assert findNamed(keywordRow, 'completion-kind').getClientProperty('mark') == 'k'
+            assert findLabel(keywordRow, 'completion-parameters') == null
+            def empty = list.cellRenderer.getListCellRendererComponent(list, null, 3, false, false)
+            assert findNamed(empty, 'completion-kind').getClientProperty('mark') == '?'
+            assert parameters.text == '(int index, Object element)'
+            assert parameters.foreground != list.background
+            assert parameters.foreground != list.foreground
+            assert returns.text == 'void'
+            assert returns.foreground == parameters.foreground
+            assert returns.parent != name.parent
+            BorderLayout rowLayout = (BorderLayout) returns.parent.layout
+            assert rowLayout.getLayoutComponent(BorderLayout.EAST).is(returns)
+            assert rowLayout.getLayoutComponent(BorderLayout.CENTER).is(name.parent)
+            def selected = list.cellRenderer.getListCellRendererComponent(list, plain, 1, true, false)
+            def selectedParameters = findLabel(selected, 'completion-parameters')
+            def selectedReturn = findLabel(selected, 'completion-return')
+            assert selectedParameters.text == '(Object e)'
+            assert selectedReturn.text == 'boolean'
+            assert selectedParameters.foreground == selectedReturn.foreground
+            assert selectedParameters.foreground != list.selectionBackground
+            assert selectedParameters.foreground != list.selectionForeground
+            assert ConsoleLanguageHost.completionInsert(indexed) == 'add'
+            assert ConsoleLanguageHost.completionInsert(ConsoleLanguageHost.Candidate.of(
+                    'iff', 'Snippet', null, 'if (${1:c}) {\n}')) == 'if (c) {\n}'
+            list.selectedIndex = 1
+            list.setSize(400, list.fixedCellHeight * list.model.size)
+            int hoveredIndex = 2
+            int hoveredY = list.fixedCellHeight * hoveredIndex + list.fixedCellHeight.intdiv(2)
+            assert ConsoleLanguageHost.completionRowAt(list, new Point(10, hoveredY)) == hoveredIndex
+            assert ConsoleLanguageHost.completionRowAt(list, new Point(10, list.height + 4)) == -1
+            assert ConsoleLanguageHost.completionRowAt(null, new Point(0, 0)) == -1
+            assert ConsoleLanguageHost.completionRowAt(list, null) == -1
+            def moved = new MouseEvent(list, MouseEvent.MOUSE_MOVED, 0, 0, 10, hoveredY, 0, false)
+            list.mouseMotionListeners.each { it.mouseMoved(moved) }
+            assert list.selectedIndex == hoveredIndex
+            def hovered = list.cellRenderer.getListCellRendererComponent(
+                    list, list.model.getElementAt(hoveredIndex), hoveredIndex, list.isSelectedIndex(hoveredIndex), false)
+            assert hovered.background == list.selectionBackground
+            def idle = list.cellRenderer.getListCellRendererComponent(
+                    list, list.model.getElementAt(0), 0, list.isSelectedIndex(0), false)
+            assert idle.background == list.background
+            list.mouseMotionListeners.each {
+                it.mouseMoved(new MouseEvent(list, MouseEvent.MOUSE_MOVED, 0, 0, 10, list.height + 4, 0, false))
+            }
+            assert list.selectedIndex == hoveredIndex
+            list.mouseListeners.each {
+                it.mousePressed(new MouseEvent(list, MouseEvent.MOUSE_PRESSED, 0, 0, 10, hoveredY, 1, false))
+            }
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void typingNarrowsTheCompletionListAndEnterInsertsTheName() {
+        def pane = new JTextPane()
+        pane.text = 'a.'
+        pane.caretPosition = 2
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            assert ConsoleLanguageHost.prefixStart('a.cont', 6) == 2
+            assert ConsoleLanguageHost.prefixStart('a.', 2) == 2
+            assert ConsoleLanguageHost.prefixStart('', 0) == 0
+            assert ConsoleLanguageHost.matchesPrefix('contains', 'cont')
+            assert ConsoleLanguageHost.matchesPrefix('containsAll', 'Cont')
+            assert !ConsoleLanguageHost.matchesPrefix('add', 'cont')
+            assert ConsoleLanguageHost.matchesPrefix('add', '')
+            assert !ConsoleLanguageHost.matchesPrefix(null, 'c')
+            def contains = ConsoleLanguageHost.Candidate.of('contains', 'Method',
+                    'boolean contains(Object o)', 'contains')
+            def containsAll = ConsoleLanguageHost.Candidate.of('containsAll', 'Method',
+                    'boolean containsAll(Collection c)', 'containsAll')
+            def add = ConsoleLanguageHost.Candidate.of('add', 'Method', 'boolean add(Object e)', 'add')
+            def list = host.armCompletion([contains, containsAll, add], 2)
+            assert host.completionShowing()
+            assert list.model.size == 3
+            pane.document.insertString(2, 'cont', null)
+            assert list.model.size == 2
+            assert list.model.getElementAt(0).label() == 'contains'
+            assert list.model.getElementAt(1).label() == 'containsAll'
+            pane.document.remove(5, 1)
+            assert pane.document.getText(0, pane.document.length) == 'a.con'
+            assert list.model.size == 2
+            press(pane, KeyEvent.VK_DOWN)
+            assert list.selectedIndex == 1
+            press(pane, KeyEvent.VK_UP)
+            assert list.selectedIndex == 0
+            press(pane, KeyEvent.VK_ENTER)
+            assert pane.document.getText(0, pane.document.length) == 'a.contains'
+            assert !host.completionShowing()
+
+            pane.text = 'a.'
+            pane.caretPosition = 2
+            host.armCompletion([contains, add], 2)
+            pane.document.insertString(2, ' ', null)
+            assert !host.completionShowing()
+
+            pane.text = 'a.'
+            pane.caretPosition = 2
+            host.armCompletion([contains], 2)
+            press(pane, KeyEvent.VK_ESCAPE)
+            assert !host.completionShowing()
+            assert pane.document.getText(0, pane.document.length) == 'a.'
+        } finally {
+            host.close()
+        }
+    }
+
+    private static void press(JTextPane pane, int keyCode) {
+        def event = new KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
+                0, keyCode, KeyEvent.CHAR_UNDEFINED)
+        pane.keyListeners.each { it.keyPressed(event) }
+    }
+
+    /** True when antialiased painting left an interior pixel of this fill. */
+    private static boolean painted(BufferedImage image, int red, int green, int blue) {
+        for (int y = 0; y < image.height; y++) {
+            for (int x = 0; x < image.width; x++) {
+                int argb = image.getRGB(x, y)
+                if (((argb >>> 16) & 0xFF) == red && ((argb >>> 8) & 0xFF) == green && (argb & 0xFF) == blue) {
+                    return true
+                }
+            }
+        }
+        false
+    }
+
+    private static JLabel findLabel(Component root, String name) {
+        Component found = findNamed(root, name)
+        found instanceof JLabel ? (JLabel) found : null
+    }
+
+    private static Component findNamed(Component root, String name) {
+        if (root?.name == name) {
+            return root
+        }
+        if (root instanceof Container) {
+            for (Component child : ((Container) root).components) {
+                Component found = findNamed(child, name)
+                if (found != null) {
+                    return found
+                }
+            }
+        }
+        null
+    }
+
+    @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.@language.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, [[ConsoleLanguageHost.Candidate.of('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, [[ConsoleLanguageHost.Candidate.of('x', 'method', '', 'x')]] as Object[])
+                pane.caret.magicCaretPosition = null
+                show.invoke(menuHost, [[ConsoleLanguageHost.Candidate.of('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()
+        }
+    }
+
+    @Test
+    void completionPopupEdgesAndClosedQueries() {
+        def pane = new JTextPane()
+        pane.text = 'name'
+        pane.caretPosition = 4
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            host.@closed = true
+            assert host.tokens('class A {}').empty
+            assert host.cst('class A {}').name == ''
+            host.@closed = false
+
+            assert ConsoleLanguageHost.completionCellSize(null, null) == [280, 20] as int[]
+            def noFont = new JList<ConsoleLanguageHost.Candidate>() {
+                @Override
+                Font getFont() {
+                    null
+                }
+            }
+            def sized = ConsoleLanguageHost.completionCellSize(noFont, [
+                    ConsoleLanguageHost.Candidate.of(null, 'Method', null, null)])
+            assert sized[0] >= 280
+
+            def add = ConsoleLanguageHost.Candidate.of('add', 'Method', 'boolean add(Object e)', 'add')
+            def clear = ConsoleLanguageHost.Candidate.of('clear', 'Method', 'void clear()', 'clear')
+            def list = host.armCompletion([add, clear], 2)
+            list.setSize(400, list.fixedCellHeight * 2)
+            def menu = host.@completionPopup
+            menu.popupMenuListeners.findAll { it.class.name.startsWith('groovy.') }.each {
+                it.popupMenuWillBecomeVisible(null)
+                it.popupMenuWillBecomeInvisible(null)
+                it.popupMenuCanceled(null)
+            }
+            list = host.armCompletion([add, clear], 2)
+            list.setSize(400, list.fixedCellHeight * 2)
+            list.mouseListeners.each {
+                it.mousePressed(new MouseEvent(list, MouseEvent.MOUSE_PRESSED, 0, 0, 1, 4000, 1, false))
+            }
+            list.addListSelectionListener {
+                list.mouseMotionListeners.each {
+                    it.mouseMoved(new MouseEvent(list, MouseEvent.MOUSE_MOVED, 0, 0, 8, 1, 0, false))
+                }
+            }
+            int y = list.fixedCellHeight + 1
+            list.mouseMotionListeners.each {
+                it.mouseMoved(new MouseEvent(list, MouseEvent.MOUSE_MOVED, 0, 0, 8, y, 0, false))
+            }
+            list.selectedIndex = -1
+            list.actionMap.get('choose').actionPerformed(new ActionEvent(list, 0, 'choose'))
+
+            def move = ConsoleLanguageHost.getDeclaredMethod('moveSelection', int)
+            move.accessible = true
+            def choose = ConsoleLanguageHost.getDeclaredMethod('chooseArmed')
+            choose.accessible = true
+            move.invoke(host, -1)
+            move.invoke(host, 40)
+            list.selectedIndex = -1
+            choose.invoke(host)
+            host.@completionList = null
+            move.invoke(host, 1)
+            choose.invoke(host)
+
+            pane.caretPosition = 2
+            host.armCompletion([add], 2)
+            pane.document.insertString(3, '!', null)
+            pane.caretPosition = pane.document.length
+            host.armCompletion([add], 2)
+            pane.document.insertString(0, 'Q', null)
+            host.armCompletion([add], 2)
+            pane.document.remove(0, 1)
+            int length = pane.document.length
+            pane.caretPosition = 1
+            host.armCompletion([add], 1)
+            if (length > 2) {
+                pane.document.remove(2, 1)
+            }
+            host.armCompletion([add], 1)
+            pane.document.remove(0, Math.min(3, pane.document.length))
+            host.armCompletion([add], 2)
+            host.@completionList = null
+            pane.document.insertString(0, 'Z', null)
+            host.armCompletion([add], 0)
+            host.@completionEnd = pane.document.length + 5
+            pane.document.insertString(0, 'Q', null)
+            host.armCompletion([add], 0)
+            pane.document.insertString(Math.min(1, pane.document.length), '-', null)
+
+            assert ConsoleLanguageHost.completionRowAt(null, null) == -1
+            assert ConsoleLanguageHost.completionRowAt(list, null) == -1
+            assert ConsoleLanguageHost.completionInsert(null) == ''
+            assert ConsoleLanguageHost.textOf(null) == ''
+            def bad = new JTextPane()
+            bad.document = new DefaultStyledDocument() {
+                @Override
+                String getText(int offset, int len) throws BadLocationException {
+                    throw new BadLocationException('test', offset)
+                }
+            }
+            assert ConsoleLanguageHost.textOf(bad) == ''
+
+            host.armCompletion([add], 2)
+            host.@completionPopup = new JPopupMenu() {
+                @Override
+                boolean isVisible() {
+                    true
+                }
+
+                @Override
+                void setVisible(boolean value) {
+                }
+            }
+            def hide = ConsoleLanguageHost.getDeclaredMethod('hideCompletion')
+            hide.accessible = true
+            hide.invoke(host)
+            assert host.@completionPopup == null
+
+            def showMenu = ConsoleLanguageHost.getDeclaredMethod('showMenu', List)
+            showMenu.accessible = true
+            showMenu.invoke(host, [[]] as Object[])
+            showMenu.invoke(host, [[add]] as Object[])
+            def showCompletions = ConsoleLanguageHost.getDeclaredMethod('showCompletions')
+            showCompletions.accessible = true
+            def runCompletions = ConsoleLanguageHost.getDeclaredMethod('runCompletions', int, String, int, int)
+            runCompletions.accessible = true
+            host.@closed = true
+            showCompletions.invoke(host)
+            runCompletions.invoke(host, 0, 'a.', 0, 2)
+            host.@closed = false
+            runCompletions.invoke(host, -1, 'a.', 0, 2)
+            host.@debounce.shutdownNow()
+            showCompletions.invoke(host)
+        } finally {
+            host.close()
+        }
+    }
+
+    @Test
+    void completionEditsMoveTheAnchorAndClampTheSelection() {
+        def pane = new JTextPane()
+        pane.text = 'name'
+        pane.caretPosition = 4
+        def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+        try {
+            def add = ConsoleLanguageHost.Candidate.of('add', 'Method', 'boolean add(Object e)', 'add')
+            def clear = ConsoleLanguageHost.Candidate.of('clear', 'Method', 'void clear()', 'clear')
+            def list = host.armCompletion([add, clear], 4)
+            list.setSize(300, Math.max(list.fixedCellHeight, 20) * 2)
+            def row = list.getCellBounds(1, 1)
+            if (row != null) {
+                list.selectedIndex = 0
+                int px = (int) row.x + 1
+                int py = (int) row.y + 1
+                list.addListSelectionListener {
+                    list.mouseMotionListeners.each {
+                        it.mouseMoved(new MouseEvent(list, MouseEvent.MOUSE_MOVED, 0, 0, px, py, 0, false))
+                    }
+                }
+                list.mouseMotionListeners.each {
+                    it.mouseMoved(new MouseEvent(list, MouseEvent.MOUSE_MOVED, 0, 0, px, py, 0, false))
+                }
+                list.listSelectionListeners.each { list.removeListSelectionListener(it) }
+            }
+            list.selectedIndex = 0
+            def move = ConsoleLanguageHost.getDeclaredMethod('moveSelection', int)
+            move.accessible = true
+            move.invoke(host, [-1] as Object[])
+            assert list.selectedIndex == 0
+            list.selectedIndex = 1
+            move.invoke(host, [20] as Object[])
+            assert list.selectedIndex == 1
+
+            def onEdit = ConsoleLanguageHost.getDeclaredMethod('onDocumentEdit', DocumentEvent)
+            onEdit.accessible = true
+            def edit = { DocumentEvent.EventType kind, int offset, int length ->
+                [
+                        getType: { kind },
+                        getOffset: { offset },
+                        getLength: { length },
+                        getDocument: { pane.document },
+                        getChange: { Element element -> null },
+                ] as DocumentEvent
+            }
+            host.@completionAnchor = 3
+            host.@completionEnd = 3
+            host.@completionOpen = true
+            onEdit.invoke(host, [edit(DocumentEvent.EventType.INSERT, 0, 1)] as Object[])
+            assert host.@completionAnchor == 4
+            host.@completionAnchor = 3
+            host.@completionEnd = 4
+            host.@completionOpen = true
+            onEdit.invoke(host, [edit(DocumentEvent.EventType.REMOVE, 0, 1)] as Object[])
+            assert host.@completionAnchor == 2
+            host.@completionList = null
+            host.@completionOpen = true
+            host.@completionAnchor = 3
+            host.@completionEnd = 3
+            onEdit.invoke(host, [edit(DocumentEvent.EventType.INSERT, 0, 1)] as Object[])
+            host.armCompletion([add], 2)
+            host.@completionEnd = 50
+            onEdit.invoke(host, [edit(DocumentEvent.EventType.INSERT, 0, 1)] as Object[])
+            assert !host.@completionOpen
+        } finally {
+            host.close()
+        }
+    }
+}
diff --git a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/MatchingHighlighterTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/MatchingHighlighterTest.groovy
new file mode 100644
index 0000000000..3575acb8b4
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/text/MatchingHighlighterTest.groovy
@@ -0,0 +1,70 @@
+/*
+ *  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.text
+
+import org.junit.jupiter.api.Test
+
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+import javax.swing.text.DefaultStyledDocument
+
+final class MatchingHighlighterTest {
+
+    @Test
+    void caretHighlightsTheMatchingDelimiter() {
+        def doc = new DefaultStyledDocument()
+        def filter = new SmartDocumentFilter(doc)
+        doc.documentFilter = filter
+        def pane = new JTextPane(doc)
+        def highlighter = new MatchingHighlighter(filter, pane)
+        highlighter.caretUpdate(null)
+        SwingUtilities.invokeAndWait {}
+
+        doc.insertString(0, 'f(x) { a[0] }', null)
+        assert filter.latest
+        assert filter.latestTokenList.every { it.offset >= 0 && it.length > 0 && it.text }
+        pane.caretPosition = 2
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+        pane.caretPosition = 3
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+        pane.caretPosition = 6
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+        pane.caretPosition = 9
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+        pane.caretPosition = 11
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+        pane.caretPosition = 0
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+
+        doc.remove(0, doc.length)
+        doc.insertString(0, 'a?[0]', null)
+        pane.caretPosition = 2
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+        pane.caretPosition = 4
+        highlighter.highlight()
+        SwingUtilities.invokeAndWait {}
+    }
+}
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..61ca3fa27e 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,14 @@
  */
 package groovy.console.ui.text
 
+import groovy.console.ui.ThemeManager
 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 +85,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
@@ -129,9 +136,9 @@ class SmartDocumentFilterTest {
 
         doc.insertString(0, 'def x = 1', null)
 
-        def tokens = filter.getLatestTokenList()
-        assert tokens != null
-        assert !tokens.isEmpty()
+        assert fg(doc, 0) == themeFg('reserved_word')
+        assert filter.latestTokenList != null
+        assert filter.latestTokenList.empty
     }
 
     @Test
@@ -149,12 +156,69 @@ 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 fg(doc, 0) == themeFg('reserved_word')
+        assert filter.latestTokenList.empty
     }
 
     @Test
@@ -193,6 +257,8 @@ class Example {
         assert filter.isLatest()
         def tokens = filter.getLatestTokenList()
         assert tokens.size() > 10
+        assert tokens.any { it.text == '{' }
+        assert !tokens.any { it.text == 'class' }
     }
 
     @Test
@@ -206,4 +272,232 @@ 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)
+        def tokens = filter.latestTokenList
+        assert tokens.any { it.text == '{' }
+        assert tokens.any { it.text == '}' }
+        assert tokens.every { it.text in ['{', '}'] }
+    }
+
+    @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 testCarriageReturnSeparatesLines() {
+        def offset = SmartDocumentFilter.getDeclaredMethod('offsetOf', String, int, int)
+        offset.accessible = true
+        assert offset.invoke(null, ['a\nb', 1, 0] as Object[]) == 2
+        assert offset.invoke(null, ['a\rb', 1, 0] as Object[]) == 2
+        assert offset.invoke(null, ['a\r\nb', 1, 1] as Object[]) == 4
+        assert offset.invoke(null, ['ab', 3, 0] as Object[]) == 2
+
+        def doc = new DefaultStyledDocument()
+        def filter = new SmartDocumentFilter(doc)
+        doc.setDocumentFilter(filter)
+        doc.insertString(0, 'def a\rdef b', null)
+        assert doc.getText(0, doc.length).contains('\r')
+        assert fg(doc, 0) == themeFg('reserved_word')
+        assert filter.isLatest()
+    }
+
+    @Test
+    void testBracketsInsideStringsAreNotDelimiters() {
+        def doc = new DefaultStyledDocument()
+        def filter = new SmartDocumentFilter(doc)
+        doc.setDocumentFilter(filter)
+        doc.insertString(0, "def s = '{'\ndef m = a?[0]\n", null)
+        def tokens = filter.latestTokenList
+        assert !tokens.any { it.text == '{' }
+        assert tokens.any { it.text == '?[' }
+        assert tokens.any { it.text == ']' }
+        assert !tokens.any { it.text == '[' }
+    }
+
+    @Test
+    void testUnclosedGStringDoesNotFailTheEdit() {
+        def doc = new DefaultStyledDocument()
+        def filter = new SmartDocumentFilter(doc)
+        doc.setDocumentFilter(filter)
+        doc.insertString(0, '"$', null)
+        assert doc.getText(0, doc.length) == '"$'
+        assert filter.isLatest()
+    }
+
+    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..852beeecdd 100644
--- a/subprojects/groovy-groovysh/ARCHITECTURE.md
+++ b/subprojects/groovy-groovysh/ARCHITECTURE.md
@@ -238,6 +238,28 @@ 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 and does
+  not depend on groovy-console. `/console` and `/inspect --gui` load
+  `groovy.console.ui` by name when that jar is already on the classpath.
+  `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 server does not assign
+  that name. The adapter speaks `GroovyLanguageServer` with LSP4J
+  messages, in-process, and does not import `org.apache.groovy.lsp.internal`.
+  `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 compile
+  fingerprint includes the parent loader's URLs. The session publishes
+  that loader, classpath, and import customizers only when the
+  `CompilerConfiguration` 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 `groovy.scriptNames`
+  command. Snippet insert text is flattened with
+  `flattenSnippetLine`. Tests for it live at the session 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..f709746425 100644
--- a/subprojects/groovy-groovysh/build.gradle
+++ b/subprojects/groovy-groovysh/build.gradle
@@ -28,11 +28,15 @@ repositories {
 
 dependencies {
     implementation project(':')
-    implementation projects.groovyConsole
     implementation projects.groovyTemplates
     implementation projects.groovyXml
     implementation projects.groovyJson
     implementation projects.groovyNio
+    // Direct implementation dependency. LSP4J stays off this module's published API.
+    implementation projects.groovyLsp
+    // /console and /inspect --gui load groovy.console.ui by name when that jar
+    // is present. The shell does not compile against it.
+    testRuntimeOnly projects.groovyConsole
     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..c46011501a 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(
@@ -331,6 +333,10 @@ class Main {
                 .variable(LineReader.SECONDARY_PROMPT_PATTERN, "%M%P > ")
                 .variable(LineReader.INDENTATION, 2)
                 .variable(LineReader.LIST_MAX, 100)
+                // A tall member list must not ask "see all N possibilities?".
+                // That prompt reads the next key, so Backspace looks frozen
+                // and never deletes the character before the caret.
+                .variable(LineReader.TOO_MANY_CANDIDATES, 'show')
                 // Left for JLine to create: DefaultHistory creates the history file owner-only,
                 // handles the concurrent-creation race, and falls back on non-POSIX file systems.
                 // Creating it here first is what used to defeat that and leave it at the umask
@@ -368,7 +374,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 +381,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 +482,8 @@ class Main {
         } catch (Throwable t) {
             t.printStackTrace()
             return 1
+        } finally {
+            languageSession?.close()
         }
         return 0
     }
@@ -492,6 +500,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/jline/GroovyCommands.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
index 5700ff8282..f086e9e5b9 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
@@ -18,8 +18,6 @@
  */
 package org.apache.groovy.groovysh.jline
 
-import groovy.console.ui.Console
-import groovy.console.ui.ObjectBrowser
 import org.apache.groovy.groovysh.Main
 import org.apache.groovy.groovysh.util.ClassUtils
 import org.jline.builtins.Completers
@@ -994,7 +992,11 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
     void console(CommandInput input) {
         checkArgCount(input, [0, 1])
         if (maybePrintHelp(input, '/console')) return
-        Console c = new Console(engine.sharedData)
+        // Loaded by name so groovysh does not compile against groovy-console.
+        // The command is registered only when that jar is already on the classpath.
+        def c = Class.forName('groovy.console.ui.Console')
+                .getConstructor(Binding)
+                .newInstance(engine.sharedData)
         def poller = new javax.swing.Timer(100, null)
         poller.addActionListener({ e ->
             if (c.inputArea != null) {
@@ -1039,7 +1041,16 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
                 out = inspector.properties()
                 options[Printer.VALUE_STYLE] = engine.groovyOption(GroovyEngine.NANORC_VALUE, DEFAULT_NANORC_VALUE)
             } else if (consoleUi && (option == '-g' || option == '--gui')) {
-                ObjectBrowser.inspect(obj)
+                try {
+                    Class.forName('groovy.console.ui.ObjectBrowser')
+                            .getMethod('inspect', Object)
+                            .invoke(null, obj)
+                } catch (Exception e) {
+                    saveException(e)
+                }
+                // The browser has no terminal table. Printing the unset
+                // result asks the printer to render null.
+                return null
             } else {
                 throw new IllegalArgumentException('Unknown option: ' + option)
             }
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
index 2a8dbeb635..e6716c1421 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
@@ -1920,8 +1920,6 @@ public class GroovyEngine implements ScriptEngine {
         private static final List<String> VALUES = Arrays.asList("true", "false");
         private static final String REGEX_GET_METHOD = "get[A-Z].*";
         private final GroovyEngine groovyEngine;
-        @SuppressWarnings("deprecation") // org.jline.console.SystemRegistry
-        private final SystemRegistry systemRegistry = SystemRegistry.get();
         private Inspector inspector;
         private AccessRules access;
         private boolean metaMethodCompletion;
@@ -1941,7 +1939,9 @@ public class GroovyEngine implements ScriptEngine {
         public void complete(LineReader reader, ParsedLine commandLine, List<Candidate> candidates) {
             assert commandLine != null;
             assert candidates != null;
-            if (systemRegistry.isCommandOrScript(commandLine)
+            @SuppressWarnings("deprecation")
+            SystemRegistry systemRegistry = SystemRegistry.get();
+            if ((systemRegistry != null && systemRegistry.isCommandOrScript(commandLine))
                     || (commandLine.wordIndex() > 0
                             && commandLine.words().get(0).equals("import"))) {
                 return;
@@ -2056,17 +2056,41 @@ public class GroovyEngine implements ScriptEngine {
                             // ignore
                         } finally {
                             param = wordbuffer.substring(eqsep + 1, varsep + 1);
-                            Helpers.doCandidates(
-                                    candidates,
-                                    Helpers.nextDomain(param, CandidateType.STATIC_METHOD, inspector.shell),
-                                    curBuf,
-                                    CandidateType.PACKAGE);
+                            // A script variable such as `a` after `def a = []` is not a
+                            // package. Scanning the classpath for `a.` blocks the reader
+                            // thread, so Tab and the following Backspace appear stuck.
+                            if (!knownReceiver(param)) {
+                                Helpers.doCandidates(
+                                        candidates,
+                                        Helpers.nextDomain(param, CandidateType.STATIC_METHOD, inspector.shell),
+                                        curBuf,
+                                        CandidateType.PACKAGE);
+                            }
                         }
                     }
                 }
             }
         }
 
+        /**
+         * True when {@code dotted} is {@code name.} and {@code name} is a
+         * script variable, binding variable, or class already defined in
+         * this session. Package prefixes such as {@code java.util.} are not.
+         */
+        private boolean knownReceiver(String dotted) {
+            int dot = dotted == null ? -1 : dotted.lastIndexOf('.');
+            if (dot <= 0) {
+                return false;
+            }
+            String name = dotted.substring(0, dot);
+            if (name.isEmpty() || name.indexOf('.') >= 0) {
+                return false;
+            }
+            return groovyEngine.variables.containsKey(name)
+                    || inspector.hasVariable(name)
+                    || inspector.nameClass().containsKey(name);
+        }
+
         private boolean completingConstructor(ParsedLine commandLine) {
             return !commandLine.word().contains("(")
                     && ((commandLine.wordIndex() == 1
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..908d10d089
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSession.groovy
@@ -0,0 +1,628 @@
+/*
+ *  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.lsp.GroovyLanguageClient
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.eclipse.lsp4j.CompletionItem
+import org.eclipse.lsp4j.CompletionItemKind
+import org.eclipse.lsp4j.Diagnostic
+import org.eclipse.lsp4j.SignatureInformation
+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.Matcher
+import java.util.regex.Pattern
+
+/**
+ * Compiler-backed session for groovysh. Speaks to {@code GroovyLanguageServer}
+ * with the same LSP messages a remote editor sends. The adapter composes
+ * the REPL buffer, binding type declarations, and the current line.
+ * Classpath and imports are 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 server does not assign the name. */
+    @PackageScope
+    static final String BUFFER_URI = '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 GroovyLanguageClient language
+    private String namesPreamble
+    private Set<String> namesCache
+    private CompilerConfiguration boundConfiguration
+    private boolean closed
+
+    GroovyshLanguageSession(GroovyEngine groovy) {
+        this.groovy = groovy
+        this.language = GroovyLanguageClient.open(BUFFER_URI, '', true)
+        bindEngine()
+    }
+
+    /**
+     * Compiles the session buffer plus {@code line} and returns diagnostics.
+     *
+     * @param line current input
+     * @return hits
+     */
+    @PackageScope
+    List<Diagnostic> diagnosticsFor(String line) {
+        bindEngine()
+        language.compile(compose(line ?: ''))
+    }
+
+    /**
+     * Completions for the current line at end-of-buffer.
+     *
+     * @param line current input
+     * @return candidates
+     */
+    @PackageScope
+    List<CompletionItem> 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<CompletionItem> completeLine(String line, int cursor) {
+        bindEngine()
+        String current = line ?: ''
+        String script = compose(current)
+        int[] caret = caretInComposed(script, current, cursor)
+        language.complete(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(GroovyLanguageClient.diagnosticText(it)) })
+                    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
+    List<SignatureInformation> 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
+    List<SignatureInformation> signaturesFor(String line, int cursor) {
+        bindEngine()
+        String current = line ?: ''
+        String script = compose(current)
+        int[] caret = caretInComposed(script, current, cursor)
+        def help = language.signatures(script, caret[0], caret[1])
+        help?.signatures ?: []
+    }
+
+    /**
+     * {@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 positionOf(script, script.length())
+        }
+        int currentStart = script.length() - current.length()
+        return 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(List<SignatureInformation> signatures) {
+        if (!signatures) {
+            return null
+        }
+        def desc = new CmdDesc()
+        desc.setMainDesc(signatures.collect { new AttributedString(it?.label ?: '') })
+        desc
+    }
+
+    @Override
+    void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
+        if (closed || candidates == null || line == null) {
+            return
+        }
+        String raw = line.line() ?: ''
+        if (raw.startsWith('/')) {
+            return
+        }
+        try {
+            def items = completeLine(raw, line.cursor())
+            mapEngineCandidates(items, line, candidates, terminalWidth(reader))
+        } catch (RuntimeException ignored) {
+        }
+    }
+
+    @Override
+    void close() {
+        if (closed) {
+            return
+        }
+        closed = true
+        language.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 = language.scriptNames(preamble) as Set
+        namesPreamble = preamble
+        namesCache
+    }
+
+    private void bindEngine() {
+        // Publish only when the instance changes so a repeated completion
+        // can reuse the compile. In-place customizer edits are not a
+        // groovysh path: the engine configuration is final and set only
+        // at construction, and interactive imports are buffer snippets.
+        CompilerConfiguration configuration = groovy.compilerConfiguration
+        if (configuration != boundConfiguration) {
+            boundConfiguration = configuration
+            language.parentLoader(groovy.classLoader)
+            language.settings([
+                    classpath: GroovyLanguageClient.classpathOf(groovy.classLoader),
+                    imports: GroovyLanguageClient.importsOf(configuration),
+                    starImports: GroovyLanguageClient.starImportsOf(configuration),
+                    scriptBaseClass: configuration?.scriptBaseClass ?: ''
+            ])
+        }
+    }
+
+    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'
+    }
+
+    /** {@code returnType name(Type param, Type name)}. */
+    private static final Pattern SIGNATURE = Pattern.compile(/^(.*)\s+(\S+)\((.*)\)\s*$/)
+    /** Same gray JLine uses for completion descriptions ({@code fg:bright-black}). */
+    private static final String GRAY = '\u001B[90m'
+    private static final String RESET = '\u001B[0m'
+    /** Minimum columns between the parameter list and the return type. */
+    private static final int RETURN_GAP = 2
+    /** JLine inserts this many columns between completion columns. */
+    private static final int COLUMN_GAP = 3
+    /** Preferred column width. Extra columns are used only while each stays at least this wide. */
+    private static final int PREFERRED_CELL = 36
+    /** Upper bound so a very wide terminal does not become a dense wall of columns. */
+    private static final int MAX_COLUMNS = 4
+    private static final int DEFAULT_WIDTH = 80
+
+    /**
+     * Maps engine candidates onto JLine {@link Candidate} values using the
+     * current word as the replacement prefix. The group is the full kind
+     * name. A method's name is immediately followed by its parameter list.
+     * The return type is right-aligned after that list, in the same gray.
+     * Candidates share one cell width so JLine can place several side by side.
+     * Selecting a method inserts only the name.
+     */
+    @PackageScope
+    static void mapEngineCandidates(List<CompletionItem> items, ParsedLine line,
+                                    List<Candidate> candidates) {
+        mapEngineCandidates(items, line, candidates, DEFAULT_WIDTH)
+    }
+
+    /**
+     * @param width terminal columns available for the list
+     */
+    @PackageScope
+    static void mapEngineCandidates(List<CompletionItem> items, ParsedLine line,
+                                    List<Candidate> candidates, int width) {
+        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) : ''
+        List<Row> rows = []
+        for (CompletionItem item : items) {
+            if (item == null) {
+                continue
+            }
+            String insert = insertedText(item)
+            if (insert.isEmpty()) {
+                continue
+            }
+            String value = insert.startsWith(curBuf) ? insert : curBuf + insert
+            String label = item.label ?: insert
+            rows.add(new Row(value, label, category(item.kind), parametersOf(item.detail), returnTypeOf(item.detail)))
+        }
+        int cell = cellWidth(width, columnCount(width))
+        for (Row row : rows) {
+            candidates.add(new Candidate(row.value, displayOf(row, cell), row.group, null, null, null, false))
+        }
+    }
+
+    /**
+     * How many candidates fit on one line. A column is added only while it
+     * stays at least {@link #PREFERRED_CELL} wide, and never more than
+     * {@link #MAX_COLUMNS}.
+     */
+    @PackageScope
+    static int columnCount(int width) {
+        int columns = 1
+        int limit = Math.min(MAX_COLUMNS, Math.max(width, 1))
+        for (int n = 1; n <= limit; n++) {
+            if (cellWidth(width, n) >= PREFERRED_CELL) {
+                columns = n
+            }
+        }
+        columns
+    }
+
+    /**
+     * Visible width of one candidate so that {@code columns} of them, plus
+     * JLine's gap between columns, fit on {@code width} without wrapping.
+     */
+    @PackageScope
+    static int cellWidth(int width, int columns) {
+        int safe = width > 0 ? width : DEFAULT_WIDTH
+        int n = columns < 1 ? 1 : columns
+        int available = safe - (n - 1) * COLUMN_GAP - 1
+        if (available < n) {
+            return 1
+        }
+        available.intdiv(n)
+    }
+
+    private static int terminalWidth(LineReader reader) {
+        if (reader == null || reader.terminal == null) {
+            return DEFAULT_WIDTH
+        }
+        int width = reader.terminal.width
+        width > 0 ? width : DEFAULT_WIDTH
+    }
+
+    /**
+     * {@code name(Type param)} in gray immediately after the name, then a gap,
+     * then the return type in the same gray. Every signature row is exactly
+     * {@code cell} columns wide, so the return types share a right edge and
+     * several candidates can sit on one line. A signature that does not fit
+     * is cut with {@code ...} before the return type.
+     */
+    private static String displayOf(Row row, int cell) {
+        if (row.parameters == null) {
+            return row.label
+        }
+        String params = '(' + row.parameters + ')'
+        String ret = row.returns ?: ''
+        int room = ret ? cell - RETURN_GAP - ret.length() : cell
+        if (room < 1) {
+            room = 1
+        }
+        String left = fit(row.label + params, room)
+        int paren = left.indexOf('(')
+        int gap = cell - left.length() - ret.length()
+        if (gap < 0) {
+            gap = 0
+        }
+        StringBuilder shown = new StringBuilder()
+        if (paren >= 0) {
+            shown.append(left, 0, paren)
+            shown.append(GRAY).append(left.substring(paren)).append(RESET)
+        } else {
+            shown.append(left)
+        }
+        shown.append(spaces(gap))
+        if (ret) {
+            shown.append(GRAY).append(ret).append(RESET)
+        }
+        shown.toString()
+    }
+
+    /** Cuts {@code text} to {@code room} columns, keeping a {@code ...} tail when it cuts. */
+    private static String fit(String text, int room) {
+        if (text == null || room <= 0) {
+            return ''
+        }
+        if (text.length() <= room) {
+            return text
+        }
+        if (room <= 3) {
+            return text.substring(0, room)
+        }
+        text.substring(0, room - 3) + '...'
+    }
+
+    private static String spaces(int count) {
+        if (count <= 0) {
+            return ''
+        }
+        char[] pad = new char[count]
+        Arrays.fill(pad, (char) ' ')
+        new String(pad)
+    }
+
+    /** Display text with color sequences removed, for tests. */
+    @PackageScope
+    static String visible(String display) {
+        if (!display) {
+            return ''
+        }
+        display.replaceAll(/\u001B\[[0-9;]*m/, '')
+    }
+
+    private static final class Row {
+        final String value
+        final String label
+        final String group
+        final String parameters
+        final String returns
+
+        private Row(String value, String label, String group, String parameters, String returns) {
+            this.value = value
+            this.label = label
+            this.group = group
+            this.parameters = parameters
+            this.returns = returns
+        }
+    }
+
+    /**
+     * Text written into the buffer. A method contributes its name.
+     * Parameter placeholders stay in the list and are not inserted.
+     */
+    @PackageScope
+    static String insertedText(CompletionItem item) {
+        if (item == null) {
+            return ''
+        }
+        if (item.kind == CompletionItemKind.Method || item.kind == CompletionItemKind.Function) {
+            return item.label ?: ''
+        }
+        String insert = flattenSnippetLine(item.insertText ?: item.label)
+        if (insert.isEmpty()) {
+            insert = item.label ?: ''
+        }
+        insert
+    }
+
+    /**
+     * Full kind name used as the completion group. Static methods are
+     * {@code Method}. An unknown kind is {@code Other}.
+     */
+    @PackageScope
+    static String category(CompletionItemKind kind) {
+        if (kind == null) {
+            return 'Other'
+        }
+        if (kind == CompletionItemKind.Function || kind == CompletionItemKind.Method) {
+            return 'Method'
+        }
+        kind.name()
+    }
+
+    /**
+     * Parameter types and names from a method signature, or {@code null}
+     * when {@code detail} is not a signature. An empty string is a method
+     * with no parameters.
+     */
+    @PackageScope
+    static String parametersOf(String detail) {
+        Matcher matcher = signatureOf(detail)
+        matcher == null ? null : matcher.group(3)
+    }
+
+    /**
+     * Return type from a method signature, or {@code null} when
+     * {@code detail} is not a signature.
+     */
+    @PackageScope
+    static String returnTypeOf(String detail) {
+        Matcher matcher = signatureOf(detail)
+        if (matcher == null) {
+            return null
+        }
+        String type = matcher.group(1).trim()
+        type.isEmpty() ? null : type
+    }
+
+    private static Matcher signatureOf(String detail) {
+        if (!detail) {
+            return null
+        }
+        Matcher matcher = SIGNATURE.matcher(detail)
+        matcher.matches() ? matcher : null
+    }
+
+    @PackageScope
+    static String flattenSnippetLine(String insert) {
+        String flattened = flattenSnippet(insert)
+        int nl = flattened.indexOf('\n')
+        nl < 0 ? flattened : flattened.substring(0, nl)
+    }
+
+    @PackageScope
+    static String flattenSnippet(String insert) {
+        if (!insert) {
+            return ''
+        }
+        insert.replaceAll(/\$\{\d+:([^}]*)}/, '$1').replaceAll(/\$\d+/, '')
+    }
+
+    private static int[] positionOf(String text, int offset) {
+        if (!text || offset <= 0) {
+            return [0, 0] as int[]
+        }
+        int capped = Math.min(offset, text.length())
+        int line = 0
+        int character = 0
+        for (int i = 0; i < capped; i++) {
+            char c = text.charAt(i)
+            if (c == '\n' as char) {
+                line++
+                character = 0
+            } else if (c == '\r' as char) {
+                if (i + 1 < text.length() && text.charAt(i + 1) == '\n' as char) {
+                    i++
+                }
+                line++
+                character = 0
+            } else {
+                character++
+            }
+        }
+        [line, character] as int[]
+    }
+}
diff --git a/subprojects/groovy-lsp/build.gradle b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy
similarity index 57%
copy from subprojects/groovy-lsp/build.gradle
copy to subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy
index eda47f58da..2cda75113b 100644
--- a/subprojects/groovy-lsp/build.gradle
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/language/package-info.groovy
@@ -16,30 +16,11 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-plugins {
-    id 'org.apache.groovy-library'
-}
 
-dependencies {
-    implementation project(':')
-    implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:${versions.lsp4j}"
-    implementation "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:${versions.lsp4j}"
-    testImplementation projects.groovyTest
-}
-
-groovyLibrary {
-    optionalModule()
-    withoutBinaryCompatibilityChecks()
-}
-
-tasks.named('jar', Jar) {
-    manifest {
-        attributes('Main-Class': 'org.apache.groovy.lsp.GroovyLanguageServerLauncher')
-    }
-}
-
-tasks.named('rat') {
-    excludes += [
-            'src/test/resources/**'
-    ]
-}
+/**
+ * Compiler-backed groovysh session. The adapter stays in this tree and talks
+ * to {@code GroovyLanguageServer} with LSP messages, the same way a remote
+ * editor does. 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..78f4181b8b 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -38,6 +38,33 @@ 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 groovy-lsp over
+the current buffer (imports, types, methods, and binding
+variables), using the same language-server messages an editor sends.
+After a dot, Tab lists the methods and properties of that receiver,
+including Groovy extension methods of that type and a name introduced
+by `def` earlier in the session.
+A method is shown as its name immediately followed by the parameter list.
+The return type is right-aligned after that list, in the same gray, with a gap
+so it stays apart from the name and the parameters.
+On a wide terminal the list is a few columns of equal width, each wide enough
+to keep one candidate on a single line. A very long signature is shortened
+with `...` rather than stretching the column.
+Selecting a method inserts the name only.
+Candidates are grouped under a full kind name (`Method`, `Field`,
+`Property`, `Variable`, `Class`, `Interface`, `Enum`, `Keyword`,
+`Snippet`). Static methods are grouped as `Method`.
+A long list is shown directly. Tab does not stop to ask whether
+to display it, and the next key is still delivered to the editor.
+`/grab` remains the way to add Maven artifacts; the next
+completion or tail-tip notices the loader change. Embedder
+`CompilerConfiguration` imports 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/commands/ConsoleCommandTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
index 950834e402..cd4b2a79ab 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
@@ -19,6 +19,9 @@
 package org.apache.groovy.groovysh.commands
 
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+
+import java.util.concurrent.TimeUnit
 
 /**
  * Tests for the {@code /console} command. The command launches a Swing
@@ -29,10 +32,9 @@ class ConsoleCommandTest extends SystemTestSupport {
 
     @Test
     void consoleIsRegisteredWhenObjectBrowserOnClasspath() {
-        // /console is conditionally registered: present iff
-        // groovy.console.ui.ObjectBrowser is on the classpath. The test
-        // module pulls groovy-console in transitively, so it should be
-        // there. If anyone removes that dep without intent, this fails.
+        // /console is registered only when groovy.console.ui.ObjectBrowser
+        // is on the runtime classpath. The test runtime supplies that jar.
+        // The shell itself does not compile against groovy-console.
         assert '/console' in groovy.commandNames()
     }
 
@@ -46,4 +48,14 @@ class ConsoleCommandTest extends SystemTestSupport {
         system.execute('/console --help')
         assert printer.output.size() > before
     }
+
+    @Test
+    @Timeout(value = 15, unit = TimeUnit.SECONDS)
+    void consoleCommandLoadsTheConsoleByName() {
+        try {
+            system.execute('/console')
+        } catch (Throwable ignored) {
+            // The Swing frame cannot open in a headless test.
+        }
+    }
 }
diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy
index b5ec346c0a..41d9279ace 100644
--- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy
@@ -18,7 +18,19 @@
  */
 package org.apache.groovy.groovysh.commands
 
+import org.jline.console.CommandInput
+import org.jline.console.CommandRegistry
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+
+import javax.swing.SwingUtilities
+import java.awt.AWTError
+import java.awt.Frame
+import java.awt.GraphicsEnvironment
+import java.awt.HeadlessException
+import java.awt.Window
+import java.lang.reflect.InvocationTargetException
+import java.util.concurrent.TimeUnit
 
 /**
  * Tests for the {@code /inspect} command. Exercises GroovyCommands and the
@@ -50,4 +62,72 @@ class InspectTest extends SystemTestSupport {
         assert out.contains('publicFields')
         assert out.contains('classProps')
     }
+
+    @Test
+    @Timeout(value = 15, unit = TimeUnit.SECONDS)
+    void inspectGuiLoadsTheObjectBrowserByName() {
+        console.execute('dummy', "data = 'hi'")
+        // ObjectBrowser is loaded by name. A headless JVM fails while
+        // creating the frame, and JLine rethrows that failure. A JVM with
+        // a display opens the window and the command returns. Requiring an
+        // exception in both cases fails whenever the display is available.
+        Throwable thrown = null
+        try {
+            system.execute('/inspect --gui $data')
+        } catch (Throwable failure) {
+            thrown = failure
+        } finally {
+            closeObjectBrowser()
+        }
+        if (thrown != null) {
+            assert uiStartupFailure(thrown) : thrown
+        } else {
+            assert !GraphicsEnvironment.isHeadless()
+        }
+        // Call the command with the flag still in its arguments. The shell
+        // parser can consume a registered option before the method runs.
+        def direct = new CommandInput('/inspect', ['--gui', 'hi'] as Object[],
+                new CommandRegistry.CommandSession(terminal, System.in, System.out, System.err))
+        assert groovy.inspect(direct) == null
+        closeObjectBrowser()
+    }
+
+    private static boolean uiStartupFailure(Throwable failure) {
+        Throwable cursor = failure
+        while (cursor != null) {
+            if (cursor instanceof HeadlessException || cursor instanceof AWTError) {
+                return true
+            }
+            String message = cursor.message
+            if (message != null && (message.contains('frame') || message.contains('Headless') || message.contains('X11'))) {
+                return true
+            }
+            cursor = cursor.cause
+        }
+        return false
+    }
+
+    private static void closeObjectBrowser() {
+        if (GraphicsEnvironment.isHeadless()) {
+            return
+        }
+        Runnable close = {
+            for (Window window : Window.getWindows()) {
+                if (window instanceof Frame && 'Groovy Object Browser' == ((Frame) window).title) {
+                    window.dispose()
+                }
+            }
+        }
+        try {
+            if (SwingUtilities.isEventDispatchThread()) {
+                close.run()
+            } else {
+                SwingUtilities.invokeAndWait(close)
+            }
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt()
+        } catch (InvocationTargetException ignored) {
+            // The window is already gone.
+        }
+    }
 }
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..c7974b7f64
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy
@@ -0,0 +1,537 @@
+/*
+ *  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.lsp.GroovyLanguageClient
+import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.eclipse.lsp4j.CompletionItem
+import org.eclipse.lsp4j.CompletionItemKind
+import org.eclipse.lsp4j.SignatureInformation
+import org.jline.console.CmdDesc
+import org.jline.console.CmdLine
+import org.jline.reader.LineReader
+import org.jline.reader.ParsedLine
+import org.jline.terminal.Size
+import org.jline.terminal.impl.DumbTerminal
+import org.jline.utils.AttributedString
+
+import java.nio.charset.StandardCharsets
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+
+import java.util.concurrent.TimeUnit
+
+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 { GroovyLanguageClient.diagnosticText(it) }
+    }
+
+    @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 { GroovyLanguageClient.diagnosticText(it).contains('ConcurrentHashMap') }
+        session.close()
+        groovy = new GroovyEngine()
+        session = new GroovyshLanguageSession(groovy)
+        def without = session.diagnosticsFor('ConcurrentHashMap m')
+        assert without.any { GroovyLanguageClient.diagnosticText(it).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
+    @Timeout(value = 20, unit = TimeUnit.SECONDS)
+    void tabAfterDotListsMembersOfTheListInTheBuffer() {
+        groovy = new GroovyEngine()
+        groovy.put('GROOVYSH_OPTIONS', [interpreterMode: true])
+        groovy.execute('def a = []')
+        session = new GroovyshLanguageSession(groovy)
+        def items = session.completeLine('a.')
+        def labels = items.collect { it.label }
+        assert 'add' in labels || 'size' in labels || 'plus' in labels : labels.take(30)
+        def candidates = []
+        session.complete(null, new Line('a.'), candidates)
+        assert candidates.any { it.value().startsWith('a.') && (it.value().contains('add') || it.value().contains('size') || it.value().contains('plus')) }
+        assert candidates.every { it.group() != 'Function' }
+        assert candidates.any { it.group() == 'Method' && it.displ().startsWith('add') && it.descr() == null }
+        assert candidates.findAll { it.group() == 'Method' }.every { !it.value().contains('(') }
+    }
+
+    @Test
+    void completeLineAndCompleterMapEngineCandidates() {
+        groovy = new GroovyEngine()
+        groovy.execute('class Box { String name }')
+        session = new GroovyshLanguageSession(groovy)
+        def items = session.completeLine('new Box().')
+        assert items != null
+        assert session.completeLine('new Box().', 9) != null
+        assert session.completeLine('new Box().', -1) != null
+        def candidates = []
+        session.complete(null, new Line('Box().'), candidates)
+        session.complete(null, new Line('/grab'), candidates)
+        session.complete(null, null, candidates)
+        session.complete(null, new Line(''), null)
+        assert GroovyshLanguageSession.flattenSnippetLine('class ${1:Name} {\n    $0\n}') == 'class Name {'
+        assert GroovyshLanguageSession.flattenSnippetLine(null) == ''
+        assert GroovyshLanguageSession.flattenSnippetLine('') == ''
+        assert GroovyshLanguageSession.flattenSnippetLine('$0') == ''
+        assert GroovyshLanguageSession.flattenSnippetLine('${1:foo}$2bar') == 'foobar'
+        assert GroovyshLanguageSession.flattenSnippetLine('one\ntwo') == 'one'
+        assert GroovyshLanguageSession.BUFFER_URI.toString() == 'groovy-buffer:repl'
+    }
+
+    @Test
+    void mapEngineCandidatesPrefixesAndSkipsEmptyInsert() {
+        def out = []
+        def items = [
+                item('name', CompletionItemKind.Field, 'String', 'name'),
+                item('', CompletionItemKind.Method, null, ''),
+                item('ping', CompletionItemKind.Method, null, '${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(
+                [item('println', CompletionItemKind.Method, null, 'println')],
+                new Line('pri', 3), out)
+        assert out[0].value() == 'println'
+        out.clear()
+        GroovyshLanguageSession.mapEngineCandidates(
+                [item('name', CompletionItemKind.Field, null, 'Box().name')],
+                new Line('Box().'), out)
+        assert out[0].value() == 'Box().name'
+        out.clear()
+        GroovyshLanguageSession.mapEngineCandidates(
+                [item('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, item('ok', CompletionItemKind.Method, null, 'ok')],
+                new Line('z'), out)
+        assert out.any { it.value() == 'ok' || it.value().endsWith('ok') }
+    }
+
+    @Test
+    void completionGroupsUseFullKindNamesAndShowParameters() {
+        def out = []
+        GroovyshLanguageSession.mapEngineCandidates([
+                item('add', CompletionItemKind.Method, 'boolean add(Object e)', 'add(${1:e})'),
+                item('clear', CompletionItemKind.Method, 'void clear()', 'clear()'),
+                item('of', CompletionItemKind.Function, 'List of(Object e1, Object e2)', 'of(${1:e1}, ${2:e2})'),
+                item('size', CompletionItemKind.Field, 'int', 'size'),
+                item('name', CompletionItemKind.Property, null, 'name'),
+                item('item', CompletionItemKind.Variable, 'String', 'item'),
+                item('List', CompletionItemKind.Class, 'java.util.List', 'List'),
+                item('Face', CompletionItemKind.Interface, 'java.util.List', 'Face'),
+                item('Color', CompletionItemKind.Enum, 'Color', 'Color'),
+                item('abstract', CompletionItemKind.Keyword, null, 'abstract'),
+                item('iff', CompletionItemKind.Snippet, null, 'if (${1:condition}) {\n    $0\n}'),
+                item('each', CompletionItemKind.Method, 'GDK', 'each'),
+        ], new Line('a.'), out)
+        def add = out.find { it.value() == 'a.add' }
+        def clear = out.find { it.value() == 'a.clear' }
+        def of = out.find { it.value() == 'a.of' }
+        def addShown = GroovyshLanguageSession.visible(add.displ())
+        def clearShown = GroovyshLanguageSession.visible(clear.displ())
+        def ofShown = GroovyshLanguageSession.visible(of.displ())
+        assert addShown.startsWith('add(Object e)')
+        assert !addShown.startsWith('add (')
+        assert addShown.endsWith('boolean')
+        assert addShown.length() == clearShown.length()
+        assert clearShown.startsWith('clear()')
+        assert clearShown.endsWith('void')
+        assert ofShown.startsWith('of(Object e1, Object e2)')
+        assert ofShown.endsWith('List')
+        assert ofShown.length() == addShown.length()
+        assert add.displ().startsWith('add\u001B[90m(Object e)')
+        assert add.displ().contains('\u001B[90mboolean')
+        assert add.descr() == null
+        assert add.group() == 'Method'
+        assert add.value() == 'a.add'
+        assert clear.value() == 'a.clear'
+        assert of.value() == 'a.of'
+        assert out.find { it.value().startsWith('a.if (') }
+        assert of.group() == 'Method'
+        assert out.find { it.value() == 'a.size' }.descr() == null
+        assert out.find { it.value() == 'a.size' }.displ() == 'size'
+        assert out.find { it.value() == 'a.size' }.group() == 'Field'
+        assert out.find { it.value() == 'a.name' }.group() == 'Property'
+        assert out.find { it.value() == 'a.item' }.group() == 'Variable'
+        assert out.find { it.value() == 'a.List' }.group() == 'Class'
+        assert out.find { it.value() == 'a.Face' }.group() == 'Interface'
+        assert out.find { it.value() == 'a.Color' }.group() == 'Enum'
+        assert out.find { it.value() == 'a.abstract' }.group() == 'Keyword'
+        assert out.find { it.value().startsWith('a.if') }.group() == 'Snippet'
+        assert out.find { it.value() == 'a.each' }.descr() == null
+        assert out.find { it.value() == 'a.each' }.displ() == 'each'
+        assert GroovyshLanguageSession.returnTypeOf('boolean add(Object e)') == 'boolean'
+        assert GroovyshLanguageSession.returnTypeOf('GDK') == null
+        assert GroovyshLanguageSession.returnTypeOf(null) == null
+        assert GroovyshLanguageSession.columnCount(40) == 1
+        assert GroovyshLanguageSession.columnCount(80) == 2
+        assert GroovyshLanguageSession.columnCount(120) == 3
+        assert GroovyshLanguageSession.columnCount(200) == 4
+        assert GroovyshLanguageSession.cellWidth(80, 2) == 38
+        assert addShown.length() == 38
+        def narrow = []
+        GroovyshLanguageSession.mapEngineCandidates([
+                item('veryLongMethod', CompletionItemKind.Method,
+                        'void veryLongMethod(Map<String, List<Object>> arg0, Collection arg1)',
+                        'veryLongMethod'),
+        ], new Line('a.'), narrow, 40)
+        def cut = GroovyshLanguageSession.visible(narrow[0].displ())
+        assert cut.length() == GroovyshLanguageSession.cellWidth(40, 1)
+        assert cut.contains('...')
+        assert cut.endsWith('void')
+        assert cut.startsWith('veryLongMethod(')
+        assert out.every { it.group() != 'Function' }
+        assert GroovyshLanguageSession.category(null) == 'Other'
+        assert GroovyshLanguageSession.parametersOf(null) == null
+        assert GroovyshLanguageSession.parametersOf('') == null
+        assert GroovyshLanguageSession.insertedText(null) == ''
+        assert GroovyshLanguageSession.insertedText(item('copyOf', CompletionItemKind.Method,
+                'List copyOf(Collection arg0)', 'copyOf(${1:arg0})')) == 'copyOf'
+        assert GroovyshLanguageSession.cellWidth(1, 100) == 1
+        assert GroovyshLanguageSession.cellWidth(0, 0) > 0
+        assert GroovyshLanguageSession.visible(null) == ''
+        assert GroovyshLanguageSession.visible('') == ''
+        def blank = new CompletionItem()
+        blank.kind = CompletionItemKind.Snippet
+        assert GroovyshLanguageSession.insertedText(blank) == ''
+        def tiny = []
+        GroovyshLanguageSession.mapEngineCandidates([
+                item('veryLongMethod', CompletionItemKind.Method,
+                        'void veryLongMethod(Object arg0)', 'veryLongMethod'),
+        ], new Line('a.'), tiny, 4)
+        assert GroovyshLanguageSession.visible(tiny[0].displ())
+        def fit = GroovyshLanguageSession.getDeclaredMethod('fit', String, int)
+        fit.accessible = true
+        assert fit.invoke(null, [null, 4] as Object[]) == ''
+        assert fit.invoke(null, ['abcdef', 0] as Object[]) == ''
+        assert fit.invoke(null, ['abcdef', 2] as Object[]) == 'ab'
+    }
+
+    @Test
+    void zeroWidthTerminalFallsBackToTheDefaultColumns() {
+        groovy = new GroovyEngine()
+        session = new GroovyshLanguageSession(groovy)
+        def terminal = new DumbTerminal('groovysh-test', 'dumb',
+                new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), StandardCharsets.UTF_8)
+        terminal.size = new Size(0, 0)
+        def reader = [getTerminal: { -> terminal }] as LineReader
+        def candidates = []
+        session.complete(reader, new Line('a.'), candidates)
+        assert candidates.any { it.group() == 'Method' }
+    }
+
+    @Test
+    void engineCompleterRecognizesReceiversAndSkipsPackages() {
+        groovy = new GroovyEngine()
+        def type = Class.forName('org.apache.groovy.groovysh.jline.GroovyEngine$MethodCompleter')
+        def ctor = type.getDeclaredConstructor(GroovyEngine)
+        ctor.accessible = true
+        def completer = ctor.newInstance(groovy)
+        def candidates = []
+        completer.complete(null, new Line('notARealName.'), candidates)
+        completer.complete(null, new Line('java.util.'), candidates)
+        groovy.put('kept', 'x')
+        completer.complete(null, new Line('kept.'), candidates)
+        def known = type.getDeclaredMethod('knownReceiver', String)
+        known.accessible = true
+        assert known.invoke(completer, [null] as Object[]) == false
+        assert known.invoke(completer, ['.x'] as Object[]) == false
+        assert known.invoke(completer, ['java.util.'] as Object[]) == false
+    }
+
+    @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.any { it?.label?.contains('ping') }
+        assert GroovyshLanguageSession.describeSignatures(null) == null
+        assert GroovyshLanguageSession.describeSignatures([]) == null
+        def labeled = GroovyshLanguageSession.describeSignatures([
+                new SignatureInformation('ping(String name)'),
+                null,
+                new SignatureInformation('')
+        ])
+        assert labeled.mainDesc.size() == 3
+        assert labeled.mainDesc[0].toString().contains('ping')
+        assert session.signaturesFor('Box.ping(', -1) != null
+        assert session.signaturesFor('Box.ping(', 99) != null
+        assert session.signaturesFor('a\r\nb', 4) != null
+        assert session.signaturesFor('a\rb', 3) != 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.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 CompletionItem item(String label, CompletionItemKind kind, String detail, String insert) {
+        def item = new CompletionItem(label)
+        item.kind = kind
+        item.detail = detail
+        item.insertText = insert
+        item
+    }
+
+    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..e7508b75a0 100644
--- a/subprojects/groovy-lsp/AGENTS.md
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -34,11 +34,34 @@ New Groovy-specific behaviour that is not protocol adaptation belongs
 in one `feature/*` type (not a shared grab bag), `compile/*`, or the
 in-process `engine/*` facade, not a public SPI. Call and override
 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`.
+completion and rename. `GroovyLanguageEngine` remains an internal
+facade that returns JDK records. Groovy Console and groovysh do not
+use it. They share `GroovyLanguageClient`, which calls
+`GroovyLanguageServer` the way a remote editor does (`initialize` without a workspace root,
+`textDocument/*`, `workspace/didChangeConfiguration`,
+`workspace/executeCommand`). Those calls stay in-process. They are not
+JSON-serialized on each keystroke. Hosts do not import
+`org.apache.groovy.lsp.internal`.
+A highlighter sets `groovy.compile` to `false` and paints
+`textDocument/semanticTokens`. String modifiers `slashy` and
+`singleQuoted` are append-only. A string without them is double-quoted.
+The `$` that ends a GString begin or part is omitted from the string
+token. `groovy.imports`, `groovy.starImports`, and
+`groovy.scriptBaseClass` are client settings. `groovy.parentLoader`
+accepts a `ClassLoader` in the same JVM. A remote client sends
+`groovy.classpath` instead. `groovy.scriptNames` returns script-level
+names. `groovy.compile` compiles immediately. Hosts resend
+configuration only when it changed, so an unchanged buffer reuses the
+snapshot.
+Brace matching uses the append-only `delimiter` semantic-token type
+from that same request, so the console does not import `GroovyLexer`.
+groovysh keeps nanorc.
+The server does not reserve buffer names. Console and groovysh each take
+an `implementation` dependency on groovy-lsp. groovysh does not depend
+on groovy-console. LSP4J is an `api` dependency of groovy-lsp so those
+hosts can compile against the protocol types. They do not re-export
+groovy-lsp. `/console` and `/inspect --gui` load `groovy.console.ui`
+by name when that jar is already on the classpath.
 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..88cb3657a7 100644
--- a/subprojects/groovy-lsp/ARCHITECTURE.md
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -189,9 +189,37 @@ 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`.
+  Groovy Console and groovysh dogfood this server the way a remote editor
+  does. Both use `GroovyLanguageClient` in this package. It constructs
+  `GroovyLanguageServer`, connects itself as the `LanguageClient`,
+  and calls `initialize` (no workspace root), `textDocument/didOpen`,
+  full-text `didChange`, `completion`, `hover`, `signatureHelp`,
+  `semanticTokens/full`, `didChangeConfiguration`, and
+  `workspace/executeCommand`. The calls are direct. They are not written
+  out as JSON on each keystroke, and a highlight request does not compile:
+  the highlighter sets `groovy.compile` to `false`, so `didChange` stays
+  lexical. A compiled overlay is used for semantic tokens only when its
+  text is the current buffer. String modifiers `slashy` and `singleQuoted`
+  are append-only (a string without them is double-quoted, and a GString
+  part keeps the modifier of the token that opened the literal). The `$`
+  that ends a GString begin or part is left out of the string token.
+  `groovy.imports` and `groovy.starImports` are comma-separated names.
+  An empty value adds no import customizer. `groovy.scriptBaseClass` sets
+  the script base class. `groovy.parentLoader` takes a `ClassLoader` for
+  an in-process client. A remote client uses `groovy.classpath`.
+  `groovy.scriptNames` returns script-level names. `groovy.compile`
+  compiles immediately and publishes diagnostics. Hosts send configuration
+  again only when the loader identity, classpath, or compiler configuration
+  changed. `didChange` keeps the document version when the text is
+  unchanged, so the next compile reuses the snapshot. In-place `addURL`
+  is still visible because the fingerprint includes that loader's URLs.
+  Brace matching uses the `delimiter` semantic-token type from the same
+  full-token request, including `?[`. The console does not lex the buffer
+  itself. groovysh keeps nanorc. The engine facade
+  still exposes `lexicalTokens`, `setParentLoader`, `setHostConfiguration`,
+  and `updateBuffer` for tests. `setHostConfiguration` drops the cached
+  compile on every call, including a repeat of the same instance. Workspace symbol
   search accepts CamelHumps (`ASF` → `AbstractServerFactory`) as well
   as a case-insensitive substring. Hover Groovydoc is Markdown
   (`{@code}`, `{@link}`, `@param`).
@@ -215,9 +243,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 +279,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 +318,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/build.gradle b/subprojects/groovy-lsp/build.gradle
index eda47f58da..c568ccf9bd 100644
--- a/subprojects/groovy-lsp/build.gradle
+++ b/subprojects/groovy-lsp/build.gradle
@@ -22,8 +22,10 @@ plugins {
 
 dependencies {
     implementation project(':')
-    implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:${versions.lsp4j}"
-    implementation "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:${versions.lsp4j}"
+    // Public server methods use these types, so embedders such as the
+    // console can compile against the same API a remote client speaks.
+    api "org.eclipse.lsp4j:org.eclipse.lsp4j:${versions.lsp4j}"
+    api "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:${versions.lsp4j}"
     testImplementation projects.groovyTest
 }
 
diff --git a/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy b/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy
new file mode 100644
index 0000000000..5c8795c5d9
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy
@@ -0,0 +1,363 @@
+/*
+ *  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
+
+import groovy.transform.Internal
+import groovy.transform.PackageScope
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.eclipse.lsp4j.ClientCapabilities
+import org.eclipse.lsp4j.CompletionItem
+import org.eclipse.lsp4j.CompletionList
+import org.eclipse.lsp4j.CompletionParams
+import org.eclipse.lsp4j.Diagnostic
+import org.eclipse.lsp4j.DidChangeConfigurationParams
+import org.eclipse.lsp4j.DidChangeTextDocumentParams
+import org.eclipse.lsp4j.DidCloseTextDocumentParams
+import org.eclipse.lsp4j.DidOpenTextDocumentParams
+import org.eclipse.lsp4j.ExecuteCommandParams
+import org.eclipse.lsp4j.Hover
+import org.eclipse.lsp4j.HoverParams
+import org.eclipse.lsp4j.InitializeParams
+import org.eclipse.lsp4j.InitializedParams
+import org.eclipse.lsp4j.MessageActionItem
+import org.eclipse.lsp4j.MessageParams
+import org.eclipse.lsp4j.Position
+import org.eclipse.lsp4j.PublishDiagnosticsParams
+import org.eclipse.lsp4j.SemanticTokens
+import org.eclipse.lsp4j.SemanticTokensParams
+import org.eclipse.lsp4j.ShowMessageRequestParams
+import org.eclipse.lsp4j.SignatureHelp
+import org.eclipse.lsp4j.SignatureHelpParams
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent
+import org.eclipse.lsp4j.TextDocumentIdentifier
+import org.eclipse.lsp4j.TextDocumentItem
+import org.eclipse.lsp4j.VersionedTextDocumentIdentifier
+import org.eclipse.lsp4j.jsonrpc.messages.Either
+import org.eclipse.lsp4j.services.LanguageClient
+
+import java.util.concurrent.CompletableFuture
+
+/**
+ * In-process LSP client shared by Groovy Console and groovysh.
+ * It calls {@link GroovyLanguageServer} the way a remote editor does:
+ * {@code initialize}, {@code textDocument/*} and
+ * {@code workspace/executeCommand}. No {@code internal} types.
+ * The calls are direct method calls, not JSON on each keystroke.
+ */
+@Internal
+final class GroovyLanguageClient implements LanguageClient, AutoCloseable {
+
+    final GroovyLanguageServer server
+    final String uri
+    private int version = 1
+    private String sent = ''
+    private final Map<String, List<Diagnostic>> diagnostics = [:]
+
+    private GroovyLanguageClient(GroovyLanguageServer server, String uri) {
+        this.server = server
+        this.uri = uri
+    }
+
+    /**
+     * Opens one untitled buffer. {@code compile} is false for a highlighter
+     * that only asks for semantic tokens.
+     */
+    static GroovyLanguageClient open(String uri, String text, boolean compile) {
+        def client = new GroovyLanguageClient(new GroovyLanguageServer(), uri)
+        client.server.connect(client)
+        def init = new InitializeParams()
+        init.capabilities = new ClientCapabilities()
+        client.server.initialize(init).get()
+        client.settings(compile ? [compile: 'true'] : [compile: 'false'])
+        client.server.initialized(new InitializedParams())
+        def body = text ?: ''
+        client.server.textDocumentService.didOpen(new DidOpenTextDocumentParams(
+                new TextDocumentItem(uri, 'groovy', client.version, body)))
+        client.sent = body
+        client
+    }
+
+    void parentLoader(ClassLoader loader) {
+        server.workspaceService.executeCommand(new ExecuteCommandParams('groovy.parentLoader', [loader])).get()
+    }
+
+    void settings(Map groovy) {
+        server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams([groovy: groovy]))
+    }
+
+    void sync(String text) {
+        String next = text ?: ''
+        if (next == sent) {
+            return
+        }
+        version++
+        server.textDocumentService.didChange(new DidChangeTextDocumentParams(
+                new VersionedTextDocumentIdentifier(uri, version),
+                [new TextDocumentContentChangeEvent(next)]))
+        sent = next
+    }
+
+    List<Diagnostic> compile(String text) {
+        sync(text)
+        server.workspaceService.executeCommand(new ExecuteCommandParams('groovy.compile', [uri])).get()
+        diagnostics[uri] ?: []
+    }
+
+    List<CompletionItem> complete(String text, int line, int character) {
+        sync(text)
+        completionItems(server.textDocumentService.completion(
+                new CompletionParams(new TextDocumentIdentifier(uri), new Position(line, character))).get())
+    }
+
+    /**
+     * Completion items from a {@code textDocument/completion} result.
+     * A missing list, or the {@code CompletionItem[]} form, is empty.
+     */
+    @PackageScope
+    static List<CompletionItem> completionItems(Object result) {
+        if (!(result instanceof Either)) {
+            return []
+        }
+        Either either = (Either) result
+        if (!either.isRight() || either.getRight() == null || !(either.getRight() instanceof CompletionList)) {
+            return []
+        }
+        List<CompletionItem> items = ((CompletionList) either.getRight()).getItems()
+        items == null ? [] : items
+    }
+
+    String hover(String text, int line, int character) {
+        sync(text)
+        hoverText(server.textDocumentService.hover(
+                new HoverParams(new TextDocumentIdentifier(uri), new Position(line, character))).get())
+    }
+
+    /** Hover markdown from either {@link Hover} contents shape. */
+    @PackageScope
+    static String hoverText(Object hover) {
+        if (!(hover instanceof Hover) || ((Hover) hover).contents == null) {
+            return ''
+        }
+        def contents = ((Hover) hover).contents
+        if (contents.isRight()) {
+            return contents.getRight()?.value ?: ''
+        }
+        if (!contents.isLeft() || contents.getLeft() == null) {
+            return ''
+        }
+        contents.getLeft().collect { either ->
+            if (either != null && either.isRight()) {
+                return either.getRight()?.value ?: ''
+            }
+            either == null ? '' : (either.getLeft() ?: '')
+        }.join('\n')
+    }
+
+    SignatureHelp signatures(String text, int line, int character) {
+        sync(text)
+        server.textDocumentService.signatureHelp(
+                new SignatureHelpParams(new TextDocumentIdentifier(uri), new Position(line, character))).get()
+    }
+
+    List<Integer> semanticTokens(String text) {
+        sync(text)
+        tokenData(server.textDocumentService.semanticTokensFull(new SemanticTokensParams(
+                new TextDocumentIdentifier(uri))).get())
+    }
+
+    /** Token data, or an empty list when the response has none. */
+    @PackageScope
+    static List<Integer> tokenData(SemanticTokens tokens) {
+        def data = tokens?.data
+        data == null ? [] : data
+    }
+
+    /**
+     * Lexer keywords, cached after the first request. Includes primitive
+     * names and {@code true}, {@code false}, {@code null}.
+     */
+    static List<String> keywordList() {
+        if (cachedKeywords != null) {
+            return cachedKeywords
+        }
+        def client = open('groovy-buffer:keywords', '', false)
+        try {
+            cachedKeywords = scriptNameList(client.server.workspaceService.executeCommand(
+                    new ExecuteCommandParams('groovy.keywords', [])).get())
+        } finally {
+            client.close()
+        }
+        cachedKeywords
+    }
+
+    private static volatile List<String> cachedKeywords
+
+    /**
+     * Lenient lexer tokens for {@code text}. Each map has {@code name},
+     * {@code text}, {@code line}, {@code character} and {@code length}.
+     */
+    List<Map<String, Object>> tokens(String text) {
+        sync(text)
+        def rows = server.workspaceService.executeCommand(
+                new ExecuteCommandParams('groovy.tokens', [uri])).get()
+        rows instanceof List ? rows : []
+    }
+
+    /**
+     * Concrete syntax tree for {@code text}. The map has {@code name},
+     * {@code text} and {@code children}.
+     */
+    Map<String, Object> cst(String text) {
+        sync(text)
+        def tree = server.workspaceService.executeCommand(
+                new ExecuteCommandParams('groovy.cst', [uri])).get()
+        tree instanceof Map ? tree : [name: '', text: '', children: []]
+    }
+
+    List<String> scriptNames(String text) {
+        sync(text)
+        scriptNameList(server.workspaceService.executeCommand(
+                new ExecuteCommandParams('groovy.scriptNames', [uri])).get())
+    }
+
+    /** Script names from {@code groovy.scriptNames}, or empty when the result is not a list. */
+    @PackageScope
+    static List<String> scriptNameList(Object names) {
+        if (!(names instanceof List)) {
+            return []
+        }
+        names.collect { it == null ? '' : it.toString() }
+    }
+
+    /**
+     * Diagnostic text. LSP 3.18 sends either a string or markup.
+     * Hosts in other packages use this to render tail-tips.
+     */
+    static String diagnosticText(Diagnostic diagnostic) {
+        def message = diagnostic?.message
+        if (!(message instanceof Either)) {
+            return message == null ? '' : message.toString()
+        }
+        Either either = (Either) message
+        if (either.isRight()) {
+            def right = either.getRight()
+            return right == null ? '' : (right.value ?: '')
+        }
+        def left = either.getLeft()
+        left == null ? '' : left.toString()
+    }
+
+    /**
+     * File URLs of a URLClassLoader, in listed order.
+     * Other loaders, and non-file URLs, contribute nothing.
+     */
+    static List<String> classpathOf(ClassLoader loader) {
+        if (!(loader instanceof URLClassLoader)) {
+            return []
+        }
+        ((URLClassLoader) loader).URLs.findAll { it != null && it.protocol == 'file' }
+                .collect { it.toURI().path }
+    }
+
+    /** Comma-separated regular imports from {@code configuration}, or empty. */
+    static String importsOf(CompilerConfiguration configuration) {
+        namesOf(configuration, 'regular').join(',')
+    }
+
+    /** Comma-separated star imports from {@code configuration}, or empty. */
+    static String starImportsOf(CompilerConfiguration configuration) {
+        namesOf(configuration, 'star').join(',')
+    }
+
+    private static List<String> namesOf(CompilerConfiguration configuration, String kind) {
+        if (configuration == null) {
+            return []
+        }
+        List<String> names = []
+        configuration.compilationCustomizers?.each { customizer ->
+            if (customizer instanceof ImportCustomizer) {
+                def field = ImportCustomizer.getDeclaredField('imports')
+                field.accessible = true
+                field.get(customizer).each { imp ->
+                    def typeField = imp.getClass().getDeclaredField('type')
+                    typeField.accessible = true
+                    if (String.valueOf(typeField.get(imp)) != kind) {
+                        return
+                    }
+                    if (kind == 'star') {
+                        def starField = imp.getClass().getDeclaredField('star')
+                        starField.accessible = true
+                        def star = starField.get(imp)
+                        if (star != null) {
+                            names << star.toString()
+                        }
+                    } else {
+                        def nodeField = imp.getClass().getDeclaredField('classNode')
+                        nodeField.accessible = true
+                        def node = nodeField.get(imp)
+                        if (node instanceof ClassNode) {
+                            names << ((ClassNode) node).getName()
+                        }
+                    }
+                }
+            }
+        }
+        names
+    }
+
+    /** Diagnostics last published for this buffer, or {@code null} when none were. */
+    @PackageScope
+    List<Diagnostic> publishedOrNull() {
+        diagnostics.containsKey(uri) ? diagnostics[uri] : null
+    }
+
+    @Override
+    void publishDiagnostics(PublishDiagnosticsParams params) {
+        if (params == null) {
+            return
+        }
+        diagnostics[params.uri] = params.diagnostics ?: []
+    }
+
+    @Override
+    void telemetryEvent(Object object) {
+    }
+
+    @Override
+    void showMessage(MessageParams messageParams) {
+    }
+
+    @Override
+    CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams requestParams) {
+        CompletableFuture.completedFuture(null)
+    }
+
+    @Override
+    void logMessage(MessageParams message) {
+    }
+
+    @Override
+    void close() {
+        server.textDocumentService.didClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier(uri)))
+        server.shutdown().get()
+        server.exit()
+    }
+}
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 5db2d68807..a86c91871a 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;
@@ -79,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;
@@ -130,7 +136,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.
@@ -146,7 +152,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);
                 }
@@ -171,6 +177,11 @@ public final class LanguageServerContext {
         if (shutdown || debounce.isShutdown()) {
             return;
         }
+        // A highlighter client sets groovy.compile=false so didChange stays
+        // a lexer-only update and does not start a second compiler.
+        if ("false".equals(settings.get().getExtra().get("compile"))) {
+            return;
+        }
         if (pendingCompile != null) {
             pendingCompile.cancel(false);
         }
@@ -196,7 +207,11 @@ public final class LanguageServerContext {
             List<CompiledDocument> documents;
             Set<URI> dropped;
             synchronized (this) {
-                documents = List.copyOf(snapshot.get().documents());
+                CompilationSnapshot committed = snapshot.get();
+                if (committed == null) {
+                    return;
+                }
+                documents = List.copyOf(committed.documents());
                 Set<URI> currentUris = new LinkedHashSet<>();
                 for (CompiledDocument document : documents) {
                     if (document.getUri() != null) {
@@ -411,8 +426,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}
      */
@@ -430,6 +446,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;
@@ -507,8 +544,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
@@ -518,6 +556,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('|')
@@ -526,27 +581,64 @@ public final class LanguageServerContext {
                 .append(settings.isAstTestEnabled()).append('|')
                 .append(settings.getThroughPhase()).append('|')
                 .append(settings.getExtra()).append('|')
-                .append(System.identityHashCode(parentLoader));
-        if (open != null) {
-            for (TextDocument document : open) {
-                builder.append('\n').append(document.getUri()).append(':')
-                        .append(document.getVersion()).append(':')
-                        .append(document.getText().hashCode());
+                .append(System.identityHashCode(parentLoader)).append('|');
+        appendHostConfiguration(builder, hostConfiguration);
+        appendLoaderUrls(builder, parentLoader);
+        appendOpenDocuments(builder, open);
+        appendExtraFiles(builder, extra);
+        return builder.toString();
+    }
+
+    private static void appendLoaderUrls(final StringBuilder builder, final ClassLoader parentLoader) {
+        if (parentLoader instanceof URLClassLoader urls) {
+            URL[] listed = urls.getURLs();
+            if (listed != null) {
+                for (URL url : listed) {
+                    builder.append('|').append(url == null ? "" : url.toExternalForm());
+                }
             }
         }
-        if (extra != null) {
-            for (Path path : extra) {
-                long mtime = 0L;
-                long size = 0L;
-                try {
-                    mtime = Files.getLastModifiedTime(path).toMillis();
-                    size = Files.size(path);
-                } catch (Exception ignored) {
-                    // missing files still participate as a zero stamp
-                }
-                builder.append('\n').append(path).append(':').append(mtime).append(':').append(size);
+    }
+
+    private static void appendOpenDocuments(final StringBuilder builder, final Collection<TextDocument> open) {
+        if (open == null) {
+            return;
+        }
+        for (TextDocument document : open) {
+            builder.append('\n').append(document.getUri()).append(':')
+                    .append(document.getVersion()).append(':')
+                    .append(document.getText().hashCode());
+        }
+    }
+
+    private static void appendExtraFiles(final StringBuilder builder, final Collection<Path> extra) {
+        if (extra == null) {
+            return;
+        }
+        for (Path path : extra) {
+            long mtime = 0L;
+            long size = 0L;
+            try {
+                mtime = Files.getLastModifiedTime(path).toMillis();
+                size = Files.size(path);
+            } catch (Exception ignored) {
+                // missing files still participate as a zero stamp
             }
+            builder.append('\n').append(path).append(':').append(mtime).append(':').append(size);
+        }
+    }
+
+    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));
         }
-        return builder.toString();
     }
 }
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..24b6dd2e75 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,8 @@ 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 org.codehaus.groovy.control.customizers.ImportCustomizer;
 
 import java.io.File;
 import java.net.MalformedURLException;
@@ -33,7 +35,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-
+import java.util.function.Consumer;
 
 /**
  * Compilation options for the language server. Grape and {@code @ASTTest}
@@ -241,7 +243,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()) {
@@ -259,9 +282,48 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
         }
         configuration.setDisabledGlobalASTTransformations(disabled);
         configuration.getOptimizationOptions().put(CompilerConfiguration.GROOVYDOC, Boolean.TRUE);
+        applyClientImports(configuration, extra);
         return configuration;
     }
 
+    /**
+     * {@code groovy.imports} and {@code groovy.starImports} are comma-separated
+     * class or package names. An empty value adds nothing, which clears a
+     * previous client import list because each compile builds a fresh
+     * configuration.
+     */
+    private static void applyClientImports(final CompilerConfiguration configuration,
+                                           final Map<String, String> extra) {
+        if (extra == null || extra.isEmpty()) {
+            return;
+        }
+        String imports = extra.get("imports");
+        String stars = extra.get("starImports");
+        String base = extra.get("scriptBaseClass");
+        if (base != null && !base.isEmpty()) {
+            configuration.setScriptBaseClass(base);
+        }
+        if ((imports == null || imports.isEmpty()) && (stars == null || stars.isEmpty())) {
+            return;
+        }
+        ImportCustomizer customizer = new ImportCustomizer();
+        addCsv(imports, customizer::addImports);
+        addCsv(stars, customizer::addStarImports);
+        configuration.addCompilationCustomizers(customizer);
+    }
+
+    private static void addCsv(final String csv, final Consumer<String> add) {
+        if (csv == null) {
+            return;
+        }
+        for (String name : csv.split(",")) {
+            String trimmed = name.trim();
+            if (!trimmed.isEmpty()) {
+                add.accept(trimmed);
+            }
+        }
+    }
+
     /**
      * Creates a class loader covering the configured classpath.
      *
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..db660fde0f
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ScriptNames.java
@@ -0,0 +1,149 @@
+/*
+ *  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()) {
+            collect(classNode, names);
+        }
+        return names;
+    }
+
+    private static void collect(final ClassNode classNode, final Set<String> names) {
+        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);
+            }
+        }
+    }
+
+    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) {
+                // Names assigned inside a closure are not script-level bindings.
+            }
+
+            @Override
+            public void visitLambdaExpression(final LambdaExpression expression) {
+                // Names assigned inside a lambda are not script-level bindings.
+            }
+
+            @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..175fde4419 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 = List.copyOf(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()) {
@@ -383,18 +476,77 @@ public final class GroovyLanguageEngine implements AutoCloseable {
     public List<Candidate> complete(final URI uri, final int line, final int character) {
         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()
+                new Position(line, character), encoding(), context.getParentLoader(), context.getHostConfiguration());
+        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/CompletionService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
index ddf4637514..7688503468 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
@@ -18,6 +18,9 @@
  */
 package org.apache.groovy.lsp.internal.feature;
 
+import groovy.lang.GroovyClassLoader;
+import groovy.lang.GroovySystem;
+import groovy.lang.MetaMethod;
 import groovy.lang.groovydoc.Groovydoc;
 import org.apache.groovy.lsp.internal.compile.AstQuery;
 import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
@@ -28,22 +31,49 @@ 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.workspace.TextDocument;
+import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.AnnotatedNode;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.ImportNode;
 import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.ModuleNode;
 import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
 import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
 import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.MapEntryExpression;
 import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
 import org.codehaus.groovy.ast.expr.PropertyExpression;
 import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.control.CompilationFailedException;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.control.Phases;
+import org.codehaus.groovy.control.ResolveVisitor;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.control.customizers.CompilationCustomizer;
+import org.codehaus.groovy.reflection.CachedClass;
+import org.codehaus.groovy.reflection.CachedMethod;
+import org.codehaus.groovy.reflection.ReflectionParameterNames;
 import org.codehaus.groovy.runtime.DefaultGroovyMethods;
+import org.codehaus.groovy.runtime.DefaultGroovyStaticMethods;
+import org.codehaus.groovy.runtime.InvokerHelper;
+import org.codehaus.groovy.runtime.m12n.ExtensionModule;
+import org.codehaus.groovy.runtime.metaclass.MetaClassRegistryImpl;
+import org.codehaus.groovy.runtime.metaclass.NewInstanceMetaMethod;
+import org.codehaus.groovy.runtime.metaclass.NewMetaMethod;
+import org.codehaus.groovy.runtime.metaclass.NewStaticMetaMethod;
+import org.codehaus.groovy.runtime.metaclass.ReflectionMetaMethod;
+import org.codehaus.groovy.vmplugin.VMPluginFactory;
+import org.codehaus.groovy.syntax.Types;
 import org.eclipse.lsp4j.CompletionItem;
 import org.eclipse.lsp4j.CompletionItemKind;
 import org.eclipse.lsp4j.CompletionList;
@@ -53,17 +83,22 @@ import org.eclipse.lsp4j.MarkupKind;
 import org.eclipse.lsp4j.Position;
 import org.eclipse.lsp4j.jsonrpc.messages.Either;
 
+import static org.objectweb.asm.Opcodes.ACC_VARARGS;
+
+import java.io.IOException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
-import java.util.stream.Collectors;
+import java.util.function.Consumer;
 
 /**
  * Keyword, snippet, member, workspace-type and GDK completions.
@@ -72,7 +107,16 @@ import java.util.stream.Collectors;
 public final class CompletionService {
 
     static final int MAX_ITEMS = 80;
-    private static final List<String> GDK_NAMES = gdkMethodNames();
+    /**
+     * Member completion of a resolved receiver is a finite set: the type's
+     * own members plus the extension methods that apply to it. The smaller
+     * cap is for open searches, such as the whole GDK catalog.
+     */
+    static final int MAX_MEMBER_ITEMS = 1024;
+    private static final String OBJECT_NAME = "Object";
+    private static final String LENGTH = "length";
+    /** Instance and static extension methods, including the GDK and classpath modules. */
+    private static final List<MetaMethod> EXTENSION_METHODS = extensionCatalog();
 
     /**
      * Completes at {@code position} in {@code document}.
@@ -85,12 +129,31 @@ public final class CompletionService {
      */
     public CompletionList complete(final TextDocument document, final CompilationSnapshot snapshot,
                                    final Position position, final PositionEncoding encoding) {
+        return complete(document, snapshot, position, encoding, null, null);
+    }
+
+    /**
+     * Completes at {@code position}. {@code parentLoader} and {@code hostConfiguration}
+     * are used only when the buffer did not parse and the caret is a member
+     * access: the broken line is rewritten so the declarations before it can
+     * still be typed.
+     *
+     * @param parentLoader compile parent loader, or {@code null}
+     * @param hostConfiguration console or groovysh configuration, or {@code null}
+     * @return a completion list (incomplete when truncated)
+     */
+    public CompletionList complete(final TextDocument document, final CompilationSnapshot snapshot,
+                                   final Position position, final PositionEncoding encoding,
+                                   final ClassLoader parentLoader, final CompilerConfiguration hostConfiguration) {
         List<CompletionItem> items = new ArrayList<>();
         String prefix = prefixAt(document, position, encoding);
         boolean memberAccess = isMemberAccess(document, position, encoding);
 
         CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
         ModuleNode module = compiled == null ? null : compiled.getModule();
+        if (module == null && memberAccess && compiled != null) {
+            module = repairedModule(document, position, parentLoader, hostConfiguration);
+        }
 
         if (!memberAccess) {
             addMatching(items, GroovyKeywords.KEYWORDS, prefix, CompletionItemKind.Keyword);
@@ -100,12 +163,20 @@ public final class CompletionService {
         if (module != null) {
             addFromAst(items, document, compiled, snapshot, position, encoding, module);
         } else if (memberAccess && prefix.length() >= 2) {
-            addGdk(items, prefix);
+            addExtensionMethods(items, prefix, null, false);
         }
 
-        boolean incomplete = items.size() > MAX_ITEMS;
+        if (memberAccess) {
+            items.sort(Comparator.comparing(CompletionItem::getSortText, Comparator.nullsLast(Comparator.naturalOrder())));
+        }
+        // A resolved receiver already listed only its own members and the
+        // extension methods that apply to it. Cutting that list at the open
+        // search cap drops extension methods, which then reappear once a
+        // longer prefix makes the Java members few enough to fit.
+        int limit = memberAccess && module != null ? MAX_MEMBER_ITEMS : MAX_ITEMS;
+        boolean incomplete = items.size() > limit;
         if (incomplete) {
-            items = new ArrayList<>(items.subList(0, MAX_ITEMS));
+            items = new ArrayList<>(items.subList(0, limit));
         }
         return new CompletionList(incomplete, items);
     }
@@ -271,19 +342,6 @@ public final class CompletionService {
         }
     }
 
-    private static ClassNode receiverOf(final ASTNode node) {
-        if (node instanceof PropertyExpression property) {
-            return property.getObjectExpression().getType();
-        }
-        if (node instanceof VariableExpression variable) {
-            return variable.getType();
-        }
-        if (node instanceof ClassNode classNode) {
-            return classNode;
-        }
-        return null;
-    }
-
     private static void addLocals(final List<CompletionItem> items, final ModuleNode module, final int line,
                                   final int column, final String prefix) {
         MethodNode enclosing = AstQuery.enclosingMethod(module, line, column);
@@ -296,7 +354,7 @@ public final class CompletionService {
             }
             CompletionItem item = new CompletionItem(parameter.getName());
             item.setKind(CompletionItemKind.Variable);
-            item.setDetail(parameter.getType().getNameWithoutPackage());
+            item.setDetail(typeName(parameter.getType()));
             item.setSortText("0" + parameter.getName());
             items.add(item);
         }
@@ -333,7 +391,7 @@ public final class CompletionService {
             }
             CompletionItem item = new CompletionItem(field.getName());
             item.setKind(CompletionItemKind.Field);
-            item.setDetail(field.getType() == null ? null : field.getType().getNameWithoutPackage());
+            item.setDetail(typeName(field.getType()));
             item.setSortText("2" + field.getName());
             items.add(item);
         }
@@ -348,25 +406,279 @@ public final class CompletionService {
         }
     }
 
-    private static void addGdk(final List<CompletionItem> items, final String prefix) {
-        for (String name : GDK_NAMES) {
-            if (!prefixMatches(name, prefix)) {
+    /**
+     * Instance or static extension methods, including the GDK and any
+     * {@code META-INF/groovy} extension module. The detail is a real
+     * signature; the receiver parameter is not part of it.
+     */
+    private static void addExtensionMethods(final List<CompletionItem> items, final String prefix,
+                                            final ClassNode receiver, final boolean staticAccess) {
+        Set<String> seen = new LinkedHashSet<>();
+        for (MetaMethod method : extensionMethods(receiver, staticAccess)) {
+            String name = method.getName();
+            if (name != null && !name.isEmpty() && name.charAt(0) != '<' && name.indexOf('$') < 0
+                    && prefixMatches(name, prefix)) {
+                String detail = extensionDetail(method);
+                if (seen.add(name + " " + detail) && !alreadyOffered(items, name, detail)) {
+                    CompletionItem item = new CompletionItem(name);
+                    item.setKind(CompletionItemKind.Method);
+                    item.setDetail(detail);
+                    item.setSortText("2" + name);
+                    item.setInsertText(name);
+                    items.add(item);
+                }
+            }
+        }
+    }
+
+    private static List<MetaMethod> extensionMethods(final ClassNode receiver, final boolean staticAccess) {
+        List<MetaMethod> methods = null;
+        Class<?> type = typeClass(receiver);
+        if (type != null) {
+            // A loaded receiver lists only the extension methods that apply
+            // to it. An empty list stays empty: the whole catalog is not a
+            // substitute for a type that adds nothing.
+            List<MetaMethod> found = InvokerHelper.getMetaClass(type).getMetaMethods();
+            methods = found == null ? List.of() : found;
+        } else {
+            methods = EXTENSION_METHODS;
+        }
+        return methods.stream()
+                .filter(method -> method != null && method.isStatic() == staticAccess)
+                .toList();
+    }
+
+    private static Class<?> typeClass(final ClassNode receiver) {
+        if (!usable(receiver) || !receiver.isResolved()) {
+            return null;
+        }
+        try {
+            return receiver.getTypeClass();
+        } catch (GroovyBugError | RuntimeException | LinkageError ignored) {
+            // getTypeClass throws GroovyBugError when the node is not a loaded class
+            return null;
+        }
+    }
+
+    /**
+     * Methods Groovy adds to existing classes: the GDK records, the VM plugin
+     * variants, and every extension module visible to the Groovy runtime.
+     */
+    private static List<MetaMethod> extensionCatalog() {
+        Set<MetaMethod> methods = new LinkedHashSet<>();
+        addInstanceExtensions(methods, DefaultGroovyMethods.class);
+        for (Class<?> extra : DefaultGroovyMethods.ADDITIONAL_CLASSES) {
+            addInstanceExtensions(methods, extra);
+        }
+        for (Class<?> plugin : VMPluginFactory.getPlugin().getPluginDefaultGroovyMethods()) {
+            addInstanceExtensions(methods, plugin);
+        }
+        addStaticExtensions(methods, DefaultGroovyStaticMethods.class);
+        for (Class<?> plugin : VMPluginFactory.getPlugin().getPluginStaticGroovyMethods()) {
+            addStaticExtensions(methods, plugin);
+        }
+        if (GroovySystem.getMetaClassRegistry() instanceof MetaClassRegistryImpl registry) {
+            for (ExtensionModule module : registry.getModuleRegistry().getModules()) {
+                try {
+                    methods.addAll(module.getMetaMethods());
+                } catch (RuntimeException | LinkageError ignored) {
+                    // a module that cannot load does not hide the others
+                }
+            }
+        }
+        return List.copyOf(methods);
+    }
+
+    private static void addInstanceExtensions(final Set<MetaMethod> into, final Class<?> type) {
+        addExtensions(into, type, true);
+    }
+
+    private static void addStaticExtensions(final Set<MetaMethod> into, final Class<?> type) {
+        addExtensions(into, type, false);
+    }
+
+    private static void addExtensions(final Set<MetaMethod> into, final Class<?> type, final boolean instance) {
+        if (type == null) {
+            return;
+        }
+        for (Method method : type.getMethods()) {
+            if (!Modifier.isStatic(method.getModifiers()) || !Modifier.isPublic(method.getModifiers())
+                    || method.getDeclaringClass() == Object.class || method.getParameterCount() == 0) {
                 continue;
             }
-            CompletionItem item = new CompletionItem(name);
-            item.setKind(CompletionItemKind.Method);
-            item.setDetail("GDK");
-            item.setSortText("4" + name);
-            items.add(item);
+            CachedMethod cached = CachedMethod.find(method);
+            if (cached == null) {
+                cached = new CachedMethod(method);
+            }
+            into.add(instance ? new NewInstanceMetaMethod(cached) : new NewStaticMetaMethod(cached));
+        }
+    }
+
+    private static boolean alreadyOffered(final List<CompletionItem> items, final String name, final String detail) {
+        int arity = arityOf(detail);
+        for (CompletionItem item : items) {
+            if (name.equals(item.getLabel()) && arityOf(item.getDetail()) == arity) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static int arityOf(final String detail) {
+        if (detail == null) {
+            return -1;
         }
+        int open = detail.lastIndexOf('(');
+        int close = detail.lastIndexOf(')');
+        if (open < 0 || close <= open) {
+            return -1;
+        }
+        String params = detail.substring(open + 1, close).trim();
+        if (params.isEmpty()) {
+            return 0;
+        }
+        int depth = 0;
+        int commas = 0;
+        for (int i = 0; i < params.length(); i++) {
+            char c = params.charAt(i);
+            if (c == '<' || c == '(') {
+                depth++;
+            } else if (c == '>' || c == ')') {
+                depth--;
+            } else if (c == ',' && depth == 0) {
+                commas++;
+            }
+        }
+        return commas + 1;
     }
 
-    private static List<String> gdkMethodNames() {
-        return List.copyOf(Arrays.stream(DefaultGroovyMethods.class.getMethods())
-                .filter(method -> Modifier.isStatic(method.getModifiers()) && Modifier.isPublic(method.getModifiers())
-                        && method.getParameterCount() > 0)
-                .map(Method::getName)
-                .collect(Collectors.toCollection(LinkedHashSet::new)));
+    /**
+     * {@code ReturnType name(Type param, ...)} for an extension method.
+     * The first bytecode parameter is the receiver and is left out.
+     */
+    private static String extensionDetail(final MetaMethod method) {
+        CachedClass[] logical = method.getParameterTypes();
+        int count = logical == null ? 0 : logical.length;
+        String[] names = parameterNames(method, count);
+        StringBuilder builder = new StringBuilder();
+        builder.append(simpleName(method.getReturnType())).append(' ').append(method.getName()).append('(');
+        for (int i = 0; i < count; i++) {
+            if (i > 0) {
+                builder.append(", ");
+            }
+            Class<?> type = logical[i] == null ? null : logical[i].getTheClass();
+            builder.append(simpleName(type)).append(' ').append(names[i]);
+        }
+        builder.append(')');
+        return builder.toString();
+    }
+
+    /**
+     * Names of the call parameters. Extension methods hide the receiver, so a
+     * reflective {@code arg1} is shown as {@code arg0}.
+     */
+    private static String[] parameterNames(final MetaMethod method, final int count) {
+        String[] names = new String[count];
+        for (int i = 0; i < count; i++) {
+            names[i] = "arg" + i;
+        }
+        String[] resolved = method instanceof ReflectionMetaMethod reflection
+                ? reflectionNames(reflection, count)
+                : extensionParameterNames().get(extensionKey(method));
+        if (resolved == null) {
+            return names;
+        }
+        for (int i = 0; i < count && i < resolved.length; i++) {
+            String name = resolved[i];
+            if (name != null && !name.isEmpty() && !name.matches("arg\\d+")) {
+                names[i] = name;
+            }
+        }
+        return names;
+    }
+
+    /**
+     * Names of the parameters the caller writes. A {@link NewMetaMethod} hides
+     * the receiver, which is the first bytecode parameter.
+     */
+    private static String[] reflectionNames(final ReflectionMetaMethod reflection, final int count) {
+        String[] names = new String[count];
+        MetaMethod cached = reflection.getCachedMethod();
+        if (!(cached instanceof CachedMethod cachedMethod)) {
+            return names;
+        }
+        Method reflected = cachedMethod.getCachedMethod();
+        int start = reflection instanceof NewMetaMethod ? 1 : 0;
+        String[] resolved = ReflectionParameterNames.names(reflected);
+        if (resolved == null) {
+            return names;
+        }
+        for (int i = 0; i < count; i++) {
+            int source = start + i;
+            if (source >= resolved.length) {
+                break;
+            }
+            names[i] = resolved[source];
+        }
+        return names;
+    }
+
+    private static Map<String, String[]> extensionParameterNames() {
+        return ExtensionParameterNameCache.NAMES;
+    }
+
+    /**
+     * Logical parameter names for extension methods, keyed by name and erased
+     * parameter types. Runtime entries are often generated proxies that no
+     * longer point at the original method. The map is published by class
+     * initialization, so readers do not need a volatile reference.
+     */
+    private static final class ExtensionParameterNameCache {
+        private static final Map<String, String[]> NAMES = load();
+
+        private static Map<String, String[]> load() {
+            Set<MetaMethod> sources = new LinkedHashSet<>();
+            // StringGroovyMethods and the other JDK categories are not in
+            // ADDITIONAL_CLASSES. The runtime serves them as generated
+            // proxies, so their names have to be read from the source class.
+            for (Class<?> type : DefaultGroovyMethods.DGM_LIKE_CLASSES) {
+                addInstanceExtensions(sources, type);
+                addStaticExtensions(sources, type);
+            }
+            sources.addAll(EXTENSION_METHODS);
+            Map<String, String[]> loaded = new HashMap<>();
+            for (MetaMethod method : sources) {
+                if (method instanceof ReflectionMetaMethod reflection) {
+                    int count = method.getParameterTypes() == null ? 0 : method.getParameterTypes().length;
+                    loaded.putIfAbsent(extensionKey(method), reflectionNames(reflection, count));
+                }
+            }
+            return Map.copyOf(loaded);
+        }
+    }
+
+    /** Name plus erased call-parameter types. The receiver is not included. */
+    private static String extensionKey(final MetaMethod method) {
+        StringBuilder key = new StringBuilder();
+        key.append(method.isStatic() ? 'S' : 'I').append(method.getName());
+        Class<?>[] types = method.getNativeParameterTypes();
+        if (types != null) {
+            for (Class<?> type : types) {
+                key.append('\0').append(type == null ? "?" : type.getName());
+            }
+        }
+        return key.toString();
+    }
+
+    private static String simpleName(final Class<?> type) {
+        if (type == null) {
+            return OBJECT_NAME;
+        }
+        if (type.isArray()) {
+            return simpleName(type.getComponentType()) + "[]";
+        }
+        String name = type.getSimpleName();
+        return name.isEmpty() ? type.getName() : name;
     }
 
     private static void addSnippets(final List<CompletionItem> items, final String prefix) {
@@ -413,19 +725,85 @@ public final class CompletionService {
 
     private static String signature(final MethodNode method) {
         StringBuilder builder = new StringBuilder();
-        ClassNode type = method.getReturnType() == null ? ClassHelper.OBJECT_TYPE : method.getReturnType();
-        builder.append(type.getNameWithoutPackage()).append(' ').append(method.getName()).append('(');
+        builder.append(typeName(method.getReturnType())).append(' ').append(method.getName()).append('(');
         Parameter[] parameters = method.getParameters();
-        for (int i = 0; i < parameters.length; i++) {
-            if (i > 0) {
-                builder.append(", ");
+        boolean varargs = parameters != null && (method.getModifiers() & ACC_VARARGS) != 0;
+        if (parameters != null) {
+            for (int i = 0; i < parameters.length; i++) {
+                if (i > 0) {
+                    builder.append(", ");
+                }
+                String rendered = typeName(parameters[i].getType());
+                if (varargs && i == parameters.length - 1 && rendered.endsWith("[]")) {
+                    rendered = rendered.substring(0, rendered.length() - 2) + "...";
+                }
+                builder.append(rendered).append(' ').append(parameters[i].getName());
             }
-            builder.append(parameters[i].getType().getNameWithoutPackage()).append(' ').append(parameters[i].getName());
         }
         builder.append(')');
         return builder.toString();
     }
 
+    /**
+     * Source-style type name. Array class nodes keep the JVM descriptor as
+     * their internal name ({@code [C}, {@code [Ljava.lang.String;});
+     * {@link ClassNode#getNameWithoutPackage()} then yields {@code [C} or
+     * {@code String;}.
+     */
+    static String typeName(final ClassNode type) {
+        if (type == null) {
+            return OBJECT_NAME;
+        }
+        if (type.isArray()) {
+            return typeName(type.getComponentType()) + "[]";
+        }
+        String name = type.getName();
+        if (name != null && !name.isEmpty() && name.charAt(0) == '[') {
+            return descriptorToName(name);
+        }
+        return unqualified(name);
+    }
+
+    private static String descriptorToName(final String name) {
+        int dims = 0;
+        int i = 0;
+        while (i < name.length() && name.charAt(i) == '[') {
+            dims++;
+            i++;
+        }
+        if (dims == 0 || i >= name.length()) {
+            return unqualified(name);
+        }
+        String base = switch (name.charAt(i)) {
+            case 'B' -> "byte";
+            case 'C' -> "char";
+            case 'D' -> "double";
+            case 'F' -> "float";
+            case 'I' -> "int";
+            case 'J' -> "long";
+            case 'S' -> "short";
+            case 'Z' -> "boolean";
+            case 'L' -> {
+                int end = name.indexOf(';', i);
+                String binary = end < 0 ? name.substring(i + 1) : name.substring(i + 1, end);
+                yield unqualified(binary);
+            }
+            default -> name;
+        };
+        return base + "[]".repeat(dims);
+    }
+
+    private static String unqualified(final String name) {
+        if (name == null || name.isEmpty()) {
+            return OBJECT_NAME;
+        }
+        int dot = name.lastIndexOf('.');
+        int slash = name.lastIndexOf('/');
+        int cut = Math.max(dot, slash);
+        String simple = cut >= 0 ? name.substring(cut + 1) : name;
+        return simple.replace('$', '.');
+    }
+
     private static String methodSnippet(final MethodNode method) {
         Parameter[] parameters = method.getParameters();
         if (parameters.length == 0) {
@@ -448,12 +826,14 @@ public final class CompletionService {
         String prefix = prefixAt(document, position, encoding);
         boolean memberAccess = isMemberAccess(document, position, encoding);
         int[] groovy = document.toGroovy(position, encoding);
-        ASTNode node = AstQuery.nodeAt(module, document, position, encoding);
-        ClassNode receiver = receiverOf(node);
         if (memberAccess) {
-            addMembers(items, receiver == null ? ClassHelper.OBJECT_TYPE : receiver, prefix);
-            if (prefix.length() >= 2) {
-                addGdk(items, prefix);
+            Receiver receiverType = resolveReceiver(document, compiled.getSourceUnit(), module, position, encoding);
+            addVisibleMembers(items, receiverType.type, prefix, receiverType.staticAccess);
+            // A reflectable receiver contributes its own extension methods at
+            // once. An unresolved receiver still waits for two characters,
+            // because the only list available then is the whole catalog.
+            if (typeClass(receiverType.type) != null || prefix.length() >= 2) {
+                addExtensionMethods(items, prefix, receiverType.type, receiverType.staticAccess);
             }
             return;
         }
@@ -466,6 +846,558 @@ public final class CompletionService {
         addWorkspaceTypes(items, compiled, snapshot, prefix, encoding);
     }
 
+    /**
+     * Receiver of a member completion. {@code staticAccess} is true when the
+     * text names a class ({@code String.}) rather than a value.
+     */
+    private static final class Receiver {
+        private final ClassNode type;
+        private final boolean staticAccess;
+
+        private Receiver(final ClassNode type, final boolean staticAccess) {
+            this.type = type == null ? ClassHelper.OBJECT_TYPE : type;
+            this.staticAccess = staticAccess;
+        }
+    }
+
+    private static Receiver resolveReceiver(final TextDocument document, final SourceUnit unit,
+                                            final ModuleNode module, final Position position,
+                                            final PositionEncoding encoding) {
+        int[] groovy = document.toGroovy(position, encoding);
+        String line = Positions.lineText(document.getText(), groovy[0]);
+        int caret = caretOffset(line, groovy[1]);
+        List<String> parts = receiverParts(line, caret);
+        ClassNode type = null;
+        boolean staticAccess = false;
+        if (!parts.isEmpty()) {
+            type = declaredType(module, parts.get(0), groovy[0], groovy[1]);
+            type = concretize(type, module, unit);
+            if (!usable(type)) {
+                type = resolveSimpleName(parts.get(0), module, unit);
+                staticAccess = usable(type);
+            }
+            for (int i = 1; i < parts.size() && usable(type); i++) {
+                type = concretize(memberType(type, parts.get(i)), module, unit);
+                staticAccess = false;
+            }
+        }
+        if (!usable(type)) {
+            int end = receiverEndOffset(line, caret);
+            int column = end <= 0 ? 1 : line.codePointCount(0, end);
+            type = concretize(expressionAt(module, groovy[0], column), module, unit);
+            staticAccess = false;
+        }
+        if (!usable(type)) {
+            return new Receiver(ClassHelper.OBJECT_TYPE, false);
+        }
+        return new Receiver(type, staticAccess);
+    }
+
+    /**
+     * Dotted names before the member-access dot, so {@code a.b.} is
+     * {@code [a, b]}. Parentheses and index expressions return an empty list
+     * and are typed from the AST instead.
+     */
+    private static List<String> receiverParts(final String line, final int caret) {
+        int end = receiverEndOffset(line, caret);
+        List<String> parts = new ArrayList<>();
+        int index = end;
+        while (index > 0) {
+            int start = skipIdentifierBack(line, index);
+            if (start == index) {
+                return parts;
+            }
+            parts.add(0, line.substring(start, index));
+            index = skipWhitespaceBack(line, start);
+            if (index <= 0 || line.charAt(index - 1) != '.') {
+                return parts;
+            }
+            index -= 1;
+            if (index > 0) {
+                char before = line.charAt(index - 1);
+                if (before == '?' || before == '*') {
+                    index -= 1;
+                }
+            }
+            index = skipWhitespaceBack(line, index);
+        }
+        return parts;
+    }
+
+    /** Exclusive UTF-16 end of the receiver expression, before the trigger dot. */
+    private static int receiverEndOffset(final String line, final int caret) {
+        int end = skipWhitespaceBack(line, caret);
+        end = skipIdentifierBack(line, end);
+        end = skipWhitespaceBack(line, end);
+        if (end <= 0 || line.charAt(end - 1) != '.') {
+            return end;
+        }
+        end -= 1;
+        if (end > 0) {
+            char before = line.charAt(end - 1);
+            if (before == '?' || before == '*') {
+                end -= 1;
+            }
+        }
+        return skipWhitespaceBack(line, end);
+    }
+
+    private static ClassNode expressionAt(final ModuleNode module, final int line, final int column) {
+        for (ASTNode node : AstQuery.containing(module, line, column)) {
+            ClassNode type = expressionType(node, module);
+            if (usable(type)) {
+                return type;
+            }
+        }
+        return null;
+    }
+
+    private static ClassNode expressionType(final ASTNode node, final ModuleNode module) {
+        if (node instanceof VariableExpression variable) {
+            return TypeInference.of(variable, module);
+        }
+        if (node instanceof ConstructorCallExpression constructor) {
+            return constructor.getType();
+        }
+        if (node instanceof MethodCallExpression call) {
+            return call.getType();
+        }
+        if (node instanceof ClassExpression classExpression) {
+            return classExpression.getType();
+        }
+        if (node instanceof ConstantExpression constant) {
+            return constant.getType();
+        }
+        if (node instanceof PropertyExpression property) {
+            ClassNode type = property.getType();
+            return usable(type) ? type : expressionType(property.getObjectExpression(), module);
+        }
+        return null;
+    }
+
+    private static ClassNode declaredType(final ModuleNode module, final String name, final int line, final int column) {
+        if (module == null || name == null || name.isEmpty()) {
+            return null;
+        }
+        ClassNode[] best = new ClassNode[1];
+        int[] rank = new int[] {Integer.MIN_VALUE};
+        int caretRank = line * 1_000_000 + Math.max(column, 0);
+        AstQuery.walk(module, (node, ctx) -> {
+            if (node instanceof DeclarationExpression declaration) {
+                offerDeclaration(declaration, module, name, caretRank, best, rank);
+            } else if (node instanceof BinaryExpression binary) {
+                offerAssignment(binary, name, caretRank, best, rank);
+            } else if (node instanceof Parameter parameter && name.equals(parameter.getName())) {
+                offer(parameter.getLineNumber(), parameter.getColumnNumber(), TypeInference.of(parameter),
+                        caretRank, best, rank);
+            } else if (node instanceof FieldNode field && name.equals(field.getName())) {
+                offer(field.getLineNumber(), field.getColumnNumber(), TypeInference.of(field), caretRank, best, rank);
+            }
+        });
+        return best[0];
+    }
+
+    private static void offerDeclaration(final DeclarationExpression declaration, final ModuleNode module,
+                                         final String name, final int caretRank,
+                                         final ClassNode[] best, final int[] rank) {
+        if (declaration.isMultipleAssignmentDeclaration()) {
+            return;
+        }
+        VariableExpression left = declaration.getVariableExpression();
+        if (left == null || !name.equals(left.getName())) {
+            return;
+        }
+        offer(left.getLineNumber(), left.getColumnNumber(), TypeInference.of(left, module), caretRank, best, rank);
+    }
+
+    private static void offerAssignment(final BinaryExpression binary, final String name, final int caretRank,
+                                        final ClassNode[] best, final int[] rank) {
+        if (binary instanceof DeclarationExpression || binary.getOperation() == null
+                || binary.getOperation().getType() != Types.ASSIGN
+                || !(binary.getLeftExpression() instanceof VariableExpression variable)
+                || !name.equals(variable.getName())) {
+            return;
+        }
+        ClassNode type = binary.getRightExpression() == null ? null : binary.getRightExpression().getType();
+        offer(variable.getLineNumber(), variable.getColumnNumber(), type, caretRank, best, rank);
+    }
+
+    private static void offer(final int nodeLine, final int nodeColumn, final ClassNode type,
+                              final int caretRank, final ClassNode[] best, final int[] rank) {
+        if (!usable(type) || nodeLine <= 0) {
+            return;
+        }
+        int score = nodeLine * 1_000_000 + Math.max(nodeColumn, 0);
+        if (score > caretRank || score < rank[0]) {
+            return;
+        }
+        rank[0] = score;
+        best[0] = type;
+    }
+
+    private static ClassNode memberType(final ClassNode owner, final String name) {
+        if (owner == null || name == null) {
+            return null;
+        }
+        ClassNode[] found = new ClassNode[1];
+        eachType(owner, current -> {
+            if (found[0] != null) {
+                return;
+            }
+            found[0] = fieldOrPropertyType(current, name);
+            if (found[0] == null) {
+                found[0] = zeroArgReturn(current, name);
+            }
+        });
+        return found[0];
+    }
+
+    private static ClassNode fieldOrPropertyType(final ClassNode owner, final String name) {
+        for (FieldNode field : owner.getFields()) {
+            if (name.equals(field.getName()) && field.isPublic()) {
+                return field.getType();
+            }
+        }
+        for (PropertyNode property : owner.getProperties()) {
+            if (name.equals(property.getName()) && !Modifier.isPrivate(property.getModifiers())) {
+                return property.getType();
+            }
+        }
+        return null;
+    }
+
+    private static ClassNode zeroArgReturn(final ClassNode owner, final String name) {
+        String suffix = name.isEmpty() ? name : Character.toUpperCase(name.charAt(0)) + name.substring(1);
+        for (MethodNode method : owner.getMethods()) {
+            if (method.getParameters().length != 0 || !method.isPublic() || method.getName() == null) {
+                continue;
+            }
+            String methodName = method.getName();
+            if (name.equals(methodName) || ("get" + suffix).equals(methodName) || ("is" + suffix).equals(methodName)) {
+                return method.getReturnType();
+            }
+        }
+        return null;
+    }
+
+    private static void addVisibleMembers(final List<CompletionItem> items, final ClassNode receiver,
+                                          final String prefix, final boolean staticAccess) {
+        ClassNode type = receiver == null ? ClassHelper.OBJECT_TYPE : receiver;
+        if (!staticAccess && ClassHelper.isPrimitiveType(type)) {
+            type = ClassHelper.getWrapper(type);
+        }
+        Set<String> seen = new LinkedHashSet<>();
+        if (type.isArray() && !staticAccess && prefixMatches(LENGTH, prefix)) {
+            CompletionItem length = new CompletionItem(LENGTH);
+            length.setKind(CompletionItemKind.Field);
+            length.setDetail("int");
+            length.setSortText("2" + LENGTH);
+            items.add(length);
+            seen.add(LENGTH);
+        }
+        eachType(type, current -> {
+            for (MethodNode method : current.getMethods()) {
+                if (method.isSynthetic() || !method.isPublic() || method.getName() == null
+                        || method.getName().startsWith("<") || method.getName().indexOf('$') >= 0) {
+                    continue;
+                }
+                addMethod(items, seen, method, prefix);
+            }
+            addFieldsAndProperties(items, seen, current, prefix);
+        });
+    }
+
+    private static void addFieldsAndProperties(final List<CompletionItem> items, final Set<String> seen,
+                                               final ClassNode owner, final String prefix) {
+        for (FieldNode field : owner.getFields()) {
+            if (field.isSynthetic() || !field.isPublic() || field.getName() == null
+                    || !prefixMatches(field.getName(), prefix) || !seen.add(field.getName())) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(field.getName());
+            item.setKind(CompletionItemKind.Field);
+            item.setDetail(typeName(field.getType()));
+            item.setSortText("2" + field.getName());
+            items.add(item);
+        }
+        for (PropertyNode property : owner.getProperties()) {
+            if (property.isSynthetic() || property.getName() == null || Modifier.isPrivate(property.getModifiers())
+                    || !prefixMatches(property.getName(), prefix) || !seen.add(property.getName())) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(property.getName());
+            item.setKind(CompletionItemKind.Property);
+            item.setSortText("2" + property.getName());
+            items.add(item);
+        }
+    }
+
+    private static void eachType(final ClassNode start, final Consumer<ClassNode> action) {
+        ArrayDeque<ClassNode> pending = new ArrayDeque<>();
+        pending.add(start);
+        Set<String> seen = new LinkedHashSet<>();
+        while (!pending.isEmpty()) {
+            ClassNode current = pending.removeFirst();
+            if (current != null) {
+                ClassNode redirected = current.redirect();
+                String key = redirected.getName();
+                if (key == null || seen.add(key)) {
+                    action.accept(redirected);
+                    enqueueParents(pending, redirected);
+                }
+            }
+        }
+    }
+
+    private static void enqueueParents(final ArrayDeque<ClassNode> pending, final ClassNode type) {
+        ClassNode superClass = type.getSuperClass();
+        if (superClass != null) {
+            pending.add(superClass);
+        }
+        ClassNode[] interfaces = type.getInterfaces();
+        if (interfaces != null) {
+            for (ClassNode iface : interfaces) {
+                if (iface != null) {
+                    pending.add(iface);
+                }
+            }
+        }
+    }
+
+    /**
+     * Compiles a parsable stand-in for a member access the real buffer rejected.
+     * The caret line becomes {@code <receiver>.lsp}, and a leading {@code def}
+     * on a bare name is dropped so {@code def a.} still sees the earlier {@code a}.
+     */
+    private static ModuleNode repairedModule(final TextDocument document, final Position position,
+                                             final ClassLoader parentLoader,
+                                             final CompilerConfiguration hostConfiguration) {
+        if (document == null || position == null) {
+            return null;
+        }
+        String repaired = repairMemberAccess(document.getText(), position.getLine(), position.getCharacter());
+        if (repaired == null || repaired.equals(document.getText())) {
+            return null;
+        }
+        CompilerConfiguration configuration = repairConfiguration(hostConfiguration);
+        ClassLoader parent = parentLoader == null ? CompletionService.class.getClassLoader() : parentLoader;
+        GroovyClassLoader loader = new GroovyClassLoader(parent, configuration);
+        try {
+            CompilationUnit unit = new CompilationUnit(configuration, null, loader);
+            unit.addSource("repair.groovy", repaired);
+            try {
+                unit.compile(Phases.SEMANTIC_ANALYSIS);
+            } catch (CompilationFailedException ignored) {
+                // the stand-in can still carry the declarations above the caret
+            }
+            ModuleNode found = null;
+            var sources = unit.iterator();
+            while (sources.hasNext()) {
+                SourceUnit source = sources.next();
+                if (source.getAST() != null) {
+                    found = source.getAST();
+                }
+            }
+            return found;
+        } finally {
+            try {
+                loader.close();
+            } catch (IOException ignored) {
+                // the module already holds the resolved JDK types
+            }
+        }
+    }
+
+    private static CompilerConfiguration repairConfiguration(final CompilerConfiguration host) {
+        CompilerConfiguration configuration = new CompilerConfiguration();
+        configuration.setTolerance(100);
+        if (host == null) {
+            return configuration;
+        }
+        if (host.getScriptBaseClass() != null) {
+            configuration.setScriptBaseClass(host.getScriptBaseClass());
+        }
+        List<CompilationCustomizer> customizers = host.getCompilationCustomizers();
+        if (customizers != null && !customizers.isEmpty()) {
+            configuration.addCompilationCustomizers(customizers.toArray(CompilationCustomizer[]::new));
+        }
+        return configuration;
+    }
+
+    private static String repairMemberAccess(final String text, final int line, final int character) {
+        if (text == null) {
+            return null;
+        }
+        int[] range = lineRange(text, Math.max(line, 0));
+        String sourceLine = text.substring(range[0], range[1]);
+        int caret = Math.min(Math.max(character, 0), sourceLine.length());
+        int end = receiverEndOffset(sourceLine, caret);
+        String receiver = sourceLine.substring(0, end).trim();
+        if (receiver.startsWith("def") && receiver.length() > 3 && Character.isWhitespace(receiver.charAt(3))) {
+            String rest = receiver.substring(3).trim();
+            if (isIdentifier(rest)) {
+                receiver = rest;
+            }
+        }
+        if (receiver.isEmpty()) {
+            return text;
+        }
+        return text.substring(0, range[0]) + receiver + ".lsp" + text.substring(range[1]);
+    }
+
+    private static boolean isIdentifier(final String text) {
+        if (text == null || text.isEmpty() || !Character.isJavaIdentifierStart(text.codePointAt(0))) {
+            return false;
+        }
+        int index = Character.charCount(text.codePointAt(0));
+        while (index < text.length()) {
+            int cp = text.codePointAt(index);
+            if (!Character.isJavaIdentifierPart(cp) && cp != '$') {
+                return false;
+            }
+            index += Character.charCount(cp);
+        }
+        return true;
+    }
+
+    private static int[] lineRange(final String text, final int line) {
+        int current = 0;
+        int start = 0;
+        int i = 0;
+        while (i < text.length()) {
+            char c = text.charAt(i);
+            if (c == '\n' || c == '\r') {
+                if (current == line) {
+                    return new int[] {start, i};
+                }
+                if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') {
+                    i += 1;
+                }
+                current += 1;
+                start = i + 1;
+            }
+            i++;
+        }
+        if (current == line) {
+            return new int[] {start, text.length()};
+        }
+        return new int[] {text.length(), text.length()};
+    }
+
+    private static ClassNode concretize(final ClassNode type, final ModuleNode module, final SourceUnit unit) {
+        if (type == null) {
+            return null;
+        }
+        ClassNode current = type.redirect();
+        if (current.isResolved() || ClassHelper.isPrimitiveType(current)) {
+            return current;
+        }
+        String name = current.getName();
+        if (name == null || name.isEmpty()) {
+            return current;
+        }
+        if (name.indexOf('.') >= 0) {
+            ClassNode loaded = loadClass(name, unit);
+            if (loaded != null) {
+                return loaded;
+            }
+            ClassNode source = sourceClass(module, name);
+            return source == null ? current : source;
+        }
+        ClassNode simple = resolveSimpleName(name, module, unit);
+        return simple == null ? current : simple;
+    }
+
+    private static ClassNode resolveSimpleName(final String name, final ModuleNode module, final SourceUnit unit) {
+        if (name == null || name.isEmpty()) {
+            return null;
+        }
+        if ("BigDecimal".equals(name)) {
+            return ClassHelper.BigDecimal_TYPE;
+        }
+        if ("BigInteger".equals(name)) {
+            return ClassHelper.BigInteger_TYPE;
+        }
+        ClassNode source = sourceClass(module, name);
+        if (source != null) {
+            return source;
+        }
+        ClassNode imported = importedSimpleName(name, module, unit);
+        if (imported != null) {
+            return imported;
+        }
+        return defaultImport(name, unit);
+    }
+
+    private static ClassNode importedSimpleName(final String name, final ModuleNode module, final SourceUnit unit) {
+        if (module == null) {
+            return null;
+        }
+        ClassNode imported = module.getImportType(name);
+        if (imported != null) {
+            ClassNode loaded = loadClass(imported.getName(), unit);
+            return loaded == null ? imported : loaded;
+        }
+        for (ImportNode star : module.getStarImports()) {
+            ClassNode loaded = loadClass(qualify(star.getPackageName(), name), unit);
+            if (loaded != null) {
+                return loaded;
+            }
+        }
+        return null;
+    }
+
+    private static ClassNode defaultImport(final String name, final SourceUnit unit) {
+        for (String prefix : ResolveVisitor.DEFAULT_IMPORTS) {
+            ClassNode loaded = loadClass(prefix + name, unit);
+            if (loaded != null) {
+                return loaded;
+            }
+        }
+        return null;
+    }
+
+    private static ClassNode sourceClass(final ModuleNode module, final String name) {
+        if (module == null || name == null) {
+            return null;
+        }
+        for (ClassNode classNode : module.getClasses()) {
+            if (classNode.isScript()) {
+                continue;
+            }
+            if (name.equals(classNode.getName()) || name.equals(classNode.getNameWithoutPackage())) {
+                return classNode;
+            }
+        }
+        return null;
+    }
+
+    private static String qualify(final String packageName, final String simple) {
+        if (packageName == null || packageName.isEmpty() || simple == null) {
+            return null;
+        }
+        return packageName.endsWith(".") ? packageName + simple : packageName + "." + simple;
+    }
+
+    private static ClassNode loadClass(final String name, final SourceUnit unit) {
+        if (name == null || name.isEmpty() || name.endsWith(".")) {
+            return null;
+        }
+        ClassLoader loader = unit == null ? null : unit.getClassLoader();
+        if (loader == null) {
+            loader = CompletionService.class.getClassLoader();
+        }
+        try {
+            return ClassHelper.make(Class.forName(name, false, loader));
+        } catch (ClassNotFoundException | LinkageError ignored) {
+            return null;
+        }
+    }
+
+    private static boolean usable(final ClassNode type) {
+        return SymbolIdentity.isResolvedType(type);
+    }
+
     private static CompletionItemKind typeKind(final TypeIndex.TypeHit hit) {
         if (hit.iface()) {
             return CompletionItemKind.Interface;
@@ -483,7 +1415,8 @@ public final class CompletionService {
             return;
         }
         CompletionItem item = new CompletionItem(method.getName());
-        item.setKind(method.isStatic() ? CompletionItemKind.Function : CompletionItemKind.Method);
+        // IntelliJ has no Function kind. Static methods are methods.
+        item.setKind(CompletionItemKind.Method);
         item.setDetail(signature(method));
         item.setSortText("2" + method.getName());
         item.setInsertText(methodSnippet(method));
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..cfa819356c 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,9 +56,11 @@ 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", "delimiter"
     );
-    public static final List<String> TOKEN_MODIFIERS = List.of("declaration", "static", "deprecated");
+    public static final List<String> TOKEN_MODIFIERS = List.of(
+            "declaration", "static", "deprecated", "slashy", "singleQuoted");
 
     private static final int TOKEN_NAMESPACE = 0;
     private static final int TOKEN_CLASS = 1;
@@ -71,9 +74,23 @@ 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 TOKEN_DELIMITER = 14;
     private static final int MOD_DECLARATION = 1;
     private static final int MOD_STATIC = 2;
     private static final int MOD_DEPRECATED = 4;
+    private static final int MOD_SLASHY = 8;
+    private static final int MOD_SINGLE = 16;
+    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 +144,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.
@@ -171,6 +217,23 @@ public final class SemanticTokensService {
         return new SemanticTokens(data);
     }
 
+    /**
+     * Tokens for an open buffer. A compiled overlay is used only when it
+     * describes this exact text, so a highlighter does not wait for a compile.
+     *
+     * @param text current document text
+     * @param overlay compiled document, or {@code null}
+     * @param encoding negotiated encoding
+     * @return protocol tokens, never {@code null}
+     */
+    public SemanticTokens semanticTokens(final String text, final CompiledDocument overlay,
+                                         final PositionEncoding encoding) {
+        if (overlay != null && text != null && text.equals(overlay.getText())) {
+            return encode(tokenSpans(overlay, encoding));
+        }
+        return encode(lexicalTokenSpans(text, encoding));
+    }
+
     public SemanticTokens semanticTokens(final CompiledDocument document, final PositionEncoding encoding) {
         return encode(tokenSpans(document, encoding));
     }
@@ -237,41 +300,105 @@ public final class SemanticTokensService {
         if (text == null || text.isEmpty()) {
             return;
         }
-        for (Token token : GroovySourceTokens.tokenize(text)) {
-            if (token != null) {
-                int type = lexicalType(token);
-                if (type >= 0) {
-                    addLexerSpans(text, tokens, encoding, range, token, type);
-                }
-            }
+        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) {
+        // GString parts do not repeat the opening delimiter. The modifier
+        // chosen for StringLiteral or GStringBegin applies until the next one.
+        int openString = 0;
+        for (Token token : lexerTokens) {
+            openString = recordLexicalToken(text, tokens, encoding, range, token, openString);
+        }
+    }
+
+    private static int recordLexicalToken(final String text, final List<TokenSpan> tokens,
+                                          final PositionEncoding encoding, final Range range,
+                                          final Token token, final int openString) {
+        if (token == null) {
+            return openString;
+        }
+        int type = lexicalType(token);
+        if (type < 0) {
+            return openString;
+        }
+        int currentOpen = openString;
+        if (type == TOKEN_STRING && opensString(token.getType())) {
+            currentOpen = stringModifier(token.getText());
         }
+        int modifiers = type == TOKEN_STRING ? currentOpen : 0;
+        addLexerSpans(text, tokens, encoding, range, token, type, modifiers);
+        return type == TOKEN_STRING ? currentOpen : openString;
     }
 
     private static int lexicalType(final Token token) {
         int type = token.getType();
-        if (type == GroovyLexer.StringLiteral || type == GroovyLexer.GStringBegin
-                || type == GroovyLexer.GStringPart || type == GroovyLexer.GStringEnd) {
+        if (isStringToken(type)) {
             return TOKEN_STRING;
         }
-        if (type == GroovyLexer.IntegerLiteral || type == GroovyLexer.FloatingPointLiteral) {
+        if (isNumberToken(type)) {
             return TOKEN_NUMBER;
         }
         if (type == GroovyLexer.NL) {
-            String body = token.getText();
-            if (body != null && (body.startsWith("//") || body.startsWith("/*"))) {
-                return TOKEN_COMMENT;
-            }
-            return -1;
+            return commentType(token.getText());
+        }
+        if (type == GroovyLexer.AT) {
+            return TOKEN_DECORATOR;
         }
-        if (type == GroovyLexer.NOT_IN || type == GroovyLexer.NOT_INSTANCEOF
-                || GroovyKeywords.isKeyword(token.getText())) {
+        if (isOperatorToken(type)) {
+            return TOKEN_OPERATOR;
+        }
+        if (isDelimiterToken(type)) {
+            return TOKEN_DELIMITER;
+        }
+        if (GroovyKeywords.isKeywordType(type)) {
             return TOKEN_KEYWORD;
         }
+        if (isIdentifierToken(type)) {
+            return TOKEN_VARIABLE;
+        }
+        return -1;
+    }
+
+    private static boolean isStringToken(final int type) {
+        return type == GroovyLexer.StringLiteral || type == GroovyLexer.GStringBegin
+                || type == GroovyLexer.GStringPart || type == GroovyLexer.GStringEnd;
+    }
+
+    private static boolean opensString(final int type) {
+        return type == GroovyLexer.StringLiteral || type == GroovyLexer.GStringBegin;
+    }
+
+    private static boolean isNumberToken(final int type) {
+        return type == GroovyLexer.IntegerLiteral || type == GroovyLexer.FloatingPointLiteral;
+    }
+
+    private static int commentType(final String body) {
+        if (body != null && (body.startsWith("//") || body.startsWith("/*"))) {
+            return TOKEN_COMMENT;
+        }
         return -1;
     }
 
+    private static boolean isOperatorToken(final int type) {
+        return type == GroovyLexer.COMMA || type == GroovyLexer.SEMI;
+    }
+
+    private static boolean isDelimiterToken(final int type) {
+        return type == GroovyLexer.LPAREN || type == GroovyLexer.RPAREN
+                || type == GroovyLexer.LBRACK || type == GroovyLexer.RBRACK
+                || type == GroovyLexer.LBRACE || type == GroovyLexer.RBRACE
+                || type == GroovyLexer.SAFE_INDEX;
+    }
+
+    private static boolean isIdentifierToken(final int type) {
+        return type == GroovyLexer.Identifier || type == GroovyLexer.CapitalizedIdentifier;
+    }
+
     private static void addLexerSpans(final String text, final List<TokenSpan> tokens, final PositionEncoding encoding,
-                                      final Range range, final Token token, final int type) {
+                                      final Range range, final Token token, final int type, final int modifiers) {
         String tokenText = token.getText();
         if (tokenText == null || tokenText.isEmpty()) {
             return;
@@ -280,18 +407,53 @@ public final class SemanticTokensService {
         int codePointColumn = token.getCharPositionInLine();
         String[] parts = tokenText.split("\n", -1);
         for (int i = 0; i < parts.length; i++) {
-            String part = parts[i];
-            boolean trailingEmpty = part.isEmpty() && i == parts.length - 1 && tokenText.endsWith("\n");
-            int lineI = line + i;
-            if (!trailingEmpty && lineInRange(range, lineI)) {
-                String row = Positions.lineText(text, lineI + 1);
-                int startCodePoints = i == 0 ? codePointColumn : 0;
-                int endCodePoints = startCodePoints + part.codePointCount(0, part.length());
-                int startChar = Positions.groovyColumnToCharacter(row, startCodePoints + 1, encoding);
-                int endChar = Positions.groovyColumnToCharacter(row, endCodePoints + 1, encoding);
-                tokens.add(new TokenSpan(lineI, startChar, Math.max(endChar - startChar, 1), type, 0));
-            }
+            addLexerLine(text, tokens, encoding, range, token, type, modifiers,
+                    tokenText, line, codePointColumn, parts, i);
+        }
+    }
+
+    private static void addLexerLine(final String text, final List<TokenSpan> tokens, final PositionEncoding encoding,
+                                     final Range range, final Token token, final int type, final int modifiers,
+                                     final String tokenText, final int line, final int codePointColumn,
+                                     final String[] parts, final int index) {
+        String part = parts[index];
+        boolean trailingEmpty = part.isEmpty() && index == parts.length - 1 && tokenText.endsWith("\n");
+        int lineI = line + index;
+        if (trailingEmpty || !lineInRange(range, lineI)) {
+            return;
+        }
+        String painted = withoutClosingDollar(token, part);
+        if (painted.isEmpty()) {
+            return;
+        }
+        String row = Positions.lineText(text, lineI + 1);
+        int startCodePoints = index == 0 ? codePointColumn : 0;
+        int endCodePoints = startCodePoints + painted.codePointCount(0, painted.length());
+        int startChar = Positions.groovyColumnToCharacter(row, startCodePoints + 1, encoding);
+        int endChar = Positions.groovyColumnToCharacter(row, endCodePoints + 1, encoding);
+        tokens.add(new TokenSpan(lineI, startChar, Math.max(endChar - startChar, 1), type, modifiers));
+    }
+
+    private static String withoutClosingDollar(final Token token, final String painted) {
+        int tokenType = token.getType();
+        if ((tokenType == GroovyLexer.GStringBegin || tokenType == GroovyLexer.GStringPart) && painted.endsWith("$")) {
+            return painted.substring(0, painted.length() - 1);
+        }
+        return painted;
+    }
+
+    /**
+     * Quote style of the token that opened the literal.
+     * A double-quoted literal, including a GString, is modifier {@code 0}.
+     */
+    private static int stringModifier(final String body) {
+        if (body != null && (body.startsWith("$/") || body.startsWith("/"))) {
+            return MOD_SLASHY;
+        }
+        if (body != null && body.startsWith("\"")) {
+            return 0;
         }
+        return MOD_SINGLE;
     }
 
     private static void dedupeSameSpan(final List<TokenSpan> tokens) {
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SyntaxView.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SyntaxView.java
new file mode 100644
index 0000000000..20477c2749
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SyntaxView.java
@@ -0,0 +1,136 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.lsp.internal.feature;
+
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.Token;
+import org.antlr.v4.runtime.Vocabulary;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.TerminalNode;
+import org.apache.groovy.lsp.internal.util.GroovySourceTokens;
+import org.apache.groovy.parser.antlr4.GroovyLangLexer;
+import org.apache.groovy.parser.antlr4.GroovyLangParser;
+import org.apache.groovy.parser.antlr4.GroovyLexer;
+import org.apache.groovy.parser.antlr4.GroovySyntaxError;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Lenient lexer tokens and a concrete syntax tree for an open buffer.
+ * Hosts receive maps, not parser objects.
+ */
+public final class SyntaxView {
+
+    private SyntaxView() {
+    }
+
+    /**
+     * @param text buffer text, possibly incomplete
+     * @return tokens in source order, never {@code null}
+     */
+    public static List<Map<String, Object>> tokens(final String text) {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        if (text == null || text.isEmpty()) {
+            return rows;
+        }
+        Vocabulary vocabulary = GroovyLexer.VOCABULARY;
+        for (Token token : GroovySourceTokens.tokenizeLenient(text)) {
+            if (token == null || token.getType() == Token.EOF) {
+                continue;
+            }
+            String name = vocabulary.getSymbolicName(token.getType());
+            if (name == null) {
+                name = vocabulary.getDisplayName(token.getType());
+            }
+            String body = token.getText() == null ? "" : token.getText();
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("name", name == null ? "" : name);
+            row.put("text", body);
+            row.put("line", Math.max(token.getLine() - 1, 0));
+            row.put("character", Math.max(token.getCharPositionInLine(), 0));
+            row.put("length", body.length());
+            rows.add(row);
+        }
+        return rows;
+    }
+
+    /**
+     * @param text buffer text, possibly incomplete
+     * @return a tree of {@code name}, {@code text} and {@code children}
+     */
+    public static Map<String, Object> cst(final String text) {
+        String body = text == null ? "" : text;
+        try {
+            GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString(body));
+            lexer.removeErrorListeners();
+            lexer.setErrorIgnored(true);
+            GroovyLangParser parser = new GroovyLangParser(new CommonTokenStream(lexer));
+            parser.removeErrorListeners();
+            return node(parser.compilationUnit(), parser);
+        } catch (GroovySyntaxError | RuntimeException error) {
+            return leaf("error", error.getMessage() == null ? "" : error.getMessage());
+        }
+    }
+
+    private static Map<String, Object> node(final ParseTree tree, final GroovyLangParser parser) {
+        if (tree instanceof TerminalNode terminal) {
+            return terminalNode(terminal, parser);
+        }
+        ParserRuleContext rule = (ParserRuleContext) tree;
+        String name = parser.getRuleNames()[rule.getRuleIndex()];
+        List<Map<String, Object>> children = new ArrayList<>();
+        for (int i = 0; i < tree.getChildCount(); i++) {
+            children.add(node(tree.getChild(i), parser));
+        }
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("name", name);
+        row.put("text", "");
+        row.put("children", children);
+        return row;
+    }
+
+    private static Map<String, Object> terminalNode(final TerminalNode terminal, final GroovyLangParser parser) {
+        Token symbol = terminal.getSymbol();
+        if (symbol == null) {
+            return leaf("", "");
+        }
+        if (symbol.getType() == Token.EOF) {
+            return leaf("EOF", "");
+        }
+        String name = parser.getVocabulary().getSymbolicName(symbol.getType());
+        if (name == null) {
+            name = parser.getVocabulary().getDisplayName(symbol.getType());
+        }
+        String text = symbol.getText() == null ? "" : symbol.getText();
+        return leaf(name == null ? "" : name, text);
+    }
+
+    private static Map<String, Object> leaf(final String name, final String text) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("name", name);
+        row.put("text", text);
+        row.put("children", List.of());
+        return row;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
index 63c2d0a305..c930328b81 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
@@ -141,9 +141,11 @@ public final class GroovyTextDocumentService implements TextDocumentService {
 
     @Override
     public CompletableFuture<Either<List<CompletionItem>, CompletionList>> completion(final CompletionParams params) {
+        context.recompile();
         return withDocument(params.getTextDocument().getUri(), Either.forRight(new CompletionList(false, List.of())),
                 document -> Either.forRight(features.completions().complete(document, context.getSnapshot(),
-                        params.getPosition(), context.getPositionEncoding())));
+                        params.getPosition(), context.getPositionEncoding(),
+                        context.getParentLoader(), context.getHostConfiguration())));
     }
 
     @Override
@@ -153,6 +155,7 @@ public final class GroovyTextDocumentService implements TextDocumentService {
 
     @Override
... 1903 lines suppressed ...


(groovy) 02/02: GROOVY-12448: keep language-host compiles off the core compiler

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 3911ce3ca171f847333c7873bc4d1cac0e7e7636
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Oct 24 02:26:27 2026 +0900

    GROOVY-12448: keep language-host compiles off the core compiler
    
    Parameter-name lookup moves into groovy-lsp and caches classes weakly,
    so Java8 no longer pins loaders or changes reflective parameter names.
    Repair compiles use CompilerSettings, and @ASTTest is toggled per thread.
    Console highlighting restyles changed spans without growing the undo stack.
    Both hosts can turn the language engine off, and the completion chord is configurable.
---
 COMPATIBILITY.md                                   |  16 +++
 .../groovy/transform/ASTTestTransformation.groovy  |  37 +++++-
 .../control/customizers/ImportCustomizer.java      |  94 +++++++++++++
 .../org/codehaus/groovy/vmplugin/v8/Java8.java     |   8 --
 .../main/groovy/groovy/console/ui/Console.groovy   |  59 +++++++-
 .../groovy/groovy/console/ui/ConsoleActions.groovy |  10 ++
 .../groovy/console/ui/ConsoleTextEditor.java       |  13 ++
 .../console/ui/language/ConsoleLanguageHost.groovy | 114 +++++++++++++---
 .../console/ui/text/SmartDocumentFilter.java       | 148 ++++++++++++++++++---
 .../groovy/console/ui/view/BasicMenuBar.groovy     |   2 +
 .../groovy/console/ui/view/MacOSXMenuBar.groovy    |   2 +
 .../src/spec/doc/groovy-console.adoc               |   9 +-
 .../ui/language/ConsoleLanguageHostTest.groovy     |  33 +++++
 .../console/ui/text/SmartDocumentFilterTest.groovy |  18 +++
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |  13 +-
 .../apache/groovy/groovysh/jline/GroovyEngine.java |   7 +
 .../language/GroovyshLanguageSession.groovy        |  70 ++++++++--
 .../groovy-groovysh/src/spec/doc/groovysh.adoc     |   9 ++
 .../apache/groovy/lsp/GroovyLanguageClient.groovy  | 109 +++++++++------
 .../lsp/internal/compile/CompilerSettings.java     |  52 +++++++-
 .../lsp/internal/compile/GroovyCompiler.java       |  91 ++++++++-----
 .../lsp/internal/engine/GroovyLanguageEngine.java  |   3 +-
 .../lsp/internal/feature/CompletionService.java    |  79 ++++-------
 .../protocol/GroovyTextDocumentService.java        |   2 +-
 .../internal/protocol/GroovyWorkspaceService.java  |  12 ++
 .../internal/util}/ReflectionParameterNames.java   |  39 ++++--
 .../groovy/lsp/GroovyLanguageClientTest.groovy     |  18 +++
 .../lsp/internal/compile/CompilerSafetyTest.groovy |  10 +-
 .../internal/feature/CompletionServiceTest.groovy  |  23 +++-
 .../util}/ReflectionParameterNamesTest.groovy      |  26 ++--
 .../lsp/internal/util/ParameterNameLeakProbe.java  |  51 +++++++
 31 files changed, 961 insertions(+), 216 deletions(-)

diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index f9397eed9f..e8b90911b8 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -548,6 +548,22 @@ so `japicmp` sees nothing — this is a behavioural change only.
 Groovy 6 is the major version for the change. On 3.0.x/4.0.x/5.0.x/5.1.x the
 coercion still initializes the class.
 
+### Groovy 6 — Groovy Console smart highlighter and the language host
+
+`SmartDocumentFilter.getLatestTokenList()` now returns delimiter spans
+(`offset`, `length`, `text`) from semantic tokens, not ANTLR `Token`s.
+`HIGHLIGHTED_TOKEN_TYPE_LIST` is no longer published. Both types have been
+public since 3.0, so this is a source and binary break for embedders that
+read the lexer token list. Brace matching uses the new spans.
+`GroovyLanguageClient` is `@Internal`. `GroovyLanguageEngine` lives in an
+internal package; hosts talk to `GroovyLanguageClient`.
+
+`groovy-console` and `groovy-groovysh` depend on `groovy-lsp` at runtime,
+so a distribution that includes those modules also includes LSP4J and Gson.
+`groovy-groovysh` no longer puts `groovy-console` on its compile classpath.
+`/console` from the shell needs `groovy-console` available separately and
+fails quietly when it is absent.
+
 ### Groovy 7 — `SignatureCodec` version 1 no longer round-trips a union node
 
 `SignatureCodecVersion1` used to encode a checker-only `UnionTypeClassNode` by
diff --git a/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy b/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy
index 347e2ab31a..2e8706139b 100644
--- a/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy
+++ b/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy
@@ -59,6 +59,41 @@ class ASTTestTransformation implements ASTTransformation, CompilationUnitAware {
      */
     public static final String ENABLE_PROPERTY = 'groovy.asttest.enable'
 
+    /**
+     * Per-thread override of {@value #ENABLE_PROPERTY}. In-process tooling
+     * sets this for the compile it is running so the JVM property, which a
+     * host compile on another thread still reads, stays unchanged.
+     */
+    private static final ThreadLocal<Boolean> THREAD_ENABLED = new ThreadLocal<>()
+
+    /**
+     * @return the override on this thread, or {@code null} when the system property applies
+     */
+    static Boolean currentThreadEnabled() {
+        THREAD_ENABLED.get()
+    }
+
+    /**
+     * Sets the per-thread override. {@code null} clears it.
+     *
+     * @param enabled {@code true} to run {@code @ASTTest}, {@code false} to skip it, {@code null} to clear
+     */
+    static void setThreadEnabled(Boolean enabled) {
+        if (enabled == null) {
+            THREAD_ENABLED.remove()
+        } else {
+            THREAD_ENABLED.set(enabled)
+        }
+    }
+
+    private static boolean enabled() {
+        Boolean override = THREAD_ENABLED.get()
+        if (override != null) {
+            return override.booleanValue()
+        }
+        Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, 'true'))
+    }
+
     /**
      * Compilation unit that owns the current transformation.
      */
@@ -74,7 +109,7 @@ class ASTTestTransformation implements ASTTransformation, CompilationUnitAware {
      */
     @Override
     void visit(final ASTNode[] nodes, final SourceUnit source) {
-        if (!Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, 'true'))) return
+        if (!enabled()) return
 
         AnnotationNode annotationNode = nodes[0]
 
diff --git a/src/main/java/org/codehaus/groovy/control/customizers/ImportCustomizer.java b/src/main/java/org/codehaus/groovy/control/customizers/ImportCustomizer.java
index f46fcc0f70..c03f639d65 100644
--- a/src/main/java/org/codehaus/groovy/control/customizers/ImportCustomizer.java
+++ b/src/main/java/org/codehaus/groovy/control/customizers/ImportCustomizer.java
@@ -28,10 +28,13 @@ import org.codehaus.groovy.control.ResolveVisitor;
 import org.codehaus.groovy.control.SourceUnit;
 
 import java.lang.module.ModuleFinder;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -197,6 +200,97 @@ public class ImportCustomizer extends CompilationCustomizer {
         return this;
     }
 
+    /**
+     * Regular imports, as fully qualified class names. An import whose alias
+     * is not the simple class name is omitted here and reported by {@link #getAliases()}.
+     *
+     * @return class names, never {@code null}
+     * @since 6.0.0
+     */
+    public List<String> getImports() {
+        List<String> names = new ArrayList<>();
+        for (Import imp : imports) {
+            if (imp.type == ImportType.regular && imp.classNode != null
+                    && (imp.alias == null || imp.alias.equals(imp.classNode.getNameWithoutPackage()))) {
+                names.add(imp.classNode.getName());
+            }
+        }
+        return names;
+    }
+
+    /**
+     * Star imports. Each entry is the package name as it was stored, with a trailing dot.
+     *
+     * @return package names, never {@code null}
+     * @since 6.0.0
+     */
+    public List<String> getStarImports() {
+        List<String> names = new ArrayList<>();
+        for (Import imp : imports) {
+            if (imp.type == ImportType.star && imp.star != null) {
+                names.add(imp.star);
+            }
+        }
+        return names;
+    }
+
+    /**
+     * Static star imports, as fully qualified class names.
+     *
+     * @return class names, never {@code null}
+     * @since 6.0.0
+     */
+    public List<String> getStaticStarImports() {
+        List<String> names = new ArrayList<>();
+        for (Import imp : imports) {
+            if (imp.type == ImportType.staticStar && imp.classNode != null) {
+                names.add(imp.classNode.getName());
+            }
+        }
+        return names;
+    }
+
+    /**
+     * Aliases for regular imports. The key is the alias and the value is the class name.
+     * An import whose alias is just the simple class name is not included.
+     *
+     * @return aliases, never {@code null}
+     * @since 6.0.0
+     */
+    public Map<String, String> getAliases() {
+        Map<String, String> aliases = new LinkedHashMap<>();
+        for (Import imp : imports) {
+            if (imp.type == ImportType.regular && imp.alias != null && imp.classNode != null
+                    && !imp.alias.equals(imp.classNode.getNameWithoutPackage())) {
+                aliases.put(imp.alias, imp.classNode.getName());
+            }
+        }
+        return aliases;
+    }
+
+    /**
+     * Static imports. Each entry is {@code alias=ClassName.member}. When the
+     * alias is the member name, the alias is omitted and the entry is {@code ClassName.member}.
+     *
+     * @return static imports, never {@code null}
+     * @since 6.0.0
+     */
+    public List<String> getStaticImports() {
+        List<String> names = new ArrayList<>();
+        for (Import imp : imports) {
+            if (imp.type != ImportType.staticImport || imp.classNode == null || imp.field == null) {
+                continue;
+            }
+            String member = imp.classNode.getName() + '.' + imp.field;
+            if (imp.alias != null && !imp.alias.equals(imp.field)) {
+                names.add(imp.alias + '=' + member);
+            } else {
+                names.add(member);
+            }
+        }
+        return names;
+    }
+
     //
 
     private void addImport(final String className) {
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java
index 05adb969a8..3f5eedfa8d 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Java8.java
@@ -39,7 +39,6 @@ import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.ListExpression;
 import org.codehaus.groovy.ast.expr.PropertyExpression;
 import org.codehaus.groovy.ast.stmt.ReturnStatement;
-import org.codehaus.groovy.reflection.ReflectionParameterNames;
 import org.codehaus.groovy.reflection.ReflectionUtils;
 import org.codehaus.groovy.runtime.MetaClassHelper;
 import org.codehaus.groovy.vmplugin.VMPlugin;
@@ -739,13 +738,6 @@ public class Java8 implements VMPlugin {
      */
     protected void fillParameterNames(final String[] names, final Member member) {
         try {
-            if (member instanceof Executable executable) {
-                String[] resolved = ReflectionParameterNames.names(executable);
-                for (int i = 0, n = Math.min(names.length, resolved.length); i < n; i += 1) {
-                    names[i] = resolved[i] != null ? resolved[i] : "arg" + i;
-                }
-                return;
-            }
             java.lang.reflect.Parameter[] parameters = ((Executable) member).getParameters();
             for (int i = 0, n = names.length; i < n; i += 1) {
                 names[i] = parameters[i].getName();
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 d17edc2ed5..f53638e07e 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
@@ -130,6 +130,10 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
 
     /** Attribute key for the {@code SourcePosition} stored on a source link. */
     private static final String LINK_POSITION = 'groovy.console.link.position'
+    /** Preference key for the completion key stroke. Default is {@code alt SLASH}. */
+    static final String COMPLETION_SHORTCUT = 'completionShortcut'
+    /** Default completion key stroke, understood by {@link KeyStroke#getKeyStroke(String)}. */
+    static final String DEFAULT_COMPLETION_SHORTCUT = 'alt SLASH'
     private static final boolean DEBUG_GRAPE = Boolean.getBoolean('groovy.grape.debug')
 
     /** User preferences backing console settings. */
@@ -146,6 +150,13 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
     static boolean smartHighlighter = prefs.getBoolean('smartHighlighter',
             Boolean.valueOf(SystemUtil.getSystemPropertySafe('groovy.console.enable.smart.highlighter', 'true')))
 
+    /**
+     * Whether the editor attaches the in-process language host.
+     * When this is off, diagnostics, completion and the background compile
+     * stay off and the console behaves as it did before that host existed.
+     */
+    static boolean compilerAssistance = prefs.getBoolean('compilerAssistance', true)
+
     /** Whether full stack traces are shown for execution failures. */
     boolean fullStackTraces = prefs.getBoolean('fullStackTraces',
             Boolean.valueOf(System.getProperty('groovy.full.stacktrace', 'false')))
@@ -603,11 +614,18 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
 
     /**
      * 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.
+     * the console compiler configuration. Skipped when
+     * {@link #compilerAssistance} is off, and any host already attached
+     * is closed.
      */
     @CompileStatic
     private void installLanguageHost() {
+        if (!compilerAssistance) {
+            languageHost?.close()
+            languageHost = null
+            return
+        }
+        languageHost?.close()
         languageHost = ConsoleLanguageHost.attach(inputArea, shell.classLoader, config)
     }
 
@@ -1096,6 +1114,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             if (exit) {
                 languageHost?.close()
                 languageHost = null
+                inputEditor?.closeHighlighter()
                 if (frame instanceof Window) {
                     frame.hide()
                     frame.dispose()
@@ -1175,8 +1194,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
                     listeners.each { inputArea.document.addDocumentListener(it) }
                     setDirty(false)
                     inputArea.caretPosition = 0
-                    languageHost?.refresh()
                 }
+                languageHost?.refresh()
             } finally {
                 swing.edt { inputArea.editable = true }
                 // GROOVY-3684: focus away and then back to inputArea ensures caret blinks
@@ -2302,6 +2321,40 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         prefs.putBoolean('smartHighlighter', evt.source.selected)
     }
 
+    /**
+     * Turns the in-process language host on or off. Off skips diagnostics,
+     * completion and the background compile.
+     */
+    void compilerAssistance(EventObject evt = null) {
+        boolean enabled = evt?.source?.selected as boolean
+        compilerAssistance = enabled
+        prefs.putBoolean('compilerAssistance', enabled)
+        if (inputArea != null) {
+            installLanguageHost()
+        }
+    }
+
+    /**
+     * Stores a completion key stroke such as {@code alt SLASH} or {@code ctrl SPACE}.
+     * An empty or unrecognised value is declined.
+     */
+    void completionShortcut(EventObject evt = null) {
+        String current = prefs.get(COMPLETION_SHORTCUT, DEFAULT_COMPLETION_SHORTCUT)
+        Component parent = frame instanceof Component ? (Component) frame : null
+        def entered = JOptionPane.showInputDialog(parent,
+                'Key stroke, for example "alt SLASH" or "ctrl SPACE".', current)
+        if (entered == null) {
+            return
+        }
+        String spec = entered.toString().trim()
+        if (KeyStroke.getKeyStroke(spec) == null) {
+            JOptionPane.showMessageDialog(parent, "Could not understand '${spec}'.",
+                    'Completion Shortcut', JOptionPane.WARNING_MESSAGE)
+            return
+        }
+        prefs.put(COMPLETION_SHORTCUT, spec)
+    }
+
     /** Updates the main window title to reflect the current script and dirty state. */
     void updateTitle() {
         if (frame.title) {
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleActions.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleActions.groovy
index 702105ccf3..46c68b02ee 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleActions.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleActions.groovy
@@ -410,6 +410,16 @@ smartHighlighterAction = action(
         closure: controller.&smartHighlighter
 )
 
+compilerAssistanceAction = action(
+        name: 'Compiler Assistance',
+        closure: controller.&compilerAssistance
+)
+
+completionShortcutAction = action(
+        name: 'Completion Shortcut...',
+        closure: controller.&completionShortcut
+)
+
 aboutAction = action(
         name: 'About',
         closure: controller.&showAbout,
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleTextEditor.java b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleTextEditor.java
index 4a9ecccb42..6c73e08c66 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleTextEditor.java
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsoleTextEditor.java
@@ -466,6 +466,7 @@ public class ConsoleTextEditor extends JScrollPane {
      */
     public void enableHighLighter(Class<? extends DocumentFilter> clazz) {
         DefaultStyledDocument doc = (DefaultStyledDocument) textEditor.getDocument();
+        closeHighlighter();
 
         try {
             DocumentFilter documentFilter = clazz.getConstructor(doc.getClass()).newInstance(doc);
@@ -484,6 +485,18 @@ public class ConsoleTextEditor extends JScrollPane {
         textEditor.addCaretListener(new MatchingHighlighter(smartDocumentFilter, textEditor));
     }
 
+    /**
+     * Closes the smart highlighter's language client, if one is installed.
+     */
+    public void closeHighlighter() {
+        if (textEditor == null || !(textEditor.getDocument() instanceof DefaultStyledDocument doc)) {
+            return;
+        }
+        if (doc.getDocumentFilter() instanceof SmartDocumentFilter smartDocumentFilter) {
+            smartDocumentFilter.close();
+        }
+    }
+
     private void disableMatchingHighlighter() {
         for (CaretListener cl : textEditor.getCaretListeners()) {
             if (cl instanceof MatchingHighlighter) {
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
index 4facb2b920..12761ba5b2 100644
--- 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
@@ -18,6 +18,7 @@
  */
 package groovy.console.ui.language
 
+import groovy.console.ui.Console
 import groovy.transform.CompileStatic
 import groovy.transform.Internal
 import groovy.transform.PackageScope
@@ -78,7 +79,9 @@ import java.util.concurrent.RejectedExecutionException
 import java.util.concurrent.ScheduledExecutorService
 import java.util.concurrent.ScheduledFuture
 import java.util.concurrent.TimeUnit
+import java.lang.reflect.InvocationTargetException
 import java.util.concurrent.atomic.AtomicInteger
+import java.util.prefs.Preferences
 
 /**
  * In-process language host for Groovy Console. Speaks to
@@ -200,7 +203,8 @@ final class ConsoleLanguageHost implements AutoCloseable {
 
             @Override
             void keyReleased(KeyEvent e) {
-                if (e.keyCode == KeyEvent.VK_SLASH) {
+                KeyStroke stroke = completionStroke()
+                if (stroke != null && e.keyCode == stroke.keyCode) {
                     swallowNextTyped = false
                 }
             }
@@ -306,18 +310,54 @@ final class ConsoleLanguageHost implements AutoCloseable {
     }
 
     /**
-     * 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.
+     * Compiles the current editor text and paints diagnostic highlights.
+     * The compile runs on the caller. File → Open calls this from its
+     * worker, then the paint is applied on the EDT before this method
+     * returns. Typing uses {@link #schedule()} instead.
      */
     void refresh() {
         if (closed) {
             return
         }
-        generation.incrementAndGet()
+        int gen = generation.incrementAndGet()
         pendingHighlight?.cancel(false)
-        String text = textOf(input)
-        paint(text, compileAndDiagnostics(text))
+        String text = readEditorText()
+        List<Hit> hits = compileAndDiagnostics(text)
+        if (closed || gen != generation.get()) {
+            return
+        }
+        Runnable draw = {
+            if (!closed && gen == generation.get()) {
+                paint(text, hits)
+            }
+        }
+        if (SwingUtilities.isEventDispatchThread()) {
+            draw.run()
+            return
+        }
+        try {
+            SwingUtilities.invokeAndWait(draw)
+        } catch (InterruptedException ignored) {
+            Thread.currentThread().interrupt()
+        } catch (InvocationTargetException ignored) {
+        }
+    }
+
+    /** Document text, read on the EDT. Swing documents are not thread-safe. */
+    private String readEditorText() {
+        if (SwingUtilities.isEventDispatchThread()) {
+            return textOf(input)
+        }
+        String[] holder = new String[1]
+        try {
+            SwingUtilities.invokeAndWait { holder[0] = textOf(input) }
+        } catch (InterruptedException ignored) {
+            Thread.currentThread().interrupt()
+            return ''
+        } catch (InvocationTargetException ignored) {
+            return ''
+        }
+        return holder[0] == null ? '' : holder[0]
     }
 
     /**
@@ -382,10 +422,12 @@ final class ConsoleLanguageHost implements AutoCloseable {
     private void publishSettings() {
         publishParent()
         List<String> classpath = GroovyLanguageClient.classpathOf(parent)
+        language.hostConfiguration(configuration)
+        // The configuration object already carries imports and AST customizers.
+        // Repeating them as strings would add a second ImportCustomizer and
+        // bind an aliased type under its simple name as well.
         language.settings([
                 classpath: classpath,
-                imports: GroovyLanguageClient.importsOf(configuration),
-                starImports: GroovyLanguageClient.starImportsOf(configuration),
                 scriptBaseClass: configuration?.scriptBaseClass ?: ''
         ])
         publishedClasspath = classpath
@@ -1009,17 +1051,55 @@ final class ConsoleLanguageHost implements AutoCloseable {
     }
 
     /**
-     * Alt+/. Ctrl+Space is left free: it switches the input method on
-     * Chinese desktops and on macOS. Ctrl+/ remains comment/uncomment.
+     * The completion chord from the console preference
+     * {@code completionShortcut}. The default is Alt+/. Ctrl+Space is not
+     * the default: it switches the input method on Chinese desktops and on
+     * macOS. A layout that cannot type {@code /} without Shift can store
+     * another stroke, for example {@code ctrl SPACE}.
      */
-    private static boolean isCompletionChord(KeyEvent e) {
-        if (e.keyCode != KeyEvent.VK_SLASH) {
+    @PackageScope
+    static boolean isCompletionChord(KeyEvent e) {
+        if (e == null) {
             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
+        KeyStroke stroke = completionStroke()
+        if (stroke == null) {
+            return false
+        }
+        int expected = downMasks(stroke.modifiers)
+        int actual = e.modifiersEx & DOWN_MASKS
+        return e.keyCode == stroke.keyCode && actual == expected
+    }
+
+    @PackageScope
+    static KeyStroke completionStroke() {
+        Preferences preferences = (Preferences) Console.prefs
+        String spec = preferences.get(Console.COMPLETION_SHORTCUT, Console.DEFAULT_COMPLETION_SHORTCUT)
+        KeyStroke stroke = KeyStroke.getKeyStroke(spec)
+        stroke == null ? KeyStroke.getKeyStroke(Console.DEFAULT_COMPLETION_SHORTCUT) : stroke
+    }
+
+    private static final int DOWN_MASKS = InputEvent.ALT_DOWN_MASK | InputEvent.CTRL_DOWN_MASK
+            | InputEvent.META_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK | InputEvent.ALT_GRAPH_DOWN_MASK
+
+    private static int downMasks(int modifiers) {
+        int down = modifiers & DOWN_MASKS
+        if ((modifiers & InputEvent.ALT_MASK) != 0) {
+            down |= InputEvent.ALT_DOWN_MASK
+        }
+        if ((modifiers & InputEvent.CTRL_MASK) != 0) {
+            down |= InputEvent.CTRL_DOWN_MASK
+        }
+        if ((modifiers & InputEvent.META_MASK) != 0) {
+            down |= InputEvent.META_DOWN_MASK
+        }
+        if ((modifiers & InputEvent.SHIFT_MASK) != 0) {
+            down |= InputEvent.SHIFT_DOWN_MASK
+        }
+        if ((modifiers & InputEvent.ALT_GRAPH_MASK) != 0) {
+            down |= InputEvent.ALT_GRAPH_DOWN_MASK
+        }
+        return down
     }
 
     @PackageScope
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 65d2fc4978..ee9802581c 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
@@ -22,6 +22,7 @@ import groovy.console.ui.ThemeManager;
 import groovy.lang.Tuple2;
 import org.apache.groovy.lsp.GroovyLanguageClient;
 
+import javax.swing.event.UndoableEditListener;
 import javax.swing.text.AttributeSet;
 import javax.swing.text.BadLocationException;
 import javax.swing.text.DefaultStyledDocument;
@@ -47,7 +48,10 @@ import java.util.concurrent.atomic.AtomicReference;
  * begin or part is left in the default style. Structural brackets,
  * including {@code ?[}, are {@code delimiter} tokens on that same
  * response. {@link #getLatestTokenList()} exposes them for brace
- * matching. The filter does not lex the buffer itself.
+ * matching. The filter does not lex the buffer itself. A keystroke
+ * walks token offsets once and restyles only the spans whose style
+ * changed. Those attribute edits are not recorded on the undo stack.
+ * {@link #close()} releases the in-process language client.
  *
  * @since 3.0.0
  */
@@ -142,10 +146,17 @@ public class SmartDocumentFilter extends DocumentFilter {
         String text = styledDocument.getText(0, styledDocument.getLength());
         List<Integer> data = language.semanticTokens(text);
         int[] dirty = dirtyRange(text);
+        UndoableEditListener[] listeners = styledDocument.getUndoableEditListeners();
+        for (UndoableEditListener listener : listeners) {
+            styledDocument.removeUndoableEditListener(listener);
+        }
         try {
             this.latestTokenList.set(List.copyOf(paintSemantic(text, data, dirty[0], dirty[1])));
             this.latest = true;
         } finally {
+            for (UndoableEditListener listener : listeners) {
+                styledDocument.addUndoableEditListener(listener);
+            }
             this.setRenderRange(null);
         }
     }
@@ -171,31 +182,109 @@ public class SmartDocumentFilter extends DocumentFilter {
 
     private List<Delimiter> paintSemantic(String text, List<Integer> data, int dirtyStart, int dirtyStop) {
         List<Delimiter> delimiters = new ArrayList<>();
-        if (!text.isEmpty() && dirtyStart <= dirtyStop) {
-            styledDocument.setCharacterAttributes(dirtyStart, dirtyStop - dirtyStart + 1, defaultStyle, true);
+        List<Painted> next = new ArrayList<>();
+        if (data == null) {
+            data = List.of();
         }
-        int line = 0;
         int character = 0;
+        int lineStart = 0;
+        boolean full = text.isEmpty() || (dirtyStart <= 0 && dirtyStop >= text.length() - 1);
         for (int i = 0; i + 4 < data.size(); i += 5) {
             int deltaLine = data.get(i);
-            line += deltaLine;
-            character = deltaLine == 0 ? character + data.get(i + 1) : data.get(i + 1);
-            int length = data.get(i + 2);
-            int start = offsetOf(text, line, character);
-            int end = Math.min(start + Math.max(length, 0), text.length());
-            if (end > start) {
-                int type = data.get(i + 3);
-                if (type == TYPE_DELIMITER) {
-                    delimiters.add(new Delimiter(start, end - start, text.substring(start, end)));
-                } else if (end > dirtyStart && start <= dirtyStop) {
-                    Style style = styleFor(type, data.get(i + 4));
-                    styledDocument.setCharacterAttributes(start, end - start, style, true);
+            int deltaChar = data.get(i + 1);
+            if (deltaLine == 0) {
+                character += deltaChar;
+            } else {
+                lineStart = advanceLines(text, lineStart, deltaLine);
+                character = deltaChar;
+            }
+            int length = Math.max(data.get(i + 2), 0);
+            int start = Math.min(lineStart + Math.max(character, 0), text.length());
+            int end = Math.min(start + length, text.length());
+            if (end <= start) {
+                continue;
+            }
+            int type = data.get(i + 3);
+            if (type == TYPE_DELIMITER) {
+                delimiters.add(new Delimiter(start, end - start, text.substring(start, end)));
+            } else if (full || (end > dirtyStart && start <= dirtyStop)) {
+                next.add(new Painted(start, end, styleFor(type, data.get(i + 4))));
+            }
+        }
+        if (full) {
+            // An empty record means this is the first paint, or a theme
+            // switch asked for a full reset. Clear leftover colours first;
+            // applyStyleDiff can only retire spans it still remembers.
+            if (painted.isEmpty() && !text.isEmpty()) {
+                setAttributes(0, text.length(), defaultStyle);
+            }
+            applyStyleDiff(next);
+        } else if (dirtyStart <= dirtyStop) {
+            setAttributes(dirtyStart, dirtyStop - dirtyStart + 1, defaultStyle);
+            for (Painted span : next) {
+                setAttributes(span.start, span.end - span.start, span.style);
+            }
+            List<Painted> kept = new ArrayList<>();
+            for (Painted old : painted) {
+                if (old.end <= dirtyStart || old.start > dirtyStop) {
+                    kept.add(old);
                 }
             }
+            kept.addAll(next);
+            painted = kept;
         }
         return delimiters;
     }
 
+    private static int advanceLines(String text, int from, int lines) {
+        int index = Math.max(from, 0);
+        int seen = 0;
+        while (index < text.length() && seen < lines) {
+            char c = text.charAt(index);
+            if (c == '\n') {
+                seen++;
+            } else if (c == '\r') {
+                if (index + 1 < text.length() && text.charAt(index + 1) == '\n') {
+                    index++;
+                }
+                seen++;
+            }
+            index++;
+        }
+        return index;
+    }
+
+    private void applyStyleDiff(List<Painted> next) {
+        for (Painted old : painted) {
+            if (!containsSame(next, old)) {
+                setAttributes(old.start, old.end - old.start, defaultStyle);
+            }
+        }
+        for (Painted span : next) {
+            if (!containsSame(painted, span)) {
+                setAttributes(span.start, span.end - span.start, span.style);
+            }
+        }
+        painted = next;
+    }
+
+    private static boolean containsSame(List<Painted> spans, Painted target) {
+        for (Painted span : spans) {
+            if (span.start == target.start && span.end == target.end && span.style == target.style) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void setAttributes(int offset, int length, AttributeSet style) {
+        if (length <= 0 || offset < 0 || offset >= styledDocument.getLength()) {
+            return;
+        }
+        int count = Math.min(length, styledDocument.getLength() - offset);
+        styledDocument.setCharacterAttributes(offset, count, style, true);
+    }
+
     private Style styleFor(int type, int modifiers) {
         if (type == TYPE_KEYWORD) {
             return styleNamed(STYLE_RESERVED);
@@ -265,6 +354,7 @@ public class SmartDocumentFilter extends DocumentFilter {
      */
     public void reparseDocument() {
         this.latestTokenList.set(List.of());
+        this.painted = List.of();
         try {
             parseDocument();
         } catch (BadLocationException e) {
@@ -272,6 +362,17 @@ public class SmartDocumentFilter extends DocumentFilter {
         }
     }
 
+    /**
+     * Releases the in-process language client. A second call does nothing.
+     */
+    public void close() {
+        if (closed) {
+            return;
+        }
+        closed = true;
+        language.close();
+    }
+
     /**
      * Updates all SmartDocumentFilter styles with current theme colors.
      * Attribute values are sourced from ThemeManager so custom .theme files
@@ -324,9 +425,12 @@ public class SmartDocumentFilter extends DocumentFilter {
     }
 
     private volatile boolean latest = false;
+    private volatile boolean closed;
     /** Replaced as a whole. Callers receive an unmodifiable snapshot. */
     private final AtomicReference<List<Delimiter>> latestTokenList = new AtomicReference<>(List.of());
     private volatile Tuple2<Integer, Integer> renderRange;
+    /** Spans painted by the last full pass. A partial pass clears this. */
+    private List<Painted> painted = List.of();
 
     /**
      * Indicates whether the cached token list reflects the current document text.
@@ -391,4 +495,16 @@ public class SmartDocumentFilter extends DocumentFilter {
             return text;
         }
     }
+
+    private static final class Painted {
+        private final int start;
+        private final int end;
+        private final Style style;
+
+        private Painted(int start, int end, Style style) {
+            this.start = start;
+            this.end = end;
+            this.style = style;
+        }
+    }
 }
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/BasicMenuBar.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/BasicMenuBar.groovy
index 83ad4ec7ae..e72b6e1636 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/BasicMenuBar.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/BasicMenuBar.groovy
@@ -59,6 +59,8 @@ menuBar {
         menuItem(largerFontAction)
         menuItem(smallerFontAction)
         checkBoxMenuItem(smartHighlighterAction, selected: controller.smartHighlighter)
+        checkBoxMenuItem(compilerAssistanceAction, selected: controller.compilerAssistance)
+        menuItem(completionShortcutAction)
         separator()
         checkBoxMenuItem(captureStdOutAction, selected: controller.captureStdOut)
         checkBoxMenuItem(captureStdErrAction, selected: controller.captureStdErr)
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/MacOSXMenuBar.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/MacOSXMenuBar.groovy
index 19db4d15bd..a17ba8a31a 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/MacOSXMenuBar.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/view/MacOSXMenuBar.groovy
@@ -185,6 +185,8 @@ menuBar {
         separator()
         menuItem(largerFontAction)
         menuItem(smallerFontAction)
+        checkBoxMenuItem(compilerAssistanceAction, selected: controller.compilerAssistance)
+        menuItem(completionShortcutAction)
         separator()
         checkBoxMenuItem(captureStdOutAction, selected: controller.captureStdOut)
         checkBoxMenuItem(captureStdErrAction, selected: controller.captureStdErr)
diff --git a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
index 747033807a..1261e93f0d 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -179,8 +179,15 @@ without selecting any characters
 compiler diagnostics with a thin red wavy underline. A diagnostic that only marks the end of the buffer,
 such as unfinished input (`a.` or `a?.`), does not paint the operator or the
 character before the caret.
-`Alt+/` offers compiler-backed completions
+Highlighting restyles only the spans that changed and does not add those
+attribute edits to the undo stack.
+`View -> Compiler Assistance` turns the language host off. Diagnostics,
+completion and the background compile then stay off; `View -> Smart Highlighter`
+still selects semantic-token or regex colouring.
+The completion key stroke defaults to `Alt+/`
 (`Ctrl+Space` is left free for the system input method).
+`View -> Completion Shortcut...` stores another stroke, for example `ctrl SPACE`
+or `alt shift SLASH`, in the `completionShortcut` preference.
 After `.`, `?.`, or `*.`, the list is the methods, fields, and properties
 that receiver can access, including when the statement is not finished yet.
 A method is shown with its parameter types and names, and its return type
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
index 0f5d700cc0..2c842c9e53 100644
--- 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
@@ -18,6 +18,7 @@
  */
 package groovy.console.ui.language
 
+import groovy.console.ui.Console
 import groovy.console.ui.text.SmartDocumentFilter
 import org.apache.groovy.lsp.GroovyLanguageClient
 import org.codehaus.groovy.control.CompilerConfiguration
@@ -55,6 +56,38 @@ import java.awt.event.MouseEvent
 
 final class ConsoleLanguageHostTest {
 
+    @Test
+    void completionChordFollowsThePreference() {
+        def previous = Console.prefs.get(Console.COMPLETION_SHORTCUT, null)
+        def source = new JTextPane()
+        try {
+            Console.prefs.remove(Console.COMPLETION_SHORTCUT)
+            def alt = new KeyEvent(source, KeyEvent.KEY_PRESSED, 0, InputEvent.ALT_DOWN_MASK, KeyEvent.VK_SLASH, '/' as char)
+            assert ConsoleLanguageHost.isCompletionChord(alt)
+            def shifted = new KeyEvent(source, KeyEvent.KEY_PRESSED, 0,
+                    InputEvent.ALT_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK, KeyEvent.VK_SLASH, '/' as char)
+            assert !ConsoleLanguageHost.isCompletionChord(shifted)
+            def space = new KeyEvent(source, KeyEvent.KEY_PRESSED, 0, InputEvent.CTRL_DOWN_MASK, KeyEvent.VK_SPACE, ' ' as char)
+            assert !ConsoleLanguageHost.isCompletionChord(space)
+            Console.prefs.put(Console.COMPLETION_SHORTCUT, 'ctrl SPACE')
+            assert ConsoleLanguageHost.isCompletionChord(space)
+            assert !ConsoleLanguageHost.isCompletionChord(alt)
+            def withShift = new KeyEvent(source, KeyEvent.KEY_PRESSED, 0,
+                    InputEvent.ALT_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK, KeyEvent.VK_SLASH, '/' as char)
+            Console.prefs.put(Console.COMPLETION_SHORTCUT, 'alt shift SLASH')
+            assert ConsoleLanguageHost.isCompletionChord(withShift)
+            Console.prefs.put(Console.COMPLETION_SHORTCUT, 'not a stroke')
+            assert ConsoleLanguageHost.completionStroke().keyCode == KeyEvent.VK_SLASH
+            assert !ConsoleLanguageHost.isCompletionChord(null)
+        } finally {
+            if (previous == null) {
+                Console.prefs.remove(Console.COMPLETION_SHORTCUT)
+            } else {
+                Console.prefs.put(Console.COMPLETION_SHORTCUT, previous)
+            }
+        }
+    }
+
     @Test
     void diagnosticsHoverAndCompletionUseTheEngine() {
         def pane = new JTextPane()
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 61ca3fa27e..9d7d085643 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
@@ -21,6 +21,7 @@ package groovy.console.ui.text
 import groovy.console.ui.ThemeManager
 import org.junit.jupiter.api.Test
 
+import javax.swing.event.UndoableEditListener
 import javax.swing.text.BadLocationException
 import javax.swing.text.DefaultStyledDocument
 import javax.swing.text.StyleConstants
@@ -481,6 +482,23 @@ reserved_word = #00007C, bold
         assert filter.isLatest()
     }
 
+    @Test
+    void testAttributeEditsAreNotUndoableAndCloseIsIdempotent() {
+        def doc = new DefaultStyledDocument()
+        int edits = 0
+        UndoableEditListener listener = { event -> edits++ }
+        doc.addUndoableEditListener(listener)
+        def filter = new SmartDocumentFilter(doc)
+        doc.setDocumentFilter(filter)
+        doc.insertString(0, 'def x = 1\n', null)
+        assert edits == 1
+        doc.insertString(doc.length, 'def y = 2\n', null)
+        assert edits == 2
+        assert fg(doc, 0) == themeFg('reserved_word')
+        filter.close()
+        filter.close()
+    }
+
     private static final class BleDocument extends DefaultStyledDocument {
         boolean fail
 
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 c46011501a..98b7dc1311 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
@@ -501,16 +501,19 @@ class Main {
     }
 
     /**
-     * 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.
+     * Attaches the in-process language session unless {@code noSyntaxCheck}
+     * is set. In that case Tab keeps {@code scriptCompleter} and tail-tips
+     * stay on the engine's Inspector path.
      *
      * @param scriptEngine REPL engine
      * @param systemRegistry command registry
-     * @return the session
+     * @return the session, or {@code null} when syntax checking is off
      */
     private static GroovyshLanguageSession installLanguageSession(GroovyEngine scriptEngine, GroovySystemRegistry systemRegistry) {
+        if (scriptEngine.isNoSyntaxCheck()) {
+            systemRegistry.setScriptDescription(scriptEngine::scriptDescription)
+            return null
+        }
         GroovyshLanguageSession session = new GroovyshLanguageSession(scriptEngine)
         systemRegistry.addCompleter(session)
         systemRegistry.setScriptDescription(session::scriptDescription)
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
index e6716c1421..a1b2813008 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyEngine.java
@@ -1096,6 +1096,13 @@ public class GroovyEngine implements ScriptEngine {
         return groovyOption(groovyOptions(), option, defval);
     }
 
+    /**
+     * @return whether compiler-backed completion and tail-tips are turned off
+     */
+    public boolean isNoSyntaxCheck() {
+        return Boolean.TRUE.equals(groovyOption(NO_SYNTAX_CHECK, Boolean.FALSE));
+    }
+
     /**
      * Returns the value of a specific option from the given options map.
      *
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
index 908d10d089..3d15e97780 100644
--- 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
@@ -37,6 +37,13 @@ import org.jline.reader.ParsedLine
 import org.jline.utils.AttributedString
 
 import java.lang.reflect.Modifier
+import java.util.concurrent.Callable
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import java.util.concurrent.atomic.AtomicReference
 import java.util.regex.Matcher
 import java.util.regex.Pattern
 
@@ -62,8 +69,17 @@ final class GroovyshLanguageSession implements AutoCloseable, Completer {
     private static final Set<String> SKIP = ['GROOVYSH_OPTIONS', 'GROOVY_OPTIONS', 'CONSOLE_OPTIONS',
             'ROOT', 'PWD', 'NANORC', '_args'] as Set
 
+    /** Bound for one completion or tail-tip compile. A busy compile is skipped. */
+    private static final long QUERY_BUDGET_MS = 2_000L
+
     private final GroovyEngine groovy
     private final GroovyLanguageClient language
+    private final ExecutorService queries = Executors.newSingleThreadExecutor({ Runnable runnable ->
+        Thread thread = new Thread(runnable, 'groovysh-language')
+        thread.daemon = true
+        thread
+    })
+    private final AtomicReference<Future<?>> inflight = new AtomicReference<>()
     private String namesPreamble
     private Set<String> namesCache
     private CompilerConfiguration boundConfiguration
@@ -126,14 +142,11 @@ final class GroovyshLanguageSession implements AutoCloseable, Completer {
      */
     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(GroovyLanguageClient.diagnosticText(it)) })
-                    return desc
-                }
-            } catch (Exception ignored) {
+            def hits = query({ diagnosticsFor(line.line ?: '') } as Callable<List<Diagnostic>>, Collections.<Diagnostic>emptyList())
+            if (hits) {
+                def desc = new CmdDesc()
+                desc.setMainDesc(hits.take(5).collect { new AttributedString(GroovyLanguageClient.diagnosticText(it)) })
+                return desc
             }
             return null
         }
@@ -142,7 +155,9 @@ final class GroovyshLanguageSession implements AutoCloseable, Completer {
                 String raw = line.line ?: ''
                 String head = line.head ?: ''
                 int cursor = Math.min(head.length() + 1, raw.length())
-                def desc = describeSignatures(signaturesFor(raw, cursor))
+                def desc = describeSignatures(query(
+                        { signaturesFor(raw, cursor) } as Callable<List<SignatureInformation>>,
+                        Collections.<SignatureInformation>emptyList()))
                 if (desc != null) {
                     return desc
                 }
@@ -223,18 +238,47 @@ final class GroovyshLanguageSession implements AutoCloseable, Completer {
             return
         }
         try {
-            def items = completeLine(raw, line.cursor())
+            def items = query({ completeLine(raw, line.cursor()) } as Callable<List<CompletionItem>>,
+                    Collections.<CompletionItem>emptyList())
             mapEngineCandidates(items, line, candidates, terminalWidth(reader))
         } catch (RuntimeException ignored) {
         }
     }
 
+    /**
+     * Runs one compile unless another is still in flight or it exceeds
+     * {@link #QUERY_BUDGET_MS}. Callers that need the result directly,
+     * including tests, call {@link #completeLine(String, int)} instead.
+     */
+    private <T> T query(Callable<T> action, T fallback) {
+        Future<?> running = inflight.get()
+        if (running != null && !running.isDone()) {
+            return fallback
+        }
+        Future<T> future = queries.submit(action)
+        inflight.set(future)
+        try {
+            return future.get(QUERY_BUDGET_MS, TimeUnit.MILLISECONDS)
+        } catch (TimeoutException ignored) {
+            // Leave the task running. cancel(true) marks the future done while
+            // the worker is still inside the compile, and the interrupt stays
+            // set on that thread for the next task.
+            return fallback
+        } catch (InterruptedException ignored) {
+            Thread.currentThread().interrupt()
+            return fallback
+        } catch (Exception ignored) {
+            return fallback
+        }
+    }
+
     @Override
     void close() {
         if (closed) {
             return
         }
         closed = true
+        queries.shutdownNow()
         language.close()
     }
 
@@ -282,10 +326,12 @@ final class GroovyshLanguageSession implements AutoCloseable, Completer {
         if (configuration != boundConfiguration) {
             boundConfiguration = configuration
             language.parentLoader(groovy.classLoader)
+            language.hostConfiguration(configuration)
+            // Imports and AST customizers travel on the configuration object.
+            // A second string-built ImportCustomizer would also bind an alias
+            // under the simple class name.
             language.settings([
                     classpath: GroovyLanguageClient.classpathOf(groovy.classLoader),
-                    imports: GroovyLanguageClient.importsOf(configuration),
-                    starImports: GroovyLanguageClient.starImportsOf(configuration),
                     scriptBaseClass: configuration?.scriptBaseClass ?: ''
             ])
         }
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index 78f4181b8b..fa7a72eebd 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -65,6 +65,15 @@ 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.
+A completion or tail-tip compile that is already running is not started
+again, and one that exceeds two seconds is abandoned so the prompt stays
+responsive.
+Set the Groovy option `noSyntaxCheck` to skip the language session.
+Tab then uses the shell's own completer, and syntax tail-tips stay on
+the previous inspector path.
+`groovy-groovysh` depends on `groovy-lsp` (and therefore LSP4J and Gson).
+`/console` still needs `groovy-console` on the classpath; that module is
+not pulled in by groovysh itself.
 
 [[GroovyShell-Command-lineOptionsandArguments]]
 === Command-line Options and Arguments
diff --git a/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy b/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy
index 5c8795c5d9..32e9be46e5 100644
--- a/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy
+++ b/subprojects/groovy-lsp/src/main/groovy/org/apache/groovy/lsp/GroovyLanguageClient.groovy
@@ -20,7 +20,6 @@ package org.apache.groovy.lsp
 
 import groovy.transform.Internal
 import groovy.transform.PackageScope
-import org.codehaus.groovy.ast.ClassNode
 import org.codehaus.groovy.control.CompilerConfiguration
 import org.codehaus.groovy.control.customizers.ImportCustomizer
 import org.eclipse.lsp4j.ClientCapabilities
@@ -67,6 +66,8 @@ final class GroovyLanguageClient implements LanguageClient, AutoCloseable {
 
     final GroovyLanguageServer server
     final String uri
+    /** Serializes buffer version, sent text, and published diagnostics. */
+    private final Object exchange = new Object()
     private int version = 1
     private String sent = ''
     private final Map<String, List<Diagnostic>> diagnostics = [:]
@@ -99,26 +100,46 @@ final class GroovyLanguageClient implements LanguageClient, AutoCloseable {
         server.workspaceService.executeCommand(new ExecuteCommandParams('groovy.parentLoader', [loader])).get()
     }
 
+    /**
+     * Publishes the host {@link CompilerConfiguration}, including static
+     * imports, aliases and AST customizers. {@code null} clears it.
+     */
+    void hostConfiguration(CompilerConfiguration configuration) {
+        server.workspaceService.executeCommand(
+                new ExecuteCommandParams('groovy.hostConfiguration', [configuration])).get()
+    }
+
     void settings(Map groovy) {
         server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams([groovy: groovy]))
     }
 
+    /**
+     * Publishes {@code text} when it differs from the last buffer sent.
+     * The lock covers only the version and the text. It is not held across
+     * a compile: the compiler thread publishes diagnostics back onto this
+     * client, and holding the lock there deadlocks.
+     */
     void sync(String text) {
-        String next = text ?: ''
-        if (next == sent) {
-            return
+        synchronized (exchange) {
+            String next = text ?: ''
+            if (next == sent) {
+                return
+            }
+            version++
+            sent = next
+            server.textDocumentService.didChange(new DidChangeTextDocumentParams(
+                    new VersionedTextDocumentIdentifier(uri, version),
+                    [new TextDocumentContentChangeEvent(next)]))
         }
-        version++
-        server.textDocumentService.didChange(new DidChangeTextDocumentParams(
-                new VersionedTextDocumentIdentifier(uri, version),
-                [new TextDocumentContentChangeEvent(next)]))
-        sent = next
     }
 
     List<Diagnostic> compile(String text) {
         sync(text)
         server.workspaceService.executeCommand(new ExecuteCommandParams('groovy.compile', [uri])).get()
-        diagnostics[uri] ?: []
+        synchronized (exchange) {
+            def found = diagnostics[uri]
+            return found == null ? [] : found.toList()
+        }
     }
 
     List<CompletionItem> complete(String text, int line, int character) {
@@ -279,44 +300,48 @@ final class GroovyLanguageClient implements LanguageClient, AutoCloseable {
 
     /** Comma-separated regular imports from {@code configuration}, or empty. */
     static String importsOf(CompilerConfiguration configuration) {
-        namesOf(configuration, 'regular').join(',')
+        collect(configuration) { ImportCustomizer customizer -> customizer.imports }.join(',')
     }
 
     /** Comma-separated star imports from {@code configuration}, or empty. */
     static String starImportsOf(CompilerConfiguration configuration) {
-        namesOf(configuration, 'star').join(',')
+        collect(configuration) { ImportCustomizer customizer -> customizer.starImports }.join(',')
+    }
+
+    /** Comma-separated static imports from {@code configuration}, or empty. */
+    static String staticImportsOf(CompilerConfiguration configuration) {
+        collect(configuration) { ImportCustomizer customizer -> customizer.staticImports }.join(',')
+    }
+
+    /** Comma-separated static-star class names from {@code configuration}, or empty. */
+    static String staticStarsOf(CompilerConfiguration configuration) {
+        collect(configuration) { ImportCustomizer customizer -> customizer.staticStarImports }.join(',')
     }
 
-    private static List<String> namesOf(CompilerConfiguration configuration, String kind) {
+    /** Comma-separated {@code alias=class} entries, or empty. */
+    static String aliasesOf(CompilerConfiguration configuration) {
+        if (configuration == null) {
+            return ''
+        }
+        def pairs = []
+        configuration.compilationCustomizers?.each { customizer ->
+            if (customizer instanceof ImportCustomizer) {
+                customizer.aliases.each { alias, type -> pairs << "${alias}=${type}" }
+            }
+        }
+        pairs.join(',')
+    }
+
+    private static List<String> collect(CompilerConfiguration configuration, Closure<List<String>> read) {
         if (configuration == null) {
             return []
         }
         List<String> names = []
         configuration.compilationCustomizers?.each { customizer ->
             if (customizer instanceof ImportCustomizer) {
-                def field = ImportCustomizer.getDeclaredField('imports')
-                field.accessible = true
-                field.get(customizer).each { imp ->
-                    def typeField = imp.getClass().getDeclaredField('type')
-                    typeField.accessible = true
-                    if (String.valueOf(typeField.get(imp)) != kind) {
-                        return
-                    }
-                    if (kind == 'star') {
-                        def starField = imp.getClass().getDeclaredField('star')
-                        starField.accessible = true
-                        def star = starField.get(imp)
-                        if (star != null) {
-                            names << star.toString()
-                        }
-                    } else {
-                        def nodeField = imp.getClass().getDeclaredField('classNode')
-                        nodeField.accessible = true
-                        def node = nodeField.get(imp)
-                        if (node instanceof ClassNode) {
-                            names << ((ClassNode) node).getName()
-                        }
-                    }
+                def found = read.call((ImportCustomizer) customizer)
+                if (found != null) {
+                    names.addAll(found)
                 }
             }
         }
@@ -326,7 +351,13 @@ final class GroovyLanguageClient implements LanguageClient, AutoCloseable {
     /** Diagnostics last published for this buffer, or {@code null} when none were. */
     @PackageScope
     List<Diagnostic> publishedOrNull() {
-        diagnostics.containsKey(uri) ? diagnostics[uri] : null
+        synchronized (exchange) {
+            if (!diagnostics.containsKey(uri)) {
+                return null
+            }
+            def found = diagnostics[uri]
+            return found == null ? null : found.toList()
+        }
     }
 
     @Override
@@ -334,7 +365,9 @@ final class GroovyLanguageClient implements LanguageClient, AutoCloseable {
         if (params == null) {
             return
         }
-        diagnostics[params.uri] = params.diagnostics ?: []
+        synchronized (exchange) {
+            diagnostics[params.uri] = params.diagnostics ?: []
+        }
     }
 
     @Override
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 24b6dd2e75..7da09db706 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
@@ -287,10 +287,11 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
     }
 
     /**
-     * {@code groovy.imports} and {@code groovy.starImports} are comma-separated
-     * class or package names. An empty value adds nothing, which clears a
-     * previous client import list because each compile builds a fresh
-     * configuration.
+     * {@code groovy.imports}, {@code groovy.starImports}, {@code groovy.staticImports},
+     * {@code groovy.staticStars} and {@code groovy.aliases} are comma-separated.
+     * An alias entry is {@code alias=class}. A static import is {@code Class.member}
+     * or {@code alias=Class.member}. An empty value adds nothing. Each compile
+     * builds a fresh configuration, so a missing key does not keep the previous list.
      */
     private static void applyClientImports(final CompilerConfiguration configuration,
                                            final Map<String, String> extra) {
@@ -299,19 +300,58 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
         }
         String imports = extra.get("imports");
         String stars = extra.get("starImports");
+        String staticImports = extra.get("staticImports");
+        String staticStars = extra.get("staticStars");
+        String aliases = extra.get("aliases");
         String base = extra.get("scriptBaseClass");
         if (base != null && !base.isEmpty()) {
             configuration.setScriptBaseClass(base);
         }
-        if ((imports == null || imports.isEmpty()) && (stars == null || stars.isEmpty())) {
+        if (isBlank(imports) && isBlank(stars) && isBlank(staticImports) && isBlank(staticStars) && isBlank(aliases)) {
             return;
         }
         ImportCustomizer customizer = new ImportCustomizer();
         addCsv(imports, customizer::addImports);
-        addCsv(stars, customizer::addStarImports);
+        addCsv(stars, name -> customizer.addStarImports(stripTrailingDot(name)));
+        addCsv(aliases, entry -> addAlias(customizer, entry));
+        addCsv(staticImports, entry -> addStatic(customizer, entry));
+        addCsv(staticStars, customizer::addStaticStars);
         configuration.addCompilationCustomizers(customizer);
     }
 
+    private static boolean isBlank(final String value) {
+        return value == null || value.isEmpty();
+    }
+
+    private static String stripTrailingDot(final String name) {
+        return name.endsWith(".") ? name.substring(0, name.length() - 1) : name;
+    }
+
+    private static void addAlias(final ImportCustomizer customizer, final String entry) {
+        int eq = entry.indexOf('=');
+        if (eq <= 0 || eq == entry.length() - 1) {
+            return;
+        }
+        customizer.addImport(entry.substring(0, eq), entry.substring(eq + 1));
+    }
+
+    private static void addStatic(final ImportCustomizer customizer, final String entry) {
+        int eq = entry.indexOf('=');
+        String body = eq < 0 ? entry : entry.substring(eq + 1);
+        String alias = eq < 0 ? null : entry.substring(0, eq);
+        int dot = body.lastIndexOf('.');
+        if (dot <= 0 || dot == body.length() - 1) {
+            return;
+        }
+        String type = body.substring(0, dot);
+        String member = body.substring(dot + 1);
+        if (alias == null || alias.isEmpty()) {
+            customizer.addStaticImport(type, member);
+        } else {
+            customizer.addStaticImport(alias, type, member);
+        }
+    }
+
     private static void addCsv(final String csv, final Consumer<String> add) {
         if (csv == null) {
             return;
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 78cbdfc3bc..7939e6fe7e 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
@@ -20,12 +20,14 @@ package org.apache.groovy.lsp.internal.compile;
 
 import groovy.lang.GroovyClassLoader;
 import org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingPass;
+import org.codehaus.groovy.ast.ModuleNode;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
 import org.codehaus.groovy.control.CompilationFailedException;
 import org.codehaus.groovy.control.CompilationUnit;
 import org.codehaus.groovy.control.CompilerConfiguration;
 import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.transform.ASTTestTransformation;
 import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit;
 
 import java.io.File;
@@ -52,9 +54,6 @@ import java.util.function.Supplier;
  * AST. Errors are collected rather than aborting the compile.
  */
 public final class GroovyCompiler implements AutoCloseable {
-
-    private static final String ASTTEST_ENABLE = "groovy.asttest.enable";
-    private static final String GRAPE_ENABLE = "groovy.grape.enable";
     private static final Object PROCESS_FLAGS = new Object();
 
     private GroovyClassLoader groovyLoader;
@@ -324,10 +323,61 @@ public final class GroovyCompiler implements AutoCloseable {
     }
 
     /**
-     * Sets {@code groovy.asttest.enable} and {@code groovy.grape.enable} for
-     * {@code action}, then restores whatever values were there before.
-     * Every compile shares this JVM lock, so one session cannot restore
-     * flags another session still needs.
+     * Compiles one buffer with the same settings as a workspace compile.
+     * {@code @Grab} is omitted through {@code disabledGlobalASTTransformations}.
+     * {@code @ASTTest} is a local transform, so it follows a per-thread override
+     * rather than the JVM system property.
+     *
+     * @param name source name
+     * @param source buffer text
+     * @param settings compiler settings, or {@code null} for {@link CompilerSettings#defaults()}
+     * @param parentLoader parent loader, or {@code null} for this class's loader
+     * @param host console or groovysh configuration, or {@code null}
+     * @param phase compile phase, a {@code Phases} constant
+     * @return the module, or {@code null} when the buffer produced none
+     */
+    public static ModuleNode compileFragment(final String name, final String source, final CompilerSettings settings,
+                                             final ClassLoader parentLoader, final CompilerConfiguration host,
+                                             final int phase) {
+        CompilerSettings effective = settings == null ? CompilerSettings.defaults() : settings;
+        CompilerConfiguration configuration = effective.toConfiguration(host);
+        configuration.setTolerance(100);
+        ClassLoader parent = parentLoader == null ? GroovyCompiler.class.getClassLoader() : parentLoader;
+        return withProcessFlags(effective, () -> {
+            GroovyClassLoader loader = new GroovyClassLoader(parent, configuration);
+            try {
+                CompilationUnit unit = new CompilationUnit(configuration, null, loader);
+                unit.addSource(name == null ? "fragment.groovy" : name, source == null ? "" : source);
+                try {
+                    unit.compile(phase);
+                } catch (CompilationFailedException ignored) {
+                    // a repair stand-in can still carry the declarations above the caret
+                }
+                ModuleNode found = null;
+                var sources = unit.iterator();
+                while (sources.hasNext()) {
+                    SourceUnit src = sources.next();
+                    if (src.getAST() != null) {
+                        found = src.getAST();
+                    }
+                }
+                return found;
+            } finally {
+                try {
+                    loader.close();
+                } catch (IOException ignored) {
+                    // the module already holds the resolved JDK types
+                }
+            }
+        });
+    }
+
+    /**
+     * Runs {@code action} with a per-thread {@code @ASTTest} override taken
+     * from {@code settings}. The JVM properties {@code groovy.asttest.enable}
+     * and {@code groovy.grape.enable} are left alone. The lock serializes
+     * tooling compiles so two sessions do not overlap; the override itself
+     * does not need the JVM properties.
      *
      * @param settings compiler settings
      * @param action work that must see those flags
@@ -336,37 +386,16 @@ public final class GroovyCompiler implements AutoCloseable {
      */
     static <T> T withProcessFlags(final CompilerSettings settings, final Supplier<T> action) {
         synchronized (PROCESS_FLAGS) {
-            String previousAstTest = System.getProperty(ASTTEST_ENABLE);
-            String previousGrape = System.getProperty(GRAPE_ENABLE);
+            Boolean previous = ASTTestTransformation.currentThreadEnabled();
             try {
-                applyProcessFlags(settings);
+                ASTTestTransformation.setThreadEnabled(settings != null && settings.isAstTestEnabled());
                 return action.get();
             } finally {
-                restoreProperty(ASTTEST_ENABLE, previousAstTest);
-                restoreProperty(GRAPE_ENABLE, previousGrape);
+                ASTTestTransformation.setThreadEnabled(previous);
             }
         }
     }
 
-    /**
-     * {@code @ASTTest} is a local transform; {@code disabledGlobalASTTransformations}
-     * does not turn it off. The documented process flags do.
-     *
-     * @param settings compiler settings
-     */
-    private static void applyProcessFlags(final CompilerSettings settings) {
-        System.setProperty(ASTTEST_ENABLE, Boolean.toString(settings.isAstTestEnabled()));
-        System.setProperty(GRAPE_ENABLE, Boolean.toString(settings.isGrapeEnabled()));
-    }
-
-    private static void restoreProperty(final String key, final String previous) {
-        if (previous == null) {
-            System.clearProperty(key);
-        } else {
-            System.setProperty(key, previous);
-        }
-    }
-
     private synchronized GroovyClassLoader loaderFor(final CompilerSettings settings, final ClassLoader parentLoader,
                                                      final CompilerConfiguration configuration) {
         LoaderKey key = new LoaderKey(settings.getClasspath(), configuration.getClasspath(),
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 175fde4419..e3dfac3fbc 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
@@ -476,7 +476,8 @@ public final class GroovyLanguageEngine implements AutoCloseable {
     public List<Candidate> complete(final URI uri, final int line, final int character) {
         TextDocument document = documentAt(uri);
         CompletionList list = features.completions().complete(document, context.getSnapshot(),
-                new Position(line, character), encoding(), context.getParentLoader(), context.getHostConfiguration());
+                new Position(line, character), encoding(), context.getParentLoader(), context.getHostConfiguration(),
+                context.getSettings());
         List<CompletionItem> items = list.getItems();
         return items.stream()
                 .filter(Objects::nonNull)
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
index 7688503468..1a7cc5c649 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
@@ -18,18 +18,20 @@
  */
 package org.apache.groovy.lsp.internal.feature;
 
-import groovy.lang.GroovyClassLoader;
 import groovy.lang.GroovySystem;
 import groovy.lang.MetaMethod;
 import groovy.lang.groovydoc.Groovydoc;
 import org.apache.groovy.lsp.internal.compile.AstQuery;
 import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
 import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.CompilerSettings;
+import org.apache.groovy.lsp.internal.compile.GroovyCompiler;
 import org.apache.groovy.lsp.internal.compile.ImportSupport;
 import org.apache.groovy.lsp.internal.compile.TypeIndex;
 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.ReflectionParameterNames;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.ast.ASTNode;
@@ -53,16 +55,12 @@ import org.codehaus.groovy.ast.expr.MapExpression;
 import org.codehaus.groovy.ast.expr.MethodCallExpression;
 import org.codehaus.groovy.ast.expr.PropertyExpression;
 import org.codehaus.groovy.ast.expr.VariableExpression;
-import org.codehaus.groovy.control.CompilationFailedException;
-import org.codehaus.groovy.control.CompilationUnit;
 import org.codehaus.groovy.control.CompilerConfiguration;
 import org.codehaus.groovy.control.Phases;
 import org.codehaus.groovy.control.ResolveVisitor;
 import org.codehaus.groovy.control.SourceUnit;
-import org.codehaus.groovy.control.customizers.CompilationCustomizer;
 import org.codehaus.groovy.reflection.CachedClass;
 import org.codehaus.groovy.reflection.CachedMethod;
-import org.codehaus.groovy.reflection.ReflectionParameterNames;
 import org.codehaus.groovy.runtime.DefaultGroovyMethods;
 import org.codehaus.groovy.runtime.DefaultGroovyStaticMethods;
 import org.codehaus.groovy.runtime.InvokerHelper;
@@ -85,7 +83,6 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either;
 
 import static org.objectweb.asm.Opcodes.ACC_VARARGS;
 
-import java.io.IOException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayDeque;
@@ -145,6 +142,22 @@ public final class CompletionService {
     public CompletionList complete(final TextDocument document, final CompilationSnapshot snapshot,
                                    final Position position, final PositionEncoding encoding,
                                    final ClassLoader parentLoader, final CompilerConfiguration hostConfiguration) {
+        return complete(document, snapshot, position, encoding, parentLoader, hostConfiguration, null);
+    }
+
+    /**
+     * Completes at {@code position}. {@code settings} controls the repair
+     * compile used when the buffer did not parse. {@code null} uses
+     * {@link CompilerSettings#defaults()}, which does not run {@code @Grab}
+     * or {@code @ASTTest}.
+     *
+     * @param settings session settings, or {@code null}
+     * @return a completion list (incomplete when truncated)
+     */
+    public CompletionList complete(final TextDocument document, final CompilationSnapshot snapshot,
+                                   final Position position, final PositionEncoding encoding,
+                                   final ClassLoader parentLoader, final CompilerConfiguration hostConfiguration,
+                                   final CompilerSettings settings) {
         List<CompletionItem> items = new ArrayList<>();
         String prefix = prefixAt(document, position, encoding);
         boolean memberAccess = isMemberAccess(document, position, encoding);
@@ -152,7 +165,7 @@ public final class CompletionService {
         CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
         ModuleNode module = compiled == null ? null : compiled.getModule();
         if (module == null && memberAccess && compiled != null) {
-            module = repairedModule(document, position, parentLoader, hostConfiguration);
+            module = repairedModule(document, position, parentLoader, hostConfiguration, settings);
         }
 
         if (!memberAccess) {
@@ -1172,6 +1185,13 @@ public final class CompletionService {
     private static ModuleNode repairedModule(final TextDocument document, final Position position,
                                              final ClassLoader parentLoader,
                                              final CompilerConfiguration hostConfiguration) {
+        return repairedModule(document, position, parentLoader, hostConfiguration, null);
+    }
+
+    private static ModuleNode repairedModule(final TextDocument document, final Position position,
+                                             final ClassLoader parentLoader,
+                                             final CompilerConfiguration hostConfiguration,
+                                             final CompilerSettings settings) {
         if (document == null || position == null) {
             return null;
         }
@@ -1179,49 +1199,8 @@ public final class CompletionService {
         if (repaired == null || repaired.equals(document.getText())) {
             return null;
         }
-        CompilerConfiguration configuration = repairConfiguration(hostConfiguration);
-        ClassLoader parent = parentLoader == null ? CompletionService.class.getClassLoader() : parentLoader;
-        GroovyClassLoader loader = new GroovyClassLoader(parent, configuration);
-        try {
-            CompilationUnit unit = new CompilationUnit(configuration, null, loader);
-            unit.addSource("repair.groovy", repaired);
-            try {
-                unit.compile(Phases.SEMANTIC_ANALYSIS);
-            } catch (CompilationFailedException ignored) {
-                // the stand-in can still carry the declarations above the caret
-            }
-            ModuleNode found = null;
-            var sources = unit.iterator();
-            while (sources.hasNext()) {
-                SourceUnit source = sources.next();
-                if (source.getAST() != null) {
-                    found = source.getAST();
-                }
-            }
-            return found;
-        } finally {
-            try {
-                loader.close();
-            } catch (IOException ignored) {
-                // the module already holds the resolved JDK types
-            }
-        }
-    }
-
-    private static CompilerConfiguration repairConfiguration(final CompilerConfiguration host) {
-        CompilerConfiguration configuration = new CompilerConfiguration();
-        configuration.setTolerance(100);
-        if (host == null) {
-            return configuration;
-        }
-        if (host.getScriptBaseClass() != null) {
-            configuration.setScriptBaseClass(host.getScriptBaseClass());
-        }
-        List<CompilationCustomizer> customizers = host.getCompilationCustomizers();
-        if (customizers != null && !customizers.isEmpty()) {
-            configuration.addCompilationCustomizers(customizers.toArray(CompilationCustomizer[]::new));
-        }
-        return configuration;
+        return GroovyCompiler.compileFragment("repair.groovy", repaired, settings, parentLoader,
+                hostConfiguration, Phases.SEMANTIC_ANALYSIS);
     }
 
     private static String repairMemberAccess(final String text, final int line, final int character) {
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
index c930328b81..4f57178ab5 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
@@ -145,7 +145,7 @@ public final class GroovyTextDocumentService implements TextDocumentService {
         return withDocument(params.getTextDocument().getUri(), Either.forRight(new CompletionList(false, List.of())),
                 document -> Either.forRight(features.completions().complete(document, context.getSnapshot(),
                         params.getPosition(), context.getPositionEncoding(),
-                        context.getParentLoader(), context.getHostConfiguration())));
+                        context.getParentLoader(), context.getHostConfiguration(), context.getSettings())));
     }
 
     @Override
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
index 4413c3f440..c8ef14949d 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
@@ -45,6 +45,7 @@ import org.eclipse.lsp4j.WorkspaceSymbol;
 import org.eclipse.lsp4j.WorkspaceSymbolParams;
 import org.eclipse.lsp4j.jsonrpc.messages.Either;
 import org.eclipse.lsp4j.services.LanguageClient;
+import org.codehaus.groovy.control.CompilerConfiguration;
 import org.eclipse.lsp4j.services.WorkspaceService;
 
 import java.util.ArrayList;
@@ -177,6 +178,9 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         if ("groovy.parentLoader".equals(command)) {
             return publishParentLoader(args);
         }
+        if ("groovy.hostConfiguration".equals(command)) {
+            return publishHostConfiguration(args);
+        }
         if (commandWith(command, "groovy.scriptNames", args, 1)) {
             return scriptNames(args);
         }
@@ -210,6 +214,14 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         return CompletableFuture.completedFuture(null);
     }
 
+    private CompletableFuture<Object> publishHostConfiguration(final List<Object> args) {
+        Object configuration = args.isEmpty() ? null : args.get(0);
+        if (configuration == null || configuration instanceof CompilerConfiguration) {
+            context.setHostConfiguration((CompilerConfiguration) configuration);
+        }
+        return CompletableFuture.completedFuture(null);
+    }
+
     private CompletableFuture<Object> scriptNames(final List<Object> args) {
         context.recompile();
         CompiledDocument compiled = context.getSnapshot().get(String.valueOf(args.get(0)));
diff --git a/src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/ReflectionParameterNames.java
similarity index 82%
rename from src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java
rename to subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/ReflectionParameterNames.java
index dc5b6a55fe..ac832c2c3b 100644
--- a/src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/ReflectionParameterNames.java
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.codehaus.groovy.reflection;
+package org.apache.groovy.lsp.internal.util;
 
 import org.objectweb.asm.ClassReader;
 import org.objectweb.asm.ClassVisitor;
@@ -27,12 +27,13 @@ import org.objectweb.asm.Type;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.lang.reflect.Constructor;
 import java.lang.reflect.Executable;
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.WeakHashMap;
 
 /**
  * Formal parameter names for a reflective method or constructor.
@@ -40,11 +41,13 @@ import java.util.concurrent.ConcurrentHashMap;
  * {@link Parameter#getName()} is {@code arg0} unless the class was compiled
  * with {@code -parameters}. JDK and Groovy class files still record the
  * source names in {@code LocalVariableTable}; those names are used when the
- * method-parameters attribute is absent.
+ * method-parameters attribute is absent. The table is cached by class with
+ * weak keys, so a throwaway class loader can be collected after the caller
+ * drops the class.
  */
 public final class ReflectionParameterNames {
 
-    private static final Map<Class<?>, Map<String, String[]>> DEBUG_NAMES = new ConcurrentHashMap<>();
+    private static final WeakHashMap<Class<?>, Map<String, String[]>> DEBUG_NAMES = new WeakHashMap<>();
 
     private ReflectionParameterNames() {
     }
@@ -89,21 +92,41 @@ public final class ReflectionParameterNames {
     private static String[] debugNames(final Executable executable) {
         String key = executable instanceof Method method
                 ? method.getName() + Type.getMethodDescriptor(method)
-                : "<init>" + Type.getConstructorDescriptor((java.lang.reflect.Constructor<?>) executable);
-        Map<String, String[]> table = DEBUG_NAMES.computeIfAbsent(executable.getDeclaringClass(), ReflectionParameterNames::readClass);
+                : "<init>" + Type.getConstructorDescriptor((Constructor<?>) executable);
+        Class<?> type = executable.getDeclaringClass();
+        Map<String, String[]> table = cached(type);
+        if (table == null) {
+            Map<String, String[]> loaded = readClass(type);
+            synchronized (DEBUG_NAMES) {
+                Map<String, String[]> existing = DEBUG_NAMES.get(type);
+                if (existing != null) {
+                    table = existing;
+                } else {
+                    DEBUG_NAMES.put(type, loaded);
+                    table = loaded;
+                }
+            }
+        }
         String[] found = table.get(key);
         return found == null ? null : found.clone();
     }
 
+    private static Map<String, String[]> cached(final Class<?> type) {
+        synchronized (DEBUG_NAMES) {
+            return DEBUG_NAMES.get(type);
+        }
+    }
+
     private static Map<String, String[]> readClass(final Class<?> type) {
         Map<String, String[]> table = new HashMap<>();
         if (type == null || type.isPrimitive() || type.isArray()) {
             return table;
         }
         String resource = type.getName().replace('.', '/') + ".class";
-        InputStream in = type.getClassLoader() == null
+        ClassLoader loader = type.getClassLoader();
+        InputStream in = loader == null
                 ? ClassLoader.getSystemResourceAsStream(resource)
-                : type.getClassLoader().getResourceAsStream(resource);
+                : loader.getResourceAsStream(resource);
         if (in == null) {
             return table;
         }
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageClientTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageClientTest.groovy
index 87617ac8bc..dc30be3215 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageClientTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageClientTest.groovy
@@ -154,8 +154,26 @@ final class GroovyLanguageClientTest {
         configuration.addCompilationCustomizers(imports)
         configuration.addCompilationCustomizers(
                 new SourceAwareCustomizer(new ImportCustomizer()))
+        imports.addImport('L', 'java.util.List')
+        imports.addStaticImport('java.lang.System', 'out')
+        imports.addStaticImport('sout', 'java.lang.System', 'out')
+        imports.addStaticStars('java.lang.Math')
         assert GroovyLanguageClient.importsOf(configuration).contains('java.util.List')
         assert GroovyLanguageClient.starImportsOf(configuration).contains('java.io')
+        assert GroovyLanguageClient.aliasesOf(configuration).contains('L=java.util.List')
+        def aliasOnly = new ImportCustomizer()
+        aliasOnly.addImport('JL', 'java.util.List')
+        def aliasConfig = new CompilerConfiguration()
+        aliasConfig.addCompilationCustomizers(aliasOnly)
+        assert GroovyLanguageClient.importsOf(aliasConfig) == ''
+        assert GroovyLanguageClient.aliasesOf(aliasConfig) == 'JL=java.util.List'
+        assert GroovyLanguageClient.staticImportsOf(configuration).contains('java.lang.System.out')
+        assert GroovyLanguageClient.staticImportsOf(configuration).contains('sout=java.lang.System.out')
+        assert !GroovyLanguageClient.staticImportsOf(configuration).contains('java.lang.Math')
+        assert GroovyLanguageClient.staticStarsOf(configuration).contains('java.lang.Math')
+        assert GroovyLanguageClient.aliasesOf(null) == ''
+        assert GroovyLanguageClient.staticImportsOf(null) == ''
+        assert GroovyLanguageClient.staticStarsOf(null) == ''
         def directory = File.createTempDir('lsp-cp', '')
         directory.deleteOnExit()
         def loader = new URLClassLoader([directory.toURI().toURL(), new URL('http://example.com/missing.jar')] as URL[], null)
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/CompilerSafetyTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/CompilerSafetyTest.groovy
index 7ddf31f395..5fe33e19e8 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/CompilerSafetyTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/CompilerSafetyTest.groovy
@@ -38,6 +38,7 @@ import org.junit.jupiter.api.parallel.Resources
 
 import org.apache.groovy.lsp.internal.diagnostic.DiagnosticConverter
 import org.apache.groovy.lsp.internal.position.PositionEncoding
+import org.codehaus.groovy.transform.ASTTestTransformation
 import org.apache.groovy.lsp.internal.workspace.TextDocument
 import org.codehaus.groovy.control.CompilerConfiguration
 import org.codehaus.groovy.control.ErrorCollector
@@ -377,7 +378,6 @@ final class CompilerSafetyTest {
                 try {
                     ready.countDown()
                     assert start.await(5, TimeUnit.SECONDS)
-                    def expected = Boolean.toString(settings.astTestEnabled)
                     GroovyCompiler.withProcessFlags(settings) {
                         inside.incrementAndGet()
                         started.incrementAndGet()
@@ -386,8 +386,12 @@ final class CompilerSafetyTest {
                             LockSupport.parkNanos(1_000_000L)
                         }
                         maxInside.updateAndGet { Math.max(it, inside.get()) }
-                        assert System.getProperty('groovy.asttest.enable') == expected
-                        assert System.getProperty('groovy.grape.enable') == expected
+                        assert System.getProperty('groovy.asttest.enable') == null
+                        assert System.getProperty('groovy.grape.enable') == null
+                        assert ASTTestTransformation.currentThreadEnabled() == settings.astTestEnabled
+                        def other = new AtomicReference<Boolean>()
+                        Thread.start { other.set(ASTTestTransformation.currentThreadEnabled()) }.join(5_000)
+                        assert other.get() == null
                         inside.decrementAndGet()
                         null
                     }
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/CompletionServiceTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/CompletionServiceTest.groovy
index 332323a375..2e17f7176c 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/CompletionServiceTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/feature/CompletionServiceTest.groovy
@@ -228,12 +228,25 @@ final class CompletionServiceTest {
                 [null, null, null] as Object[]) == null
         assert call('repairedModule', [TextDocument, Position, ClassLoader, CompilerConfiguration] as Class[],
                 [null, null, null, null] as Object[]) == null
+        assert call('repairedModule', [TextDocument, Position, ClassLoader, CompilerConfiguration, CompilerSettings] as Class[],
+                [null, null, null, null, null] as Object[]) == null
 
-        def config = new CompilerConfiguration()
-        config.scriptBaseClass = 'groovy.lang.Script'
-        config.addCompilationCustomizers(new ImportCustomizer())
-        def repaired = call('repairConfiguration', [CompilerConfiguration] as Class[], [config] as Object[])
-        assert repaired.scriptBaseClass == 'groovy.lang.Script'
+        System.clearProperty('probe.ran')
+        def astText = '''\
+import groovy.transform.ASTTest
+@ASTTest(value={ System.setProperty('probe.ran','yes') })
+def x = 'abc'
+x.
+'''
+        def astDoc = new TextDocument(URI.create('groovy-buffer:repair-ast'), 'groovy', 1, astText)
+        call('repairedModule', [TextDocument, Position, ClassLoader, CompilerConfiguration, CompilerSettings] as Class[],
+                [astDoc, new Position(3, 2), CompletionServiceTest.classLoader, null, CompilerSettings.defaults()] as Object[])
+        assert System.getProperty('probe.ran') == null
+        def astOn = new CompilerSettings([], [], CompilerSettings.defaults().throughPhase, false, true)
+        call('repairedModule', [TextDocument, Position, ClassLoader, CompilerConfiguration, CompilerSettings] as Class[],
+                [astDoc, new Position(3, 2), CompletionServiceTest.classLoader, null, astOn] as Object[])
+        assert System.getProperty('probe.ran') == 'yes'
+        System.clearProperty('probe.ran')
 
         def nodes = [new ClassNode[1], new int[1]]
         call('offer', [int, int, ClassNode, int, ClassNode[], int[]] as Class[],
diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/ReflectionParameterNamesTest.groovy
similarity index 87%
rename from src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy
rename to subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/ReflectionParameterNamesTest.groovy
index ccede9e260..a4e50e3e76 100644
--- a/src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/util/ReflectionParameterNamesTest.groovy
@@ -16,16 +16,14 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.codehaus.groovy.reflection
+package org.apache.groovy.lsp.internal.util
 
 import org.codehaus.groovy.runtime.DefaultGroovyMethods
-import org.codehaus.groovy.vmplugin.VMPluginFactory
-import org.codehaus.groovy.vmplugin.v8.Java8
 import org.junit.jupiter.api.Test
 import org.objectweb.asm.ClassWriter
 import org.objectweb.asm.Opcodes
 
-import java.lang.reflect.Member
+import java.lang.ref.WeakReference
 
 final class ReflectionParameterNamesTest {
 
@@ -61,12 +59,6 @@ final class ReflectionParameterNamesTest {
         def constructed = Holder.getDeclaredConstructor(String)
         assert ReflectionParameterNames.names(constructed) == ['person'] as String[]
 
-        def fill = Java8.getDeclaredMethod('fillParameterNames', String[], Member)
-        fill.accessible = true
-        def filled = new String[1]
-        fill.invoke(VMPluginFactory.plugin, [filled, charAt] as Object[])
-        assert filled[0] == 'index'
-
         def anyName = ReflectionParameterNames.getDeclaredMethod('anyName', String[])
         anyName.accessible = true
         assert anyName.invoke(null, [null] as Object[]) == false
@@ -98,6 +90,20 @@ final class ReflectionParameterNamesTest {
         assert ReflectionParameterNames.names(boomPing) == ['arg0'] as String[]
     }
 
+    @Test
+    void cacheDoesNotPinAThrowawayLoader() {
+        WeakReference<?>[] refs = ParameterNameLeakProbe.defineAndName(classWithoutDebug('cov/Gone'))
+        for (int i = 0; i < 40 && (refs[0].get() != null || refs[1].get() != null); i++) {
+            System.gc()
+            byte[] pressure = new byte[8 * 1024 * 1024]
+            pressure[0] = (byte) i
+        }
+        // Touch the cache so a cleared weak key is expunged.
+        ReflectionParameterNames.names(String.getMethod('length'))
+        assert refs[1].get() == null
+        assert refs[0].get() == null
+    }
+
     private static byte[] classWithoutDebug(String internalName) {
         def writer = new ClassWriter(0)
         writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, 'java/lang/Object', null)
diff --git a/subprojects/groovy-lsp/src/test/java/org/apache/groovy/lsp/internal/util/ParameterNameLeakProbe.java b/subprojects/groovy-lsp/src/test/java/org/apache/groovy/lsp/internal/util/ParameterNameLeakProbe.java
new file mode 100644
index 0000000000..2d03f1f267
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/java/org/apache/groovy/lsp/internal/util/ParameterNameLeakProbe.java
@@ -0,0 +1,51 @@
+/*
+ *  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.util;
+
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Method;
+
+/**
+ * Defines {@code cov.Gone} and asks for its parameter names without
+ * letting Groovy's runtime see the class. The caller must drop every
+ * strong reference before checking the returned weak references.
+ */
+public final class ParameterNameLeakProbe {
+
+    private ParameterNameLeakProbe() {
+    }
+
+    public static WeakReference<?>[] defineAndName(final byte[] bytes) throws Exception {
+        Isolated loader = new Isolated(ParameterNameLeakProbe.class.getClassLoader());
+        Class<?> type = loader.define(bytes);
+        Method method = type.getDeclaredMethod("ping", int.class);
+        ReflectionParameterNames.names(method);
+        return new WeakReference<?>[] {new WeakReference<>(loader), new WeakReference<>(type)};
+    }
+
+    private static final class Isolated extends ClassLoader {
+        private Isolated(final ClassLoader parent) {
+            super(parent);
+        }
+
+        private Class<?> define(final byte[] bytes) {
+            return defineClass("cov.Gone", bytes, 0, bytes.length);
+        }
+    }
+}