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/17 09:40:49 UTC

(groovy) branch GROOVY-12448 updated (3df51d9a1d -> 41918bed41)

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

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


 discard 3df51d9a1d GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine
     new 41918bed41 GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (3df51d9a1d)
            \
             N -- N -- N   refs/heads/GROOVY-12448 (41918bed41)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../reflection/ReflectionParameterNames.java       | 156 +++++++++++++++++++++
 .../org/codehaus/groovy/vmplugin/v8/Java8.java     |   8 ++
 .../ReflectionParameterNamesTest.groovy}           |  41 +++---
 .../console/ui/language/ConsoleLanguageHost.groovy |  43 +++++-
 .../src/spec/doc/groovy-console.adoc               |   1 +
 .../ui/language/ConsoleLanguageHostTest.groovy     |  22 ++-
 .../lsp/internal/feature/CompletionService.java    |   7 +-
 .../internal/feature/LanguageQualityTest.groovy    |   3 +
 8 files changed, 248 insertions(+), 33 deletions(-)
 create mode 100644 src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java
 copy src/test/groovy/{bugs/Groovy6764Bug.groovy => org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy} (50%)


(groovy) 01/01: GROOVY-12448: Wire Groovy Console and groovysh to the in-process language engine

Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 41918bed414adb3351065f95c17a037a75e276b8
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 +-
 .../internal/AbstractFriendlyErrorStrategy.java    |   95 +-
 .../reflection/ReflectionParameterNames.java       |  156 +++
 .../org/codehaus/groovy/vmplugin/v8/Java8.java     |    8 +
 .../parser/antlr4/ParserNegativeSyntaxTest.groovy  |   15 +
 .../reflection/ReflectionParameterNamesTest.groovy |   44 +
 subprojects/groovy-console/build.gradle            |    3 +
 .../main/groovy/groovy/console/ui/Console.groovy   |  138 ++-
 .../groovy/groovy/console/ui/ObjectBrowser.groovy  |   14 +-
 .../groovy/groovy/console/ui/ThemeManager.groovy   |    4 +-
 .../console/ui/language/ConsoleLanguageHost.groovy | 1310 ++++++++++++++++++++
 .../groovy/groovy/console/ui/language/package.html |   27 +
 .../console/ui/text/MatchingHighlighter.java       |   90 +-
 .../console/ui/text/SmartDocumentFilter.java       |  540 +++-----
 .../src/spec/doc/groovy-console.adoc               |   33 +-
 .../console/ui/ConsoleLanguageWiringTest.groovy    |  134 ++
 .../ui/language/ConsoleLanguageHostTest.groovy     |  886 +++++++++++++
 .../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    |   12 +-
 .../apache/groovy/groovysh/jline/GroovyEngine.java |   34 +-
 .../language/GroovyshLanguageSession.groovy        |  628 ++++++++++
 .../groovy/groovysh/language/package-info.groovy}  |   33 +-
 .../groovy-groovysh/src/spec/doc/groovysh.adoc     |   26 +
 .../groovy/groovysh/GroovyshEmbeddingTest.groovy   |   22 +
 .../groovysh/commands/ConsoleCommandTest.groovy    |    7 +-
 .../language/GroovyshLanguageSessionTest.groovy    |  482 +++++++
 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 |  130 +-
 .../lsp/internal/compile/CompilerSettings.java     |   65 +-
 .../lsp/internal/compile/GroovyCompiler.java       |   15 +-
 .../groovy/lsp/internal/compile/ScriptNames.java   |  143 +++
 .../lsp/internal/engine/GroovyLanguageEngine.java  |  342 ++++-
 .../lsp/internal/feature/CompletionService.java    |  938 +++++++++++++-
 .../internal/feature/SemanticTokensService.java    |  153 ++-
 .../groovy/lsp/internal/feature/SyntaxView.java    |  132 ++
 .../protocol/GroovyTextDocumentService.java        |   11 +-
 .../internal/protocol/GroovyWorkspaceService.java  |   42 +-
 .../groovy/lsp/internal/util/GroovyKeywords.java   |   36 +
 .../lsp/internal/util/GroovySourceTokens.java      |   34 +-
 .../lsp/internal/workspace/TextDocument.java       |   34 +
 .../groovy-lsp/src/spec/doc/lsp-userguide.adoc     |   64 +-
 .../groovy/lsp/GroovyLanguageClientTest.groovy     |  140 +++
 .../groovy/lsp/GroovyLanguageServerTest.groovy     |  100 ++
 .../lsp/internal/compile/ScriptNamesTest.groovy    |  124 ++
 .../internal/compile/WorkspaceLayoutTest.groovy    |   26 +
 .../engine/GroovyLanguageEngineBufferTest.groovy   |  256 ++++
 .../internal/feature/LanguageQualityTest.groovy    |  126 +-
 .../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 +
 65 files changed, 7943 insertions(+), 671 deletions(-)

diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index 0f26ad77f1..6f3878447e 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -636,7 +636,8 @@ If you embed or script with Groovy, **you** own these:
 11. **Ship only the modules you use.** `org.apache.groovy:groovy` is the
     core runtime and compiler. `groovy-all` is an aggregate that also brings
     in the interactive and developer tooling — `groovy-groovysh`,
-    `groovy-console`, `groovy-swing`, `groovy-servlet`, `groovy-jmx`,
+    `groovy-console` (and their `groovy-lsp` runtime dependency),
+    `groovy-swing`, `groovy-servlet`, `groovy-jmx`,
     `groovy-groovydoc`, `groovy-docgenerator` — and both Grape engines;
     a server-side application rarely needs any of them. This is not only
     about what an attacker can reach. A module changes behavior by being
diff --git a/config/checkstyle/gate-suppressions.xml b/config/checkstyle/gate-suppressions.xml
index 92b84ddcc8..55c3cb8ab8 100644
--- a/config/checkstyle/gate-suppressions.xml
+++ b/config/checkstyle/gate-suppressions.xml
@@ -92,5 +92,7 @@
     <!-- the language-server AstQuery is itself named AstQuery and calls org.codehaus.groovy.ast.query.AstQuery -->
     <suppress id="FullyQualifiedName" files="subprojects[/\\]groovy-lsp[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]groovy[/\\]lsp[/\\]internal[/\\]compile[/\\]AstQuery\.java$"/>
     <suppress id="FullyQualifiedNameInJavadoc" files="subprojects[/\\]groovy-lsp[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]groovy[/\\]lsp[/\\]internal[/\\]compile[/\\]AstQuery\.java$"/>
+    <!-- GroovyLanguageEngine declares its own Token record, so the ANTLR token parameter stays qualified -->
+    <suppress id="FullyQualifiedName" files="subprojects[/\\]groovy-lsp[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]groovy[/\\]lsp[/\\]internal[/\\]engine[/\\]GroovyLanguageEngine\.java$"/>
 
 </suppressions>
diff --git a/licenses/LICENSE-BINZIP b/licenses/LICENSE-BINZIP
index f23e78c49c..1d54648ec6 100644
--- a/licenses/LICENSE-BINZIP
+++ b/licenses/LICENSE-BINZIP
@@ -308,7 +308,7 @@ Eclipse LSP4J License
 
 This product bundles Eclipse LSP4J (`org.eclipse.lsp4j:org.eclipse.lsp4j`)
 and Eclipse LSP4J JSON-RPC (`org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc`)
-as binary dependencies of the optional `groovy-lsp` module.
+as binary dependencies of `groovy-lsp`, which Groovy Console and groovysh require.
 
 Those artifacts are licensed under the Eclipse Public License 2.0.
 See licenses/lsp4j-license.txt for the full license text.
diff --git a/licenses/LICENSE-SDK b/licenses/LICENSE-SDK
index c7cf037cb3..fd3ad899bd 100644
--- a/licenses/LICENSE-SDK
+++ b/licenses/LICENSE-SDK
@@ -308,7 +308,7 @@ Eclipse LSP4J License
 
 This product bundles Eclipse LSP4J (`org.eclipse.lsp4j:org.eclipse.lsp4j`)
 and Eclipse LSP4J JSON-RPC (`org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc`)
-as binary dependencies of the optional `groovy-lsp` module.
+as binary dependencies of `groovy-lsp`, which Groovy Console and groovysh require.
 
 Those artifacts are licensed under the Eclipse Public License 2.0.
 See licenses/lsp4j-license.txt for the full license text.
diff --git a/licenses/lsp4j-BINZIP.txt b/licenses/lsp4j-BINZIP.txt
index 56cc889a94..7c328d9eb0 100644
--- a/licenses/lsp4j-BINZIP.txt
+++ b/licenses/lsp4j-BINZIP.txt
@@ -2,7 +2,7 @@ Eclipse LSP4J License
 
 This product bundles Eclipse LSP4J (`org.eclipse.lsp4j:org.eclipse.lsp4j`)
 and Eclipse LSP4J JSON-RPC (`org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc`)
-as binary dependencies of the optional `groovy-lsp` module.
+as binary dependencies of `groovy-lsp`, which Groovy Console and groovysh require.
 
 Those artifacts are licensed under the Eclipse Public License 2.0.
 See licenses/lsp4j-license.txt for the full license text.
diff --git a/notices/NOTICE-BINZIP b/notices/NOTICE-BINZIP
index 1b037b97e6..2e4caab6b4 100644
--- a/notices/NOTICE-BINZIP
+++ b/notices/NOTICE-BINZIP
@@ -43,7 +43,7 @@ which are available under the terms of the Eclipse Public License v2.0
 Eclipse LSP4J
 
 This product bundles Eclipse LSP4J and Eclipse LSP4J JSON-RPC
-(org.eclipse.lsp4j) as binary dependencies of the optional groovy-lsp module.
+(org.eclipse.lsp4j) as binary dependencies of groovy-lsp, which Groovy Console and groovysh require.
 Those artifacts are licensed under the Eclipse Public License 2.0.
 See licenses/lsp4j-license.txt.
 
diff --git a/notices/NOTICE-SDK b/notices/NOTICE-SDK
index fce2cdf6ec..e7ad63ccc5 100644
--- a/notices/NOTICE-SDK
+++ b/notices/NOTICE-SDK
@@ -43,7 +43,7 @@ which are available under the terms of the Eclipse Public License v2.0
 Eclipse LSP4J
 
 This product bundles Eclipse LSP4J and Eclipse LSP4J JSON-RPC
-(org.eclipse.lsp4j) as binary dependencies of the optional groovy-lsp module.
+(org.eclipse.lsp4j) as binary dependencies of groovy-lsp, which Groovy Console and groovysh require.
 Those artifacts are licensed under the Eclipse Public License 2.0.
 See licenses/lsp4j-license.txt.
 
diff --git a/notices/lsp4j-BINZIP.txt b/notices/lsp4j-BINZIP.txt
index ebe16b2a60..83d0f9f909 100644
--- a/notices/lsp4j-BINZIP.txt
+++ b/notices/lsp4j-BINZIP.txt
@@ -1,6 +1,6 @@
 Eclipse LSP4J
 
 This product bundles Eclipse LSP4J and Eclipse LSP4J JSON-RPC
-(org.eclipse.lsp4j) as binary dependencies of the optional groovy-lsp module.
+(org.eclipse.lsp4j) as binary dependencies of groovy-lsp, which Groovy Console and groovysh require.
 Those artifacts are licensed under the Eclipse Public License 2.0.
 See licenses/lsp4j-license.txt.
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java
index 5fc90d6c68..c4a71d98e5 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java
@@ -19,6 +19,7 @@
 package org.apache.groovy.parser.antlr4.internal;
 
 import org.antlr.v4.runtime.CharStream;
+import org.antlr.v4.runtime.CommonToken;
 import org.antlr.v4.runtime.DefaultErrorStrategy;
 import org.antlr.v4.runtime.FailedPredicateException;
 import org.antlr.v4.runtime.InputMismatchException;
@@ -101,6 +102,10 @@ import static org.apache.groovy.parser.antlr4.GroovyParser.WHILE;
  * <p>
  * Two layers, on purpose: {@link MissingDelimiterDiagnostic} may <em>relocate</em>
  * the caret (insertion point after an unclosed {@code ) } / {@code ] } / {@code } }).
+ * A receiver followed by {@code ?.} / {@code *.} / {@code ??.} / {@code .&} / {@code ::}
+ * with no member name is the same kind of relocation: the operator was accepted
+ * by the lexer, and the missing name belongs at the caret after it (or on the
+ * following token), matching a trailing {@code .}.
  * Keyword / missing-receiver {@code ?[} / array-creation / unclosed type
  * argument / method-in-the-wrong-place / unmatched {@code do} / enum-constant
  * separator / sole-expected-punctuation / unexpected-EOF wording only changes
@@ -139,7 +144,13 @@ abstract class AbstractFriendlyErrorStrategy extends DefaultErrorStrategy {
                 at = hit.at;
                 message = hit.message;
             } else {
-                message = refineFallbackMessage(e, fallbackMessage);
+                DanglingMember dangling = danglingMember(e);
+                if (dangling != null) {
+                    at = dangling.at;
+                    message = dangling.message;
+                } else {
+                    message = refineFallbackMessage(e, fallbackMessage);
+                }
             }
         } catch (IndexOutOfBoundsException | IllegalArgumentException ignored) {
             message = fallbackMessage;
@@ -375,6 +386,88 @@ abstract class AbstractFriendlyErrorStrategy extends DefaultErrorStrategy {
         };
     }
 
+    /**
+     * {@code a?.} / {@code a*.} / {@code a??.} / {@code a.&} / {@code a::} with
+     * no member name. The lexer token is the operator, so ANTLR reports the
+     * mismatch on its first character ({@code ?}, {@code *}, {@code :}). The
+     * name was what failed to appear. Point at the column after the operator
+     * when the input ends there, and at the following token otherwise.
+     * A leading {@code ?.} (no receiver) keeps ANTLR's token.
+     */
+    private DanglingMember danglingMember(final RecognitionException e) {
+        if (e == null || !(e.getInputStream() instanceof TokenStream tokens)) {
+            return null;
+        }
+        Token offending = e.getOffendingToken();
+        if (offending == null || !isTrailingMemberOperator(offending.getType())) {
+            return null;
+        }
+        int index = offending.getTokenIndex();
+        if (index < 1) {
+            return null;
+        }
+        Token prev = previousDefaultChannel(tokens, index, false);
+        if (prev == null || !canEndPathExpression(prev.getType())) {
+            return null;
+        }
+        Token next = nextDefaultChannel(tokens, index, true);
+        if (next == null || next.getType() == Token.EOF || isStatementEnd(next.getType())) {
+            return new DanglingMember(caretAfter(offending), "Unexpected end of input");
+        }
+        return new DanglingMember(next, "Unexpected input: " + getTokenErrorDisplay(next));
+    }
+
+    private static boolean isTrailingMemberOperator(final int type) {
+        return switch (type) {
+            case SPREAD_DOT, SAFE_DOT, SAFE_CHAIN_DOT, METHOD_POINTER, METHOD_REFERENCE -> true;
+            default -> false;
+        };
+    }
+
+    private static boolean isStatementEnd(final int type) {
+        return switch (type) {
+            case RPAREN, RBRACK, RBRACE, SEMI -> true;
+            default -> false;
+        };
+    }
+
+    /**
+     * A zero-width caret at the first column after {@code offending}.
+     * The listener turns that column into a one-character span; past the
+     * end of the line the span collapses and nothing is painted.
+     */
+    private static Token caretAfter(final Token offending) {
+        String text = offending.getText();
+        int length = text == null ? 0 : text.length();
+        CommonToken caret = new CommonToken(offending);
+        caret.setCharPositionInLine(offending.getCharPositionInLine() + length);
+        caret.setText("");
+        return caret;
+    }
+
+    private static Token nextDefaultChannel(final TokenStream tokens, final int startExclusive,
+                                            final boolean skipNl) {
+        int n = tokens.size();
+        for (int i = startExclusive + 1; i < n; i++) {
+            Token t = tokens.get(i);
+            if (t.getChannel() != Token.DEFAULT_CHANNEL || (skipNl && t.getType() == NL)) {
+                continue;
+            }
+            return t;
+        }
+        return null;
+    }
+
+    private static final class DanglingMember {
+        final Token at;
+        final String message;
+
+        private DanglingMember(final Token at, final String message) {
+            this.at = at;
+            this.message = message;
+        }
+    }
+
     /**
      * When ANTLR's expected set is a single punctuation token, name it
      * (javac: {@code '(' expected} → {@code Missing '('}). Closers are omitted:
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..d18f973dc8
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/reflection/ReflectionParameterNames.java
@@ -0,0 +1,156 @@
+/*
+ *  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) {
+            ClassReader reader = new ClassReader(in);
+            reader.accept(new ClassVisitor(Opcodes.ASM9) {
+                @Override
+                public MethodVisitor visitMethod(final int access, final String name, final String descriptor,
+                                                  final String signature, final String[] exceptions) {
+                    boolean isStatic = (access & Opcodes.ACC_STATIC) != 0;
+                    Type[] arguments = Type.getArgumentTypes(descriptor);
+                    String[] slots = new String[arguments.length];
+                    return new MethodVisitor(Opcodes.ASM9) {
+                        @Override
+                        public void visitLocalVariable(final String variable, final String desc, final String sig,
+                                                        final Label start, final Label end, final int index) {
+                            // Javac lists parameters before locals that reuse a slot.
+                            // Label offsets are not resolved while the table is visited.
+                            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);
+                            }
+                        }
+                    };
+                }
+            }, ClassReader.SKIP_FRAMES);
+        } catch (IOException | RuntimeException ignored) {
+            return table;
+        }
+        return table;
+    }
+}
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 36710b9897..4c356b7391 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;
@@ -736,6 +737,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/apache/groovy/parser/antlr4/ParserNegativeSyntaxTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/ParserNegativeSyntaxTest.groovy
index c1f90e2c8a..875a246d79 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/ParserNegativeSyntaxTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/ParserNegativeSyntaxTest.groovy
@@ -3513,6 +3513,21 @@ final class ParserNegativeSyntaxTest {
         expectContains 'import static', 'Unexpected end of input'
     }
 
+    @Test
+    void 'trailing safe navigation points after the operator'() {
+        // The operator is one token. The missing name is after it, same as a trailing dot.
+        expectContains 'a?.', 'Unexpected end of input @ line 1, column 4.'
+        expectContains 'a*.', 'Unexpected end of input @ line 1, column 4.'
+        expectContains 'a??.', 'Unexpected end of input @ line 1, column 5.'
+        expectContains 'a.&', 'Unexpected end of input @ line 1, column 4.'
+        expectContains 'a::', 'Unexpected end of input @ line 1, column 4.'
+        expectContains 'def a = []\na?.', 'Unexpected end of input @ line 2, column 4.'
+        expectContains 'a?.\n', 'Unexpected end of input @ line 1, column 4.'
+        expectContains 'a?. + 1', "Unexpected input: '+' @ line 1, column 5."
+        expectContains '?.foo', "Unexpected input: '?.' @ line 1, column 1."
+        expectContains 'a.', 'Unexpected end of input @ line 1, column 3.'
+    }
+
     @Test
     void 'number ending with underscore'() {
         expectContainsOnce 'def n = 1_', 'Number ending with underscores is invalid'
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..f560cdb9bd
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/reflection/ReflectionParameterNamesTest.groovy
@@ -0,0 +1,44 @@
+/*
+ *  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.junit.jupiter.api.Test
+
+final class ReflectionParameterNamesTest {
+
+    @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
+    }
+}
diff --git a/subprojects/groovy-console/build.gradle b/subprojects/groovy-console/build.gradle
index 695c3f2e3c..497a9a2e49 100644
--- a/subprojects/groovy-console/build.gradle
+++ b/subprojects/groovy-console/build.gradle
@@ -29,6 +29,9 @@ dependencies {
     implementation "org.ow2.asm:asm-util:${versions.asm}"
     implementation projects.groovyGrapeIvy
     implementation projects.groovyTemplates
+    // Editor language services. implementation, not api: the engine stays
+    // internal, and LSP4J is not part of this module's published API.
+    implementation projects.groovyLsp
     runtimeOnly "org.abego.treelayout:org.abego.treelayout.core:${versions.treelayout}"
     testImplementation projects.groovyTest
     testImplementation testFixtures(projects.groovySwing)
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
index 2c4d7afcaf..66c94b4c42 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
@@ -34,14 +35,7 @@ import groovy.transform.EqualsAndHashCode
 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
@@ -66,6 +60,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.RootPaneContainer
@@ -89,10 +86,11 @@ import javax.swing.text.html.HTML
 import java.awt.BorderLayout
 import java.awt.Color
 import java.awt.Component
-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
@@ -100,7 +98,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
@@ -294,6 +291,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
     CompilerConfiguration config
     /** Shell used to compile and run scripts. */
     GroovyShell shell
+    /** In-process language host for diagnostics and completions. */
+    private ConsoleLanguageHost languageHost
     /** Counter used for generated script names. */
     int scriptNameCounter = 0
     /** Interceptor that captures stdout. */
@@ -507,6 +506,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             config.addCompilationCustomizers(new ASTTransformationCustomizer(ThreadInterrupt))
         }
         shell = new GroovyShell(parent, binding, config)
+        languageHost?.bind(shell.classLoader, config)
     }
 
     /** Default Swing delegates used to build the console frame and menu bar. */
@@ -579,9 +579,20 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         }
         installInterceptor()
         updateTitle()
+        installLanguageHost()
         swing.doLater inputArea.&requestFocus
     }
 
+    /**
+     * Attaches the in-process language host to the editor and publishes
+     * the console compiler configuration. groovy-console depends on
+     * groovy-lsp, so the host is always constructed.
+     */
+    @CompileStatic
+    private void installLanguageHost() {
+        languageHost = ConsoleLanguageHost.attach(inputArea, shell.classLoader, config)
+    }
+
     /**
      * Make the console frames capable of native fullscreen
      * for Mac OS X Lion and beyond.
@@ -954,7 +965,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             }
         }
 
-        // update SmartDocumentFilter styles (ANTLR token-based highlighting)
+        // update SmartDocumentFilter styles (groovy-lsp semantic tokens)
         SmartDocumentFilter.updateStyles()
 
         // force re-parse to apply new colors to existing text — suppress
@@ -1007,6 +1018,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         if (threadInterrupt) {
             config.addCompilationCustomizers(new ASTTransformationCustomizer(ThreadInterrupt))
         }
+        languageHost?.bind(shell.classLoader, config)
     }
 
     /** Updates cached selection bounds and the row/column display. */
@@ -1067,6 +1079,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         if (askToInterruptScript()) {
             def exit = askToSaveFile()
             if (exit) {
+                languageHost?.close()
+                languageHost = null
                 if (frame instanceof Window) {
                     frame.hide()
                     frame.dispose()
@@ -1114,6 +1128,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         nativeFullScreenForMac(swing.consoleFrame)
         swing.consoleFrame.pack()
         swing.consoleFrame.show()
+        consoleController.installLanguageHost()
         swing.doLater swing.inputArea.&requestFocus
     }
 
@@ -1145,6 +1160,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 }
@@ -1373,61 +1389,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 ?: ''))
     }
 
-    private static void applyCstColors(TreeViewer viewer) {
-        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
-    }
-
-    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
+    /** 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))
             }
         }
-        null
+        treeNode
     }
 
-    /** 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
+    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 void showComponent(String title, Component content) {
+        boolean dark = ThemeManager.isDark()
+        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
+            }
+        }
     }
 
     /**
@@ -1713,6 +1731,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             fc.selectedFiles?.each { file ->
                 shell.getClassLoader().addURL(file.toURL())
             }
+            languageHost?.bind(shell.classLoader, config)
         }
     }
 
@@ -1725,6 +1744,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             currentClasspathDir = fc.currentDirectory
             Preferences.userNodeForPackage(Console).put('currentClasspathDir', currentClasspathDir.path)
             shell.getClassLoader().addURL(fc.selectedFile.toURL())
+            languageHost?.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..6808ca5737
--- /dev/null
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/language/ConsoleLanguageHost.groovy
@@ -0,0 +1,1310 @@
+/*
+ *  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 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.Graphics
+import java.awt.Graphics2D
+import java.awt.Point
+import java.awt.Rectangle
+import java.awt.RenderingHints
+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
+    /** Matches the server cap so the popup can scroll past the first screen. */
+    private static final int MAX_COMPLETIONS = 80
+    /** 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 DefaultHighlighter.DefaultHighlightPainter(new Color(255, 180, 180))
+    private final List<Object> highlights = []
+    private final AtomicInteger generation = new AtomicInteger()
+    private final ScheduledExecutorService debounce = Executors.newSingleThreadScheduledExecutor { Runnable r ->
+        Thread thread = new Thread(r, 'groovy-console-language')
+        thread.daemon = true
+        thread
+    }
+    private volatile boolean closed
+    /** Set on Alt+/ so the following keyTyped does not insert '/' or Option+/. */
+    private boolean swallowNextTyped
+    private volatile ClassLoader parent
+    private volatile CompilerConfiguration configuration
+    /** 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
+        JScrollPane scroll = new JScrollPane(list)
+        scroll.border = null
+        scroll.focusable = false
+        scroll.verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
+        scroll.horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_NEVER
+        int width = 280
+        int rowHeight = 18
+        for (int i = 0; i < shown.size(); i++) {
+            Component rendered = list.cellRenderer.getListCellRendererComponent(list, shown.get(i), i, false, false)
+            Dimension size = rendered.preferredSize
+            width = Math.max(width, (int) size.width)
+            rowHeight = Math.max(rowHeight, (int) size.height)
+        }
+        list.fixedCellHeight = rowHeight
+        int height = rowHeight * list.visibleRowCount
+        int bar = (int) scroll.verticalScrollBar.preferredSize.width
+        scroll.preferredSize = new Dimension(Math.min(width + bar, 560), height)
+        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
+            }
+        })
+        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) {
+                int index = completionRowAt(list, event.point)
+                if (index < 0 || index == list.selectedIndex) {
+                    return
+                }
+                list.selectedIndex = index
+            }
+        }
+        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
+    }
+
+    /**
+     * 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()
+        }
+    }
+
+}
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..c799f5ad10 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,7 +34,6 @@ 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;
@@ -43,13 +41,6 @@ import java.util.ListIterator;
 import java.util.Map;
 
 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,17 @@ 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();
+    private volatile List<Tuple2<Position, Integer>> highlightedTokenInfoList = Collections.emptyList();
 
     /**
      * Creates a highlighter bound to the supplied document filter and editor.
@@ -151,28 +143,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,9 +174,9 @@ 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 = List.of(
+                        tuple(doc.createPosition(triggerToken.getOffset()), triggerToken.getLength()),
+                        tuple(doc.createPosition(matchedToken.getOffset()), matchedToken.getLength())
                 );
             } catch (BadLocationException e) {
                 e.printStackTrace();
@@ -204,7 +192,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,27 +207,23 @@ 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) {
+            for (Tuple2<Position, Integer> highlightedTokenInfo : highlightedTokenInfoList) {
+                // Delimiters keep the default style. A saved attribute set can
+                // land on the next token after the matched brace is deleted.
                 doc.setCharacterAttributes(
-                        highlightedTokenInfo.getV2().getOffset(),
-                        highlightedTokenInfo.getV3(),
-                        findStyleByTokenType(highlightedTokenInfo.getV1()),
+                        highlightedTokenInfo.getV1().getOffset(),
+                        highlightedTokenInfo.getV2(),
+                        defaultStyle,
                         true
                 );
             }
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/SmartDocumentFilter.java b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/text/SmartDocumentFilter.java
index 9d639328ad..947f739920 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,56 @@ 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;
-
 
 /**
- * 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 +94,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 +103,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 +114,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 +125,136 @@ public class SmartDocumentFilter extends DocumentFilter {
     public void replace(DocumentFilter.FilterBypass fb, int offset,
                         int length, String text, AttributeSet attrs)
             throws BadLocationException {
-
-        // text might be null and indicates no replacement text
-        if (text == null) text = "";
-
-        // remove problem meta characters returns
+        if (text == null) {
+            text = "";
+        }
         text = replaceMetaCharacters(text);
-
         fb.replace(offset, length, text, attrs);
-
         parseDocument();
     }
 
     private String replaceMetaCharacters(String string) {
-        // just in case remove carriage returns
-        string = string.replace("\r\n", "\n");
-        return string;
+        return string.replace("\r\n", "\n");
     }
 
     private void parseDocument() throws BadLocationException {
-        GroovyLangLexer lexer;
-        try {
-            lexer = createLexer(styledDocument.getText(0, styledDocument.getLength()));
-        } catch (IOException e) {
-            e.printStackTrace();
-            this.latest = false;
-            return;
-        }
-
-        CommonTokenStream tokenStream = new CommonTokenStream(lexer);
-
-        try {
-            tokenStream.fill();
-        } catch (LexerNoViableAltException | GroovySyntaxError e) {
-            // ignore
-            this.latest = false;
-            return;
-        } catch (Exception e) {
-            e.printStackTrace();
-            this.latest = false;
-            return;
-        }
-
-        List<Token> tokenList = tokenStream.getTokens();
-        List<Token> tokenListToRender;
+        String text = styledDocument.getText(0, styledDocument.getLength());
+        List<Integer> data = language.semanticTokens(text);
+        int[] dirty = dirtyRange(text);
         try {
-            tokenListToRender = findTokensToRender(tokenList);
+            this.latestTokenList = 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();
+    private int[] dirtyRange(String text) {
+        int last = text.length() - 1;
+        if (last < 0) {
+            return new int[] {0, -1};
+        }
+        Tuple2<Integer, Integer> range = getRenderRange();
+        if (range == null) {
+            return new int[] {0, last};
+        }
+        int start = range.getV1();
+        int stop = range.getV2();
+        if (start < 0 || stop < 0) {
+            return new int[] {0, last};
+        }
+        int from = Math.max(0, Math.min(start, stop));
+        int to = Math.min(last, Math.max(start, stop));
+        return new int[] {from, to};
+    }
 
-            if (EOF == tokenType) {
+    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) {
                 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;
+            int type = data.get(i + 3);
+            if (type == TYPE_DELIMITER) {
+                delimiters.add(new Delimiter(start, end - start, text.substring(start, end)));
+                continue;
             }
-
-            styledDocument.setCharacterAttributes(tokenStartIndex,
-                    tokenLength,
-                    isAnnotationPart ? findStyleByTokenType(AT) : findStyleByTokenType(tokenType),
-                    true);
-
-            if (GStringBegin == tokenType || GStringPart == tokenType) {
-                styledDocument.setCharacterAttributes(
-                        tokenStartIndex + tokenLength - 1,
-                        1,
-                        defaultStyle,
-                        true);
+            if (end > dirtyStart && start <= dirtyStop) {
+                Style style = styleFor(type, data.get(i + 4));
+                styledDocument.setCharacterAttributes(start, end - start, style, true);
             }
         }
-
-        this.latestTokenList = tokenList;
-        this.latest = true;
+        return delimiters;
     }
 
-    private List<Token> findTokensToRender(List<Token> tokenList) {
-        final Tuple2<Integer, Integer> renderRange = getRenderRange();
-        if (null != renderRange) {
-            long startLine = lineNumber(renderRange.getV1());
-            long stopLine = lineNumber(renderRange.getV2());
-
-            if (startLine < 0 || stopLine < 0) return tokenList; // should never happen
-
-            return tokenList.stream()
-                    .filter(e -> e.getLine() >= startLine && PositionConfigureUtils.endPosition(e).getV1() <= stopLine)
-                    .collect(Collectors.toList());
+    private Style styleFor(int type, int modifiers) {
+        if (type == TYPE_KEYWORD) {
+            return styleNamed(STYLE_RESERVED);
         }
-
-        List<Token> tmpLatestTokenList = filterNewlines(this.latestTokenList);
-        int latestTokenListSize = tmpLatestTokenList.size();
-        if (0 == latestTokenListSize) {
-            return tokenList;
+        if (type == TYPE_STRING) {
+            if ((modifiers & MOD_SLASHY) != 0) {
+                return styleNamed(STYLE_SLASHY_QUOTES);
+            }
+            if ((modifiers & MOD_SINGLE) != 0) {
+                return styleNamed(STYLE_SINGLE_QUOTES);
+            }
+            return styleNamed(STYLE_QUOTES);
         }
-
-        List<Token> tmpTokenList = filterNewlines(tokenList);
-        int tmpTokenListSize = tmpTokenList.size();
-        if (0 == tmpTokenListSize) {
-            return tokenList;
+        if (type == TYPE_NUMBER) {
+            return styleNamed(STYLE_DIGIT);
         }
-
-        int startTokenIndex = 0;
-        int minSize = Math.min(tmpTokenListSize, latestTokenListSize);
-        for (int i = 0; i < minSize; i++) {
-            Token token = tmpTokenList.get(i);
-            Token latestToken = tmpLatestTokenList.get(i);
-
-            if (token.getType() == latestToken.getType()
-                    && token.getStartIndex() == latestToken.getStartIndex()
-                    && token.getStopIndex() == latestToken.getStopIndex()) {
-                continue;
-            }
-
-            startTokenIndex = i;
-            break;
+        if (type == TYPE_COMMENT) {
+            return styleNamed(STYLE_COMMENT);
         }
-
-        List<Token> newTokenList = new ReversedList<>(tmpTokenList);
-        List<Token> newLatestTokenList = new ReversedList<>(tmpLatestTokenList);
-
-        int stopTokenIndex = tmpTokenListSize;
-
-        Token lastToken = newTokenList.get(0);
-        Token lastLatestToken = newLatestTokenList.get(0);
-
-        for (int i = 0; i < minSize; i++) {
-            Token token = newTokenList.get(i);
-            Token latestToken = newLatestTokenList.get(i);
-
-            if ((token.getType() == latestToken.getType())
-                    && (token.getStartIndex() - lastToken.getStartIndex()) == (latestToken.getStartIndex() - lastLatestToken.getStartIndex())
-                    && ((token.getStopIndex() - lastToken.getStopIndex()) == (latestToken.getStopIndex() - lastLatestToken.getStopIndex()))) {
-                continue;
-            }
-
-            stopTokenIndex = tmpTokenListSize - i;
-            break;
+        if (type == TYPE_DECORATOR) {
+            return styleNamed(STYLE_ANNOTATION);
         }
-
-        if (startTokenIndex == stopTokenIndex) {
-            return tmpTokenListSize != latestTokenListSize ? tokenList : Collections.emptyList();
-        } else if (startTokenIndex < stopTokenIndex) {
-            return tmpTokenList.subList(startTokenIndex, stopTokenIndex);
+        if (type == TYPE_OPERATOR) {
+            return styleNamed(STYLE_OPERATION);
         }
-
-        return tokenList; // should never reach here. If unexpected error occurred, it's better to render all tokens
+        return styleNamed(STYLE_IDENT);
     }
 
-    /**
-     * Returns line number starting with 1
-     */
-    private long lineNumber(int position) {
-        long result = -1;
-
-        if (position < 0) return result;
-
-        try {
-            String text = this.styledDocument.getText(0, position + 1);
-            result = StringUtils.countChar(text, '\n') + 1;
-        } catch (BadLocationException e) {
-            e.printStackTrace(System.err);
+    private static 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 result;
+        return Math.min(i + Math.max(character, 0), text.length());
     }
 
-    private List<Token> filterNewlines(List<Token> tokenList) {
-        return tokenList.stream()
-                .filter(e -> !(NL == e.getType() && e.getText().trim().isEmpty()))
-                .collect(Collectors.toList());
-    }
-
-    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 +266,7 @@ public class SmartDocumentFilter extends DocumentFilter {
      * @since 6.0.0
      */
     public void reparseDocument() {
-        this.latestTokenList = Collections.emptyList();
+        this.latestTokenList = List.of();
         try {
             parseDocument();
         } catch (BadLocationException e) {
@@ -446,48 +277,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 +307,26 @@ public class SmartDocumentFilter extends DocumentFilter {
         style.removeAttribute(StyleConstants.Underline);
         Map attrs = ThemeManager.getStyleAttrs(themeKey);
         Object fg = attrs.get("foreground");
-        if (fg instanceof Color)  StyleConstants.setForeground(style, (Color) fg);
+        if (fg instanceof Color) {
+            StyleConstants.setForeground(style, (Color) fg);
+        }
         Object bg = attrs.get("background");
-        if (bg instanceof Color)  StyleConstants.setBackground(style, (Color) bg);
-        if (Boolean.TRUE.equals(attrs.get("bold")))      StyleConstants.setBold(style, true);
-        if (Boolean.TRUE.equals(attrs.get("italic")))    StyleConstants.setItalic(style, true);
-        if (Boolean.TRUE.equals(attrs.get("underline"))) StyleConstants.setUnderline(style, true);
+        if (bg instanceof Color) {
+            StyleConstants.setBackground(style, (Color) bg);
+        }
+        if (Boolean.TRUE.equals(attrs.get("bold"))) {
+            StyleConstants.setBold(style, true);
+        }
+        if (Boolean.TRUE.equals(attrs.get("italic"))) {
+            StyleConstants.setItalic(style, true);
+        }
+        if (Boolean.TRUE.equals(attrs.get("underline"))) {
+            StyleConstants.setUnderline(style, true);
+        }
     }
 
     private volatile boolean latest = false;
-    private volatile List<Token> latestTokenList = Collections.emptyList();
+    private volatile List<Delimiter> latestTokenList = List.of();
     private volatile Tuple2<Integer, Integer> renderRange;
 
     /**
@@ -517,11 +339,12 @@ 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() {
+    public List<Delimiter> getLatestTokenList() {
         return latestTokenList;
     }
 
@@ -542,4 +365,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 e8b19811b8..49ec9c421a 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -161,12 +161,43 @@ 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
 * Error hyperlinking from the output area when a compilation error is
 expected or when an exception is thrown
+* The editor compiles the unsaved buffer in the background and highlights
+compiler diagnostics. 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
@@ -258,7 +289,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..0512543ebe
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleLanguageWiringTest.groovy
@@ -0,0 +1,134 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.console.ui
+
+import groovy.inspect.Inspector
+import groovy.swing.SwingBuilder
+import org.junit.jupiter.api.Test
+
+import javax.swing.AbstractAction
+import javax.swing.JCheckBoxMenuItem
+import javax.swing.JFileChooser
+import javax.swing.JLayeredPane
+import javax.swing.JRootPane
+import javax.swing.JTextPane
+import javax.swing.RootPaneContainer
+import javax.swing.text.DefaultStyledDocument
+import java.awt.Component
+import java.awt.Container
+import java.awt.HeadlessException
+import java.awt.event.ActionEvent
+
+class ConsoleLanguageWiringTest {
+
+    @Test
+    void consolePublishesTheLanguageHostOnShellClasspathAndExit() {
+        Console console = new Console()
+        def box = new JCheckBoxMenuItem()
+        box.selected = true
+        console.threadInterruption(new ActionEvent(box, 0, 'interrupt'))
+
+        console.inputArea = new JTextPane()
+        console.inputArea.document = new DefaultStyledDocument()
+        console.outputArea = new JTextPane()
+        console.saveAction = new AbstractAction() {
+            @Override void actionPerformed(ActionEvent e) { }
+        }
+        def editor = Console.getDeclaredField('inputEditor')
+        editor.accessible = true
+        def unsafeField = Class.forName('sun.misc.Unsafe').getDeclaredField('theUnsafe')
+        unsafeField.accessible = true
+        def unsafe = unsafeField.get(null)
+        def allocate = unsafe.class.getMethod('allocateInstance', Class)
+        def allocated = allocate.invoke(unsafe, ConsoleTextEditor)
+        allocated.metaClass.repaint = { -> }
+        editor.set(console, allocated)
+        console.frame = new FakeFrame()
+        console.swing = new SwingBuilder()
+        def install = Console.getDeclaredMethod('installLanguageHost')
+        install.accessible = true
+        install.invoke(console)
+        console.newScript(console.shell.classLoader.parent, console.shell.context)
+        box.selected = false
+        console.threadInterruption(new ActionEvent(box, 0, 'interrupt'))
+        box.selected = true
+        console.threadInterruption(new ActionEvent(box, 0, 'interrupt'))
+
+        def file = File.createTempFile('console-host', '.groovy')
+        file.deleteOnExit()
+        file.text = 'int kept = 1\n'
+        console.loadScriptFile(file)
+        assert console.inputArea.text.contains('kept')
+        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.
+        }
+    }
+
+    @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..663fa78971
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/language/ConsoleLanguageHostTest.groovy
@@ -0,0 +1,886 @@
+/*
+ *  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.JScrollPane
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+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.Highlighter
+import java.awt.BorderLayout
+import java.awt.Color
+import java.awt.Component
+import java.awt.Container
+import java.awt.Point
+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 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 unfinishedMemberOperatorDoesNotPaintTheOperator() {
+        ['a?.', 'a*.', 'a??.', 'a.&', 'a::', 'def a = []\na?.', 'a?.\n', 'a?. + 1'].each { source ->
+            def pane = new JTextPane()
+            pane.text = source
+            def host = ConsoleLanguageHost.attach(pane, ConsoleLanguageHostTest.classLoader)
+            try {
+                host.refresh()
+                String mark = ['??.', '?.', '*.', '.&', '::'].find { source.contains(it) }
+                int at = source.indexOf(mark)
+                assert pane.highlighter.highlights.every { highlight ->
+                    highlight.startOffset >= at + mark.length() || highlight.endOffset <= at
+                }
+                if (!source.contains('+')) {
+                    assert pane.toolTipText?.contains('Unexpected end of input')
+                }
+            } 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 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()
+        }
+    }
+}
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..8058c0ec8f 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,9 @@ 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)
+                Class.forName('groovy.console.ui.ObjectBrowser')
+                        .getMethod('inspect', Object)
+                        .invoke(null, obj)
             } 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..8c407f8d71 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
@@ -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..c0a9e787c9 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -38,6 +38,32 @@ 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 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..053083f099 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
@@ -29,10 +29,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()
     }
 
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..4f3c4ff98d
--- /dev/null
+++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/language/GroovyshLanguageSessionTest.groovy
@@ -0,0 +1,482 @@
+/*
+ *  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.ParsedLine
+import org.jline.utils.AttributedString
+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'
+    }
+
+    @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 b890205486..804c226e09 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
@@ -31,6 +31,8 @@ import org.apache.groovy.lsp.internal.protocol.ClientFeatures;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.DocumentStore;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.control.customizers.CompilationCustomizer;
 
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
 import org.eclipse.lsp4j.WorkspaceEdit;
@@ -45,6 +47,8 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either;
 import org.eclipse.lsp4j.services.LanguageClient;
 
 import java.net.URI;
+import java.net.URL;
+import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -53,6 +57,7 @@ import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
@@ -70,6 +75,8 @@ public final class LanguageServerContext {
     private final WorkspaceScanner scanner = new WorkspaceScanner();
     private final DiagnosticConverter diagnostics = new DiagnosticConverter();
     private final Object compileLock = new Object();
+    /** Serializes diagnostic notifications so an older snapshot cannot overwrite a newer one. */
+    private final Object publishLock = new Object();
 
     private final AtomicReference<LanguageClient> client = new AtomicReference<>();
     private final AtomicReference<ClientCapabilities> clientCapabilities = new AtomicReference<>();
@@ -77,6 +84,7 @@ public final class LanguageServerContext {
     private final AtomicReference<CompilerSettings> settings = new AtomicReference<>(CompilerSettings.defaults());
     private final AtomicReference<CompilationSnapshot> snapshot = new AtomicReference<>(CompilationSnapshot.EMPTY);
     private final AtomicReference<ClassLoader> parentLoader = new AtomicReference<>();
+    private final AtomicReference<CompilerConfiguration> hostConfiguration = new AtomicReference<>();
     private volatile boolean initialized;
     private volatile boolean shutdown;
     private volatile Integer exitCode;
@@ -90,6 +98,9 @@ public final class LanguageServerContext {
     });
     private ScheduledFuture<?> pendingCompile;
     private long compileGeneration;
+    /** Generation of the snapshot currently stored. Publishes at or below {@link #publishedGeneration} are dropped. */
+    private long snapshotGeneration;
+    private long publishedGeneration = -1;
     private String compileFingerprint;
 
     /**
@@ -128,7 +139,7 @@ public final class LanguageServerContext {
                 extra = scanner.scan(folders, effective.getSourcePaths(), effective.getIgnoredDirectories());
                 open = documents.snapshot();
                 loader = compileParentLoader();
-                fingerprint = fingerprint(open, extra, effective, loader);
+                fingerprint = fingerprint(open, extra, effective, loader, hostConfiguration.get());
                 if (fingerprint.equals(compileFingerprint) && snapshot.get() != CompilationSnapshot.EMPTY) {
                     // Another compile already committed this snapshot but may
                     // not have published yet. Fall through to publishDiagnostics.
@@ -144,7 +155,7 @@ public final class LanguageServerContext {
                 beginProgress(token, "Compiling Groovy");
                 CompilationSnapshot compiled;
                 try {
-                    compiled = compiler.compile(open, extra, effective, loader);
+                    compiled = compiler.compile(open, extra, effective, loader, hostConfiguration.get());
                 } finally {
                     endProgress(token);
                 }
@@ -152,10 +163,13 @@ public final class LanguageServerContext {
                     if (generation == compileGeneration) {
                         compileFingerprint = fingerprint;
                         snapshot.set(compiled);
+                        snapshotGeneration = generation;
                     }
                 }
             }
         }
+        // A superseded compile still publishes whatever snapshot is current.
+        // publishDiagnostics drops that call when a newer snapshot was already sent.
         publishDiagnostics();
         return snapshot.get();
     }
@@ -169,6 +183,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);
         }
@@ -178,32 +197,46 @@ public final class LanguageServerContext {
     /**
      * Publishes diagnostics for every compiled document and clears
      * diagnostics for URIs that dropped out of the snapshot.
+     * Notifications are sent in snapshot order. A publish that observed
+     * an older snapshot does not overwrite a newer one, which is what
+     * happens when a debounced compile finishes after a later compile.
      */
     public void publishDiagnostics() {
         LanguageClient languageClient = client.get();
         if (languageClient == null) {
             return;
         }
-        List<CompiledDocument> documents;
-        Set<URI> dropped;
+        final long generation;
+        final CompilationSnapshot committed;
         synchronized (this) {
-            documents = List.copyOf(snapshot.get().documents());
+            generation = snapshotGeneration;
+            committed = snapshot.get();
+        }
+        if (committed == null) {
+            return;
+        }
+        synchronized (publishLock) {
+            if (generation <= publishedGeneration) {
+                return;
+            }
+            List<CompiledDocument> documents = List.copyOf(committed.documents());
             Set<URI> currentUris = new LinkedHashSet<>();
             for (CompiledDocument document : documents) {
                 if (document.getUri() != null) {
                     currentUris.add(document.getUri());
                 }
             }
-            dropped = new LinkedHashSet<>(publishedDiagnosticUris);
+            Set<URI> dropped = new LinkedHashSet<>(publishedDiagnosticUris);
             dropped.removeAll(currentUris);
             publishedDiagnosticUris.clear();
             publishedDiagnosticUris.addAll(currentUris);
-        }
-        for (URI uri : dropped) {
-            languageClient.publishDiagnostics(new PublishDiagnosticsParams(uri.toString(), List.of()));
-        }
-        for (CompiledDocument document : documents) {
-            languageClient.publishDiagnostics(paramsFor(document));
+            publishedGeneration = generation;
+            for (URI uri : dropped) {
+                languageClient.publishDiagnostics(new PublishDiagnosticsParams(uri.toString(), List.of()));
+            }
+            for (CompiledDocument document : documents) {
+                languageClient.publishDiagnostics(paramsFor(document));
+            }
         }
     }
 
@@ -401,8 +434,9 @@ public final class LanguageServerContext {
 
     /**
      * Parent loader for the next compile. {@code null} uses this class's loader.
-     * The compile fingerprint is cleared only when the loader identity changes,
-     * so setting the same loader again does not force a recompile.
+     * The compile fingerprint is cleared when the loader identity changes.
+     * In-place {@code URLClassLoader#addURL} is visible on the next compile
+     * because the fingerprint includes that loader's URLs.
      *
      * @param parentLoader parent loader, or {@code null}
      */
@@ -420,6 +454,27 @@ public final class LanguageServerContext {
         return parentLoader.get();
     }
 
+    /**
+     * Compiler configuration from Console or groovysh. {@code null} uses
+     * a fresh tooling configuration. Every call drops the cached compile,
+     * including a repeat with the same instance, so an in-place customizer
+     * edit is visible on the next compile. Callers that query in a loop
+     * invoke this only when the configuration was replaced or edited.
+     *
+     * @param configuration host configuration, or {@code null}
+     */
+    public synchronized void setHostConfiguration(final CompilerConfiguration configuration) {
+        this.hostConfiguration.set(configuration);
+        compileFingerprint = null;
+    }
+
+    /**
+     * @return the host configuration, or {@code null}
+     */
+    public synchronized CompilerConfiguration getHostConfiguration() {
+        return hostConfiguration.get();
+    }
+
     private ClassLoader compileParentLoader() {
         ClassLoader loader = parentLoader.get();
         return loader == null ? getClass().getClassLoader() : loader;
@@ -497,8 +552,9 @@ public final class LanguageServerContext {
     }
 
     /**
-     * Stable key including the parent loader identity. In-place
-     * {@code addURL} on the same loader still needs {@code extra}.
+     * Stable key including the parent loader identity and, for a
+     * {@link URLClassLoader}, its URL list so in-place {@code addURL} is
+     * visible.
      *
      * @param open open documents
      * @param extra workspace files
@@ -508,6 +564,23 @@ public final class LanguageServerContext {
      */
     public static String fingerprint(final Collection<TextDocument> open, final Collection<Path> extra,
                                      final CompilerSettings settings, final ClassLoader parentLoader) {
+        return fingerprint(open, extra, settings, parentLoader, null);
+    }
+
+    /**
+     * Stable key of the settings, parent-loader identity,
+     * {@code URLClassLoader} URLs, host {@link CompilerConfiguration}
+     * identity, script base class, and each compilation customizer's class
+     * and identity. Editing a customizer in place does not change this key.
+     * {@link #setHostConfiguration} drops the cached compile so that edit
+     * is picked up.
+     *
+     * @param hostConfiguration console or groovysh configuration, or {@code null}
+     * @return a fingerprint
+     */
+    public static String fingerprint(final Collection<TextDocument> open, final Collection<Path> extra,
+                                     final CompilerSettings settings, final ClassLoader parentLoader,
+                                     final CompilerConfiguration hostConfiguration) {
         StringBuilder builder = new StringBuilder();
         builder.append(settings.getClasspath()).append('|')
                 .append(settings.getSourcePaths()).append('|')
@@ -516,7 +589,16 @@ public final class LanguageServerContext {
                 .append(settings.isAstTestEnabled()).append('|')
                 .append(settings.getThroughPhase()).append('|')
                 .append(settings.getExtra()).append('|')
-                .append(System.identityHashCode(parentLoader));
+                .append(System.identityHashCode(parentLoader)).append('|');
+        appendHostConfiguration(builder, hostConfiguration);
+        if (parentLoader instanceof URLClassLoader urls) {
+            URL[] listed = urls.getURLs();
+            if (listed != null) {
+                for (URL url : listed) {
+                    builder.append('|').append(url == null ? "" : url.toExternalForm());
+                }
+            }
+        }
         if (open != null) {
             for (TextDocument document : open) {
                 builder.append('\n').append(document.getUri()).append(':')
@@ -539,4 +621,18 @@ public final class LanguageServerContext {
         }
         return builder.toString();
     }
+
+    private static void appendHostConfiguration(final StringBuilder builder,
+                                                final CompilerConfiguration host) {
+        if (host == null) {
+            builder.append('0');
+            return;
+        }
+        builder.append(System.identityHashCode(host)).append('|')
+                .append(Objects.toString(host.getScriptBaseClass(), ""));
+        for (CompilationCustomizer customizer : host.getCompilationCustomizers()) {
+            builder.append('|').append(customizer.getClass().getName())
+                    .append('@').append(System.identityHashCode(customizer));
+        }
+    }
 }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
index 2c60e10ca4..e2885ad3cb 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;
@@ -34,7 +36,6 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 
-
 /**
  * Compilation options for the language server. Grape and {@code @ASTTest}
  * are off by default so opening a workspace does not execute or download
@@ -241,7 +242,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 +281,50 @@ 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();
+        if (imports != null) {
+            for (String name : imports.split(",")) {
+                String trimmed = name.trim();
+                if (!trimmed.isEmpty()) {
+                    customizer.addImports(trimmed);
+                }
+            }
+        }
+        if (stars != null) {
+            for (String name : stars.split(",")) {
+                String trimmed = name.trim();
+                if (!trimmed.isEmpty()) {
+                    customizer.addStarImports(trimmed);
+                }
+            }
+        }
+        configuration.addCompilationCustomizers(customizer);
+    }
+
     /**
      * 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..054f55446f
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ScriptNames.java
@@ -0,0 +1,143 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.lsp.internal.compile;
+
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.ModuleNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.LambdaExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.syntax.Types;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Script-level names a REPL host should not redeclare. A non-script class
+ * contributes its simple name when that name is non-empty. A script
+ * contributes methods other than the generated no-arg {@code run} and a
+ * static {@code main} whose single parameter is an array, plus names
+ * assigned in the {@code run} body. The walk enters {@code if} and
+ * {@code try}, and a tuple on the left of an assignment. It does not
+ * enter a closure or a lambda. An empty name is skipped.
+ */
+public final class ScriptNames {
+
+    private ScriptNames() {
+    }
+
+    /**
+     * @param module compiled module, or {@code null}
+     * @return names, never {@code null}
+     */
+    public static Set<String> of(final ModuleNode module) {
+        if (module == null) {
+            return Set.of();
+        }
+        Set<String> names = new LinkedHashSet<>();
+        for (ClassNode classNode : module.getClasses()) {
+            if (classNode.isScript()) {
+                for (MethodNode method : classNode.getMethods()) {
+                    if (scriptLevelMethod(method)) {
+                        names.add(method.getName());
+                    }
+                }
+                addScriptAssignments(classNode, names);
+            } else {
+                String simple = classNode.getNameWithoutPackage();
+                if (simple != null && !simple.isEmpty()) {
+                    names.add(simple);
+                }
+            }
+        }
+        return names;
+    }
+
+    static boolean scriptLevelMethod(final MethodNode method) {
+        if (method == null || method.isSynthetic()) {
+            return false;
+        }
+        String name = method.getName();
+        if (name == null || name.isEmpty() || "<init>".equals(name) || "<clinit>".equals(name)) {
+            return false;
+        }
+        if ("run".equals(name) && method.getParameters().length == 0) {
+            return false;
+        }
+        if ("main".equals(name) && method.isStatic()) {
+            Parameter[] parameters = method.getParameters();
+            if (parameters.length == 1 && parameters[0].getType() != null && parameters[0].getType().isArray()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    static void addScriptAssignments(final ClassNode script, final Set<String> names) {
+        if (script == null || names == null) {
+            return;
+        }
+        MethodNode run = script.getMethod("run", Parameter.EMPTY_ARRAY);
+        if (run == null || run.getCode() == null) {
+            return;
+        }
+        run.getCode().visit(new CodeVisitorSupport() {
+            @Override
+            public void visitClosureExpression(final ClosureExpression expression) {
+            }
+
+            @Override
+            public void visitLambdaExpression(final LambdaExpression expression) {
+            }
+
+            @Override
+            public void visitDeclarationExpression(final DeclarationExpression expression) {
+                addLeftNames(expression.getLeftExpression(), names);
+            }
+
+            @Override
+            public void visitBinaryExpression(final BinaryExpression expression) {
+                if (expression.getOperation() != null && expression.getOperation().getType() == Types.ASSIGN) {
+                    addLeftNames(expression.getLeftExpression(), names);
+                }
+                super.visitBinaryExpression(expression);
+            }
+        });
+    }
+
+    private static void addLeftNames(final Expression left, final Set<String> names) {
+        if (left instanceof VariableExpression variable) {
+            String name = variable.getName();
+            if (name != null && !name.isEmpty()) {
+                names.add(name);
+            }
+        } else if (left instanceof TupleExpression tuple) {
+            for (Expression element : tuple.getExpressions()) {
+                addLeftNames(element, names);
+            }
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java
index 37c845c896..f44bde8a27 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngine.java
@@ -21,13 +21,17 @@ package org.apache.groovy.lsp.internal.engine;
 import org.apache.groovy.lsp.internal.LanguageServerContext;
 import org.apache.groovy.lsp.internal.compile.CompiledDocument;
 import org.apache.groovy.lsp.internal.compile.CompilerSettings;
+import org.apache.groovy.lsp.internal.compile.ScriptNames;
 import org.apache.groovy.lsp.internal.diagnostic.DiagnosticConverter;
 import org.apache.groovy.lsp.internal.feature.LanguageFeatures;
 import org.apache.groovy.lsp.internal.feature.SemanticTokensService;
 import org.apache.groovy.lsp.internal.position.PositionEncoding;
 import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.GroovyKeywords;
+import org.apache.groovy.lsp.internal.util.GroovySourceTokens;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.control.CompilerConfiguration;
 import org.eclipse.lsp4j.CompletionItem;
 import org.eclipse.lsp4j.CompletionList;
 import org.eclipse.lsp4j.Diagnostic;
@@ -55,6 +59,8 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
  * In-process facade over the same compile snapshot the LSP handlers use.
@@ -64,11 +70,25 @@ import java.util.Objects;
  * A non-{@code file:} URI keeps the compiler from treating the buffer as
  * a path. The engine does not scan workspace folders and does not reserve
  * buffer names.
+ * <p>
+ * Query methods that take the buffer text replace it first. The same text
+ * keeps the document version, so the snapshot can be reused.
+ * {@link #setHostConfiguration} drops that cache on every call. Hosts
+ * invoke it when the configuration is replaced or edited, not before
+ * every query. {@link #lexicalTokens(String)} is the live highlighter:
+ * no compile, safe on the Swing EDT. {@link #tokens(URI)} needs a
+ * compiled snapshot.
  */
 public final class GroovyLanguageEngine implements AutoCloseable {
 
     private static final int SNIPPET_CONTEXT_LINES = 3;
     private static final String LANGUAGE_GROOVY = "groovy";
+    private static final Pattern SNIPPET_PLACEHOLDER = Pattern.compile("\\$\\{\\d+:([^}]*)}");
+    private static final Pattern SNIPPET_TABSTOP = Pattern.compile("\\$\\d+");
+    /**
+     * Semantic-token legend names. Same list the protocol advertises.
+     */
+    public static final List<String> TOKEN_TYPES = SemanticTokensService.TOKEN_TYPES;
 
     private final LanguageServerContext context;
     private final LanguageFeatures features;
@@ -100,7 +120,7 @@ public final class GroovyLanguageEngine implements AutoCloseable {
     /**
      * @return the session this engine drives
      */
-    public LanguageServerContext context() {
+    LanguageServerContext context() {
         return context;
     }
 
@@ -150,21 +170,25 @@ public final class GroovyLanguageEngine implements AutoCloseable {
     }
 
     /**
-     * Replaces buffer text without compiling. The next query compiles when
-     * the open version does not match the snapshot; {@link #compileNow()}
-     * and {@link #scheduleCompile(long)} remain available for callers that
-     * debounce.
+     * Replaces buffer text without compiling. The same text keeps the
+     * current version, so the next query can reuse the snapshot.
+     * {@link #compileNow()} and {@link #scheduleCompile(long)} remain
+     * available for callers that debounce.
      *
      * @param uri buffer identity
-     * @param text source text
+     * @param text source text, or {@code null} for empty
      */
     public void updateBuffer(final URI uri, final String text) {
         URI normalized = requireUri(uri);
         TextDocument existing = context.getDocuments().get(normalized);
+        String next = text == null ? "" : text;
+        if (existing != null && next.equals(existing.getText())) {
+            return;
+        }
         int version = existing == null ? 1 : existing.getVersion() + 1;
-        String language = existing == null || existing.getLanguageId() == null ? LANGUAGE_GROOVY : existing.getLanguageId();
-        context.getDocuments().open(new TextDocumentItem(normalized.toString(), language, version,
-                text == null ? "" : text));
+        String language = existing == null || existing.getLanguageId() == null || existing.getLanguageId().isEmpty()
+                ? LANGUAGE_GROOVY : existing.getLanguageId();
+        context.getDocuments().open(new TextDocumentItem(normalized.toString(), language, version, next));
     }
 
     /**
@@ -197,7 +221,9 @@ public final class GroovyLanguageEngine implements AutoCloseable {
     }
 
     /**
-     * Parent loader for the next compile.
+     * Parent loader for the next compile. A different loader drops the
+     * cached compile. An in-place {@code URLClassLoader#addURL} stays
+     * visible because the fingerprint includes that loader's URLs.
      *
      * @param parentLoader loader, or {@code null} for the session default
      */
@@ -205,6 +231,19 @@ public final class GroovyLanguageEngine implements AutoCloseable {
         context.setParentLoader(parentLoader);
     }
 
+    /**
+     * Compiler configuration from Console or groovysh. Import customizers
+     * and AST transforms are copied into the tooling compile. Every call
+     * drops the cached compile, including a repeat with the same instance,
+     * so an in-place customizer edit is visible on the next query. Do not
+     * call this before every query.
+     *
+     * @param configuration host configuration, or {@code null}
+     */
+    public void setHostConfiguration(final CompilerConfiguration configuration) {
+        context.setHostConfiguration(configuration);
+    }
+
     /**
      * Replaces the compile classpath. Empty or {@code null} clears it.
      *
@@ -215,9 +254,9 @@ public final class GroovyLanguageEngine implements AutoCloseable {
     }
 
     /**
-     * Stores an opaque extra setting so the compile fingerprint changes
-     * after a parent loader is mutated in place. Loader identity alone
-     * does not see those mutations.
+     * Stores an opaque extra setting that participates in the compile
+     * fingerprint. In-place {@code addURL} is already visible through
+     * the loader URL list.
      *
      * @param key setting name
      * @param value setting value, or {@code null} to remove
@@ -254,6 +293,47 @@ public final class GroovyLanguageEngine implements AutoCloseable {
                 .toList();
     }
 
+    /**
+     * Replaces the buffer text, then returns diagnostics.
+     *
+     * @param uri buffer identity
+     * @param text source text
+     * @return diagnostics, never {@code null}
+     */
+    public List<Hit> diagnostics(final URI uri, final String text) {
+        updateBuffer(uri, text);
+        return diagnostics(uri);
+    }
+
+    /**
+     * Script-level names the compiler already introduced in an open buffer.
+     * See {@link ScriptNames}. REPL hosts use this so binding declarations
+     * are not duplicated.
+     *
+     * @param uri buffer identity
+     * @return names, never {@code null}
+     */
+    public Set<String> declaredNames(final URI uri) {
+        TextDocument document = documentAt(uri);
+        CompiledDocument compiled = context.getSnapshot().get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return Set.of();
+        }
+        return ScriptNames.of(compiled.getModule());
+    }
+
+    /**
+     * Replaces the buffer text, then returns script-level names.
+     *
+     * @param uri buffer identity
+     * @param text source text
+     * @return names, never {@code null}
+     */
+    public Set<String> declaredNames(final URI uri, final String text) {
+        updateBuffer(uri, text);
+        return declaredNames(uri);
+    }
+
     /**
      * Hover markdown for an open buffer.
      *
@@ -268,6 +348,20 @@ public final class GroovyLanguageEngine implements AutoCloseable {
                 new Position(line, character), encoding());
     }
 
+    /**
+     * Replaces the buffer text, then returns hover markdown.
+     *
+     * @param uri buffer identity
+     * @param text source text
+     * @param line 0-based line
+     * @param character 0-based character
+     * @return markdown, never {@code null}
+     */
+    public String describe(final URI uri, final String text, final int line, final int character) {
+        updateBuffer(uri, text);
+        return describe(uri, line, character);
+    }
+
     /**
      * Definition targets in an open buffer.
      *
@@ -355,8 +449,7 @@ public final class GroovyLanguageEngine implements AutoCloseable {
         TextDocument document = documentAt(uri);
         WorkspaceEdit edit = features.rename().rename(document, context.getSnapshot(),
                 new Position(line, character), newName, encoding());
-        Map<String, List<TextEdit>> changes = edit == null || edit.getChanges() == null
-                ? Map.of() : edit.getChanges();
+        Map<String, List<TextEdit>> changes = edit.getChanges();
         List<Change> result = new ArrayList<>();
         int count = 0;
         for (Map.Entry<String, List<TextEdit>> entry : changes.entrySet()) {
@@ -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..d393eeb2a0 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;
@@ -33,17 +36,43 @@ 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 +82,21 @@ 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.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 +105,8 @@ import java.util.stream.Collectors;
 public final class CompletionService {
 
     static final int MAX_ITEMS = 80;
-    private static final List<String> GDK_NAMES = gdkMethodNames();
+    /** 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 +119,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,9 +153,12 @@ 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);
         }
 
+        if (memberAccess) {
+            items.sort(Comparator.comparing(CompletionItem::getSortText, Comparator.nullsLast(Comparator.naturalOrder())));
+        }
         boolean incomplete = items.size() > MAX_ITEMS;
         if (incomplete) {
             items = new ArrayList<>(items.subList(0, MAX_ITEMS));
@@ -271,19 +327,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 +339,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 +376,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 +391,216 @@ 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)) {
+                continue;
+            }
+            String detail = extensionDetail(method);
+            if (!seen.add(name + " " + detail) || alreadyOffered(items, name, detail)) {
                 continue;
             }
             CompletionItem item = new CompletionItem(name);
             item.setKind(CompletionItemKind.Method);
-            item.setDetail("GDK");
+            item.setDetail(detail);
             item.setSortText("4" + name);
+            item.setInsertText(name);
             items.add(item);
         }
     }
 
-    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)));
+    private static List<MetaMethod> extensionMethods(final ClassNode receiver, final boolean staticAccess) {
+        List<MetaMethod> methods = null;
+        Class<?> type = typeClass(receiver);
+        if (type != null) {
+            methods = InvokerHelper.getMetaClass(type).getMetaMethods();
+        }
+        if (methods == null || methods.isEmpty()) {
+            methods = EXTENSION_METHODS;
+        }
+        List<MetaMethod> matched = new ArrayList<>();
+        for (MetaMethod method : methods) {
+            if (method != null && method.isStatic() == staticAccess) {
+                matched.add(method);
+            }
+        }
+        return matched;
+    }
+
+    private static Class<?> typeClass(final ClassNode receiver) {
+        if (!usable(receiver)) {
+            return null;
+        }
+        try {
+            return receiver.getTypeClass();
+        } catch (RuntimeException | LinkageError ignored) {
+            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;
+            }
+            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;
+    }
+
+    /**
+     * {@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;
+        }
+        if (!(method instanceof ReflectionMetaMethod reflection)) {
+            return names;
+        }
+        MetaMethod cached = reflection.getCachedMethod();
+        if (!(cached instanceof CachedMethod cachedMethod)) {
+            return names;
+        }
+        Method reflected = cachedMethod.getCachedMethod();
+        int start = method instanceof NewMetaMethod ? 1 : 0;
+        String[] resolved = ReflectionParameterNames.names(reflected);
+        for (int i = 0; i < count; i++) {
+            int source = start + i;
+            if (source >= resolved.length) {
+                break;
+            }
+            String name = resolved[source];
+            if (name != null && !name.isEmpty() && !name.matches("arg\\d+")) {
+                names[i] = name;
+            }
+        }
+        return names;
+    }
+
+    private static String simpleName(final Class<?> type) {
+        if (type == null) {
+            return "Object";
+        }
+        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 +647,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";
+        }
+        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";
+        }
+        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 +748,11 @@ 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);
+            Receiver receiverType = resolveReceiver(document, compiled.getSourceUnit(), module, position, encoding);
+            addVisibleMembers(items, receiverType.type, prefix, receiverType.staticAccess);
             if (prefix.length() >= 2) {
-                addGdk(items, prefix);
+                addExtensionMethods(items, prefix, receiverType.type, receiverType.staticAccess);
             }
             return;
         }
@@ -466,6 +765,564 @@ 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) {
+                break;
+            }
+            parts.add(0, line.substring(start, index));
+            index = skipWhitespaceBack(line, start);
+            if (index <= 0 || line.charAt(index - 1) != '.') {
+                break;
+            }
+            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("2length");
+            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) {
+                continue;
+            }
+            ClassNode redirected = current.redirect();
+            String key = redirected.getName();
+            if (key != null && !seen.add(key)) {
+                continue;
+            }
+            action.accept(redirected);
+            ClassNode superClass = redirected.getSuperClass();
+            if (superClass != null) {
+                pending.add(superClass);
+            }
+            ClassNode[] interfaces = redirected.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;
+        for (int i = 0; i < text.length(); i++) {
+            char c = text.charAt(i);
+            if (c != '\n' && c != '\r') {
+                continue;
+            }
+            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;
+        }
+        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;
+        }
+        if (module != null) {
+            ClassNode imported = module.getImportType(name);
+            if (imported != null) {
+                ClassNode loaded = loadClass(imported.getName(), unit);
+                if (loaded != null) {
+                    return loaded;
+                }
+                return imported;
+            }
+            for (ImportNode star : module.getStarImports()) {
+                ClassNode loaded = loadClass(qualify(star.getPackageName(), name), unit);
+                if (loaded != null) {
+                    return loaded;
+                }
+            }
+        }
+        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 boolean accepts(final ClassNode receiver, final ClassNode parameter) {
+        if (parameter == null || ClassHelper.isObjectType(parameter) || ClassHelper.isDynamicTyped(parameter)) {
+            return true;
+        }
+        ClassNode left = receiver.redirect();
+        ClassNode right = parameter.redirect();
+        if (left.equals(right) || left.getName().equals(right.getName())) {
+            return true;
+        }
+        try {
+            if (left.isDerivedFrom(right)) {
+                return true;
+            }
+            return right.isInterface() && left.implementsInterface(right);
+        } catch (RuntimeException ignored) {
+            return false;
+        }
+    }
+
     private static CompletionItemKind typeKind(final TypeIndex.TypeHit hit) {
         if (hit.iface()) {
             return CompletionItemKind.Interface;
@@ -483,7 +1340,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..2b38d3d797 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,11 +300,28 @@ public final class SemanticTokensService {
         if (text == null || text.isEmpty()) {
             return;
         }
-        for (Token token : GroovySourceTokens.tokenize(text)) {
+        addLexicalTokens(text, GroovySourceTokens.tokenize(text), tokens, encoding, range);
+    }
+
+    private static void addLexicalTokens(final String text, final List<Token> lexerTokens,
+                                         final List<TokenSpan> tokens, final PositionEncoding encoding,
+                                         final Range range) {
+        // 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) {
             if (token != null) {
                 int type = lexicalType(token);
                 if (type >= 0) {
-                    addLexerSpans(text, tokens, encoding, range, token, type);
+                    int modifiers = 0;
+                    if (type == TOKEN_STRING) {
+                        int tokenType = token.getType();
+                        if (tokenType == GroovyLexer.StringLiteral || tokenType == GroovyLexer.GStringBegin) {
+                            openString = stringModifier(token.getText());
+                        }
+                        modifiers = openString;
+                    }
+                    addLexerSpans(text, tokens, encoding, range, token, type, modifiers);
                 }
             }
         }
@@ -263,15 +343,29 @@ public final class SemanticTokensService {
             }
             return -1;
         }
-        if (type == GroovyLexer.NOT_IN || type == GroovyLexer.NOT_INSTANCEOF
-                || GroovyKeywords.isKeyword(token.getText())) {
+        if (type == GroovyLexer.AT) {
+            return TOKEN_DECORATOR;
+        }
+        if (type == GroovyLexer.COMMA || type == GroovyLexer.SEMI) {
+            return TOKEN_OPERATOR;
+        }
+        if (type == GroovyLexer.LPAREN || type == GroovyLexer.RPAREN
+                || type == GroovyLexer.LBRACK || type == GroovyLexer.RBRACK
+                || type == GroovyLexer.LBRACE || type == GroovyLexer.RBRACE
+                || type == GroovyLexer.SAFE_INDEX) {
+            return TOKEN_DELIMITER;
+        }
+        if (GroovyKeywords.isKeywordType(type)) {
             return TOKEN_KEYWORD;
         }
+        if (type == GroovyLexer.Identifier || type == GroovyLexer.CapitalizedIdentifier) {
+            return TOKEN_VARIABLE;
+        }
         return -1;
     }
 
     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;
@@ -284,16 +378,39 @@ public final class SemanticTokensService {
             boolean trailingEmpty = part.isEmpty() && i == parts.length - 1 && tokenText.endsWith("\n");
             int lineI = line + i;
             if (!trailingEmpty && lineInRange(range, lineI)) {
+                String painted = part;
+                boolean trimDollar = (token.getType() == GroovyLexer.GStringBegin
+                        || token.getType() == GroovyLexer.GStringPart) && painted.endsWith("$");
+                if (trimDollar) {
+                    painted = painted.substring(0, painted.length() - 1);
+                }
+                if (painted.isEmpty()) {
+                    continue;
+                }
                 String row = Positions.lineText(text, lineI + 1);
                 int startCodePoints = i == 0 ? codePointColumn : 0;
-                int endCodePoints = startCodePoints + part.codePointCount(0, part.length());
+                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, 0));
+                tokens.add(new TokenSpan(lineI, startChar, Math.max(endChar - startChar, 1), type, modifiers));
             }
         }
     }
 
+    /**
+     * 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) {
         if (tokens.size() < 2) {
             return;
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..42b01bae60
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SyntaxView.java
@@ -0,0 +1,132 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package 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.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 = GroovyLangLexer.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) {
+            Token symbol = terminal.getSymbol();
+            String name = "";
+            String text = "";
+            if (symbol != null && symbol.getType() != Token.EOF) {
+                name = parser.getVocabulary().getSymbolicName(symbol.getType());
+                if (name == null) {
+                    name = parser.getVocabulary().getDisplayName(symbol.getType());
+                }
+                text = symbol.getText() == null ? "" : symbol.getText();
+            }
+            if (symbol != null && symbol.getType() == Token.EOF) {
+                return leaf("EOF", "");
+            }
+            return leaf(name == null ? "" : name, text);
+        }
+        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> 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
     public CompletableFuture<Hover> hover(final HoverParams params) {
+        context.recompile();
         return withDocument(params.getTextDocument().getUri(), null,
                 document -> features.hovers().hover(document, context.getSnapshot(), params.getPosition(),
                         context.getPositionEncoding()));
@@ -160,6 +163,7 @@ public final class GroovyTextDocumentService implements TextDocumentService {
 
     @Override
     public CompletableFuture<SignatureHelp> signatureHelp(final SignatureHelpParams params) {
+        context.recompile();
         return withDocument(params.getTextDocument().getUri(), new SignatureHelp(List.of(), 0, 0),
                 document -> features.signatureHelp().signatureHelp(document, context.getSnapshot(), params.getPosition(),
                         context.getPositionEncoding()));
@@ -314,8 +318,9 @@ public final class GroovyTextDocumentService implements TextDocumentService {
 
     @Override
     public CompletableFuture<SemanticTokens> semanticTokensFull(final SemanticTokensParams params) {
-        return fromCompiled(params.getTextDocument().getUri(),
-                compiled -> features.semanticTokens().semanticTokens(compiled, context.getPositionEncoding()));
+        return withDocument(params.getTextDocument().getUri(), new SemanticTokens(List.of()),
+                document -> features.semanticTokens().semanticTokens(document.getText(),
+                        context.getSnapshot().get(document), context.getPositionEncoding()));
     }
 
     @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 0ebc939a96..3176127c0a 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
@@ -22,8 +22,11 @@ import org.apache.groovy.lsp.internal.LanguageServerContext;
 import org.apache.groovy.lsp.internal.feature.LanguageFeatures;
 import org.apache.groovy.lsp.internal.compile.CompiledDocument;
 import org.apache.groovy.lsp.internal.compile.CompilerSettings;
-import org.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.compile.ScriptNames;
+import org.apache.groovy.lsp.internal.feature.SyntaxView;
+import org.apache.groovy.lsp.internal.util.GroovyKeywords;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.apache.groovy.lsp.internal.util.Uris;
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
 import org.eclipse.lsp4j.ConfigurationItem;
 import org.eclipse.lsp4j.ConfigurationParams;
@@ -44,6 +47,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either;
 import org.eclipse.lsp4j.services.LanguageClient;
 import org.eclipse.lsp4j.services.WorkspaceService;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
@@ -167,6 +171,42 @@ public final class GroovyWorkspaceService implements WorkspaceService {
             return atCaret(args, (document, position) -> features.navigation().superMethod(
                     document, context.getSnapshot(), position, context.getPositionEncoding()));
         }
+        if ("groovy.compile".equals(command)) {
+            context.recompile();
+            return CompletableFuture.completedFuture(null);
+        }
+        if ("groovy.parentLoader".equals(command)) {
+            Object loader = args.isEmpty() ? null : args.get(0);
+            if (loader == null || loader instanceof ClassLoader) {
+                context.setParentLoader((ClassLoader) loader);
+            }
+            return CompletableFuture.completedFuture(null);
+        }
+        if ("groovy.scriptNames".equals(command) && !args.isEmpty()) {
+            context.recompile();
+            CompiledDocument compiled = context.getSnapshot().get(String.valueOf(args.get(0)));
+            if (compiled == null || compiled.getModule() == null) {
+                return CompletableFuture.completedFuture(List.of());
+            }
+            return CompletableFuture.completedFuture(new ArrayList<>(ScriptNames.of(compiled.getModule())));
+        }
+        if ("groovy.keywords".equals(command)) {
+            return CompletableFuture.completedFuture(new ArrayList<>(GroovyKeywords.KEYWORDS));
+        }
+        if ("groovy.tokens".equals(command) && !args.isEmpty()) {
+            TextDocument document = context.documentFor(String.valueOf(args.get(0)));
+            if (document == null) {
+                return CompletableFuture.completedFuture(List.of());
+            }
+            return CompletableFuture.completedFuture(SyntaxView.tokens(document.getText()));
+        }
+        if ("groovy.cst".equals(command) && !args.isEmpty()) {
+            TextDocument document = context.documentFor(String.valueOf(args.get(0)));
+            if (document == null) {
+                return CompletableFuture.completedFuture(SyntaxView.cst(""));
+            }
+            return CompletableFuture.completedFuture(SyntaxView.cst(document.getText()));
+        }
         return null;
     }
 
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
index 8019d187fc..020a0f1dab 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
@@ -50,6 +50,21 @@ public final class GroovyKeywords {
         return word != null && KEYWORDS.contains(word);
     }
 
+    /**
+     * @param type a {@link GroovyLexer} token type
+     * @return {@code true} when {@code type} is painted as a reserved word
+     */
+    public static boolean isKeywordType(final int type) {
+        return KEYWORD_TOKEN_TYPES.contains(type);
+    }
+
+    /**
+     * Lexer token types painted as reserved words: vocabulary word
+     * keywords, {@code !in}/{@code !instanceof}, primitives, {@code true},
+     * {@code false} and {@code null}.
+     */
+    public static final List<Integer> KEYWORD_TOKEN_TYPES = List.copyOf(keywordTokenTypes());
+
     /**
      * Declaration modifiers, using the lexer token types so the spelling
      * still comes from the vocabulary.
@@ -63,6 +78,27 @@ public final class GroovyKeywords {
     private GroovyKeywords() {
     }
 
+    private static List<Integer> keywordTokenTypes() {
+        List<Integer> types = new ArrayList<>();
+        Vocabulary vocabulary = GroovyLexer.VOCABULARY;
+        for (int type = 1; type <= vocabulary.getMaxTokenType(); type++) {
+            if (isKeywordTokenType(vocabulary, type)) {
+                types.add(type);
+            }
+        }
+        return types;
+    }
+
+    private static boolean isKeywordTokenType(final Vocabulary vocabulary, final int type) {
+        if (type == GroovyLexer.NOT_IN || type == GroovyLexer.NOT_INSTANCEOF
+                || type == GroovyLexer.BuiltInPrimitiveType
+                || type == GroovyLexer.BooleanLiteral
+                || type == GroovyLexer.NullLiteral) {
+            return true;
+        }
+        return isWordKeyword(unquote(vocabulary.getLiteralName(type)));
+    }
+
     private static TreeSet<String> keywords() {
         TreeSet<String> words = new TreeSet<>();
         Vocabulary vocabulary = GroovyLexer.VOCABULARY;
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java
index d960b9c971..ab81e52d53 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovySourceTokens.java
@@ -27,9 +27,10 @@ import java.util.ArrayList;
 import java.util.List;
 
 /**
- * Live {@link GroovyLexer} walks over a buffer. Used as a fail-closed
- * gate for on-type {@code '}'}', comment-based folds, and lexical
- * semantic tokens (string / number / comment).
+ * Live {@link GroovyLexer} walks over a buffer. Strict walks are a
+ * fail-closed gate for an on-type closing brace, comment-based folds, and
+ * lexical semantic tokens. Lenient walks ({@link #tokenizeLenient})
+ * keep tokenising incomplete input for a host highlighter.
  */
 public final class GroovySourceTokens {
 
@@ -48,7 +49,7 @@ public final class GroovySourceTokens {
             return null;
         }
         try {
-            GroovyLexer lexer = lexer(text);
+            GroovyLexer lexer = lexer(text, false);
             for (Token token = lexer.nextToken(); token.getType() != Token.EOF; token = lexer.nextToken()) {
                 if (token.getStartIndex() <= utf16Offset && utf16Offset <= token.getStopIndex()) {
                     return token;
@@ -82,30 +83,47 @@ public final class GroovySourceTokens {
     }
 
     /**
-     * Full token walk including hidden-channel tokens.
+     * Full token walk including hidden-channel tokens. Lexical errors
+     * abort the walk so on-type formatting can refuse to guess.
      *
      * @param text document text
      * @return tokens, never {@code null}
      */
     public static List<Token> tokenize(final String text) {
+        return tokenize(text, false);
+    }
+
+    /**
+     * Token walk for a live highlighter. Incomplete input still yields
+     * tokens ({@code errorIgnored}).
+     *
+     * @param text document text
+     * @return tokens, never {@code null}
+     */
+    public static List<Token> tokenizeLenient(final String text) {
+        return tokenize(text, true);
+    }
+
+    private static List<Token> tokenize(final String text, final boolean errorIgnored) {
         List<Token> tokens = new ArrayList<>();
         if (text == null || text.isEmpty()) {
             return tokens;
         }
         try {
-            GroovyLexer lexer = lexer(text);
+            GroovyLexer lexer = lexer(text, errorIgnored);
             for (Token token = lexer.nextToken(); token.getType() != Token.EOF; token = lexer.nextToken()) {
                 tokens.add(token);
             }
         } catch (GroovySyntaxError | RuntimeException ignored) {
-            return List.of();
+            return errorIgnored ? tokens : List.of();
         }
         return tokens;
     }
 
-    private static GroovyLexer lexer(final String text) {
+    private static GroovyLexer lexer(final String text, final boolean errorIgnored) {
         GroovyLexer lexer = new GroovyLexer(CharStreams.fromString(text));
         lexer.removeErrorListeners();
+        lexer.setErrorIgnored(errorIgnored);
         return lexer;
     }
 }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
index 99c628d0a4..0f7d0ce7f9 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
@@ -119,6 +119,40 @@ public record TextDocument(URI uri, String languageId, int version, String text)
         return current.substring(0, start) + insert + current.substring(end);
     }
 
+    /**
+     * Maps a UTF-16 document offset to a 0-based line and character.
+     * CR, LF, and CRLF are line breaks. The character is a UTF-16 unit
+     * count on that line.
+     *
+     * @param document full text
+     * @param offset UTF-16 offset
+     * @return {@code {line, character}}
+     */
+    public static int[] positionOf(final String document, final int offset) {
+        if (document == null || document.isEmpty() || offset <= 0) {
+            return new int[] {0, 0};
+        }
+        int capped = Math.min(offset, document.length());
+        int line = 0;
+        int character = 0;
+        for (int i = 0; i < capped; i++) {
+            char c = document.charAt(i);
+            if (c == '\n') {
+                line++;
+                character = 0;
+            } else if (c == '\r') {
+                if (i + 1 < document.length() && document.charAt(i + 1) == '\n') {
+                    i++;
+                }
+                line++;
+                character = 0;
+            } else {
+                character++;
+            }
+        }
+        return new int[] {line, character};
+    }
+
     /**
      * Converts an LSP position to a UTF-16 offset in {@code document}.
      *
diff --git a/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc b/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
index e1bfd93c67..2c090bd3a8 100644
--- a/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
+++ b/subprojects/groovy-lsp/src/spec/doc/lsp-userguide.adoc
@@ -162,6 +162,19 @@ Extra `groovy.*` keys are stored as strings.
 A list becomes its text.
 Changing one of them forces the next compile onto a fresh class loader.
 `groovy.dependencies` is one such key.
+`groovy.imports` and `groovy.starImports` are comma-separated class or package names.
+An empty value adds no import customizer, so the next compile does not keep the previous client import list.
+`groovy.scriptBaseClass` sets the script base class.
+`groovy.compile` set to `false` skips the debounced compile.
+`textDocument/semanticTokens` still returns lexical tokens for that buffer.
+An in-process client may pass a `ClassLoader` to the `groovy.parentLoader` command.
+A remote client sends `groovy.classpath` instead.
+`groovy.scriptNames` returns the script-level names of an open document.
+`groovy.keywords` returns the lexer keywords.
+`groovy.tokens` returns the lenient lexer tokens of an open document.
+`groovy.cst` returns its concrete syntax tree.
+None of those three compile the buffer.
+`groovy.compile` compiles immediately and publishes diagnostics.
 The server records it and does not download it.
 
 [WARNING]
@@ -279,7 +292,7 @@ Completion is triggered by `.` and `@`, and by an explicit completion request.
 The list can contain:
 
 * keywords taken from the Groovy lexer, plus snippets for `class`, `interface`, `trait`, `enum`, `for`, `if`, and `try`
-* members of the receiver at the caret, inserted as a snippet
+* members of the receiver after `.`, `?.`, or `*.`, inserted as a snippet. An unfinished statement still uses the receiver's declared type or the type of its initializer. Keywords are not offered on a member access. A class name such as `String.` offers that class's members
 * types declared in the workspace, with an additional edit that adds an unambiguous import
 * GDK methods, after the first two characters of the name
 * unimplemented abstract methods, as `@Override` snippets inside a class body
@@ -457,10 +470,15 @@ class Folded {
 `// region` and `// endregion` work the same way, with or without the `#`.
 
 Semantic tokens cover the whole file or a range.
-The legend is append-only: `namespace`, `class`, `enum`, `interface`, `method`, `property`, `variable`, `parameter`, `keyword`, `string`, `number`, and `comment`.
-Lexer tokens contribute keywords, strings (GString pieces, without treating the interpolated expression as a string), numbers, and comments.
+The legend is append-only: `namespace`, `class`, `enum`, `interface`, `method`, `property`, `variable`, `parameter`, `keyword`, `string`, `number`, `comment`, `operator`, `decorator`, and `delimiter`.
+Lexer tokens contribute keywords, strings (GString pieces, without treating the interpolated expression as a string), numbers, comments, `@`, comma or semicolon, and structural brackets (`( )`, `[ ]`, `{ }`, and `?[`).
+A bracket inside a string or a comment is not a `delimiter` token.
 The AST then paints package and import names, types, methods, properties, parameters, and the identifiers at use sites.
-Modifier bits are `declaration`, `static`, and `deprecated`.
+Modifier bits are `declaration`, `static`, `deprecated`, `slashy`, and `singleQuoted`.
+A string without `slashy` or `singleQuoted` is double-quoted.
+A GString part uses the modifier of the token that opened the literal.
+The `$` that ends a GString begin or part is not part of the string token.
+When the compiled snapshot describes different text, the full semantic-token request uses the lexer only.
 The server does not advertise multiline token support.
 A multiline comment or triple-quoted string is split into one token per line.
 
@@ -480,8 +498,19 @@ Selection ranges grow from the caret through each enclosing AST node, so each re
 [[lsp-in-process]]
 == In-process use
 
-Console, groovysh, and tests can use the same snapshot without speaking JSON-RPC.
-`GroovyLanguageEngine` is that facade.
+Groovy Console and groovysh depend on this module and share `GroovyLanguageClient`, which calls `GroovyLanguageServer` in-process.
+The messages are the ones a remote editor sends.
+`initialize` does not set a workspace root, because that would compile the process directory.
+`textDocument/didChange` sends the whole buffer.
+The same text does not change the document version, so a later compile can reuse the snapshot.
+A highlighter sets `groovy.compile` to `false` and asks for `textDocument/semanticTokens`.
+That request does not compile.
+Configuration is `workspace/didChangeConfiguration`.
+The in-process commands are `groovy.compile`, `groovy.parentLoader` (a `ClassLoader` in this JVM), and `groovy.scriptNames`.
+The calls are ordinary method calls.
+They are not serialized to JSON on each keystroke.
+
+`GroovyLanguageEngine` is a second, internal facade for tests and for a host that wants JDK records instead of LSP types.
 Its results are JDK records.
 They do not use LSP4J types.
 The engine does not scan workspace folders and does not reserve buffer names.
@@ -528,14 +557,29 @@ It does not append the `_inferred_` line that the protocol hover adds.
 `symbols` matches workspace symbols, including CamelHumps.
 `rename` returns edits and does not write them.
 `format` and `organizeImports` also return edits.
-`tokens` returns absolute semantic-token spans for a host highlighter.
+`tokens` returns absolute compiled semantic-token spans.
+Those spans need a compiled snapshot, so do not call `tokens(URI)` on the Swing EDT.
+`lexicalTokens(text)` is the same legend for a caller that already holds the engine.
+Groovy Console paints `textDocument/semanticTokens` instead, so a highlight does not compile.
+Identifiers stay `variable` until a compiled `tokens(uri)` overlay replaces them.
 `compileNow` compiles immediately.
 `scheduleCompile` compiles after the delay you pass.
-`setParentLoader`, `setClasspath`, and `putExtra` are the session controls.
+`diagnostics`, `complete`, `describe`, `signatures`, and `declaredNames` each have a form that takes the buffer text, replaces it, then queries.
+The same text keeps the document version, so the snapshot is reused.
+`flattenSnippet` strips LSP snippet placeholders.
+`flattenSnippetLine` keeps the first line.
+`positionOf`, `offsetOf`, and `spanOf` convert 0-based UTF-16 positions.
+CR, LF, and CRLF are line breaks. A character past the end of a line stays on that line.
+A zero-width range at the end of the text paints the last character.
+`setParentLoader`, `setClasspath`, `setHostConfiguration`, and `putExtra` are the session controls.
+`setParentLoader` records loader identity and, for a `URLClassLoader`, its URL list, so an in-place `addURL` is visible on the next compile.
+`setHostConfiguration` copies the script base class and compilation customizers from a Console or groovysh `CompilerConfiguration` into the tooling compile.
+Every call drops the cached compile, including a repeat with the same instance, so call it after an in-place customizer edit and not before every query.
 `putExtra` records one opaque string and, like a classpath change, forces the next compile onto a fresh loader.
 
-Hosts that must not put LSP4J on the Groovy classpath can depend on `groovy-lsp` as `compileOnly` and load the engine with `Class.forName`.
-Groovy Console and groovysh do that.
+A third-party host that must keep LSP4J off its own compile classpath can depend on `groovy-lsp` as `compileOnly` and load `GroovyLanguageServer` or `GroovyLanguageEngine` with `Class.forName`.
+Console and groovysh do not do that.
+They compile against the protocol types.
 
 ****
 *Inferred results.*
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
new file mode 100644
index 0000000000..624542f14d
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageClientTest.groovy
@@ -0,0 +1,140 @@
+/*
+ *  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 org.eclipse.lsp4j.CompletionItem
+import org.eclipse.lsp4j.CompletionList
+import org.eclipse.lsp4j.Diagnostic
+import org.eclipse.lsp4j.Hover
+import org.eclipse.lsp4j.MarkupContent
+import org.eclipse.lsp4j.MarkupKind
+import org.eclipse.lsp4j.ExecuteCommandParams
+import org.eclipse.lsp4j.PublishDiagnosticsParams
+import org.eclipse.lsp4j.SemanticTokens
+import org.eclipse.lsp4j.jsonrpc.messages.Either
+import org.junit.jupiter.api.Test
+
+final class GroovyLanguageClientTest {
+
+    @Test
+    void protocolEitherShapes() {
+        assert GroovyLanguageClient.completionItems(null).empty
+        assert GroovyLanguageClient.completionItems('nope').empty
+        assert GroovyLanguageClient.completionItems(Either.forLeft([])).empty
+        assert GroovyLanguageClient.completionItems(Either.forRight(null)).empty
+        assert GroovyLanguageClient.completionItems(Either.forRight('nope')).empty
+        def items = new CompletionList(false, [new CompletionItem('ping')])
+        assert GroovyLanguageClient.completionItems(Either.forRight(items))*.label == ['ping']
+        def bareList = new CompletionList()
+        assert GroovyLanguageClient.completionItems(Either.forRight(bareList)).empty
+
+        assert GroovyLanguageClient.hoverText(null) == ''
+        assert GroovyLanguageClient.hoverText(new Hover()) == ''
+        assert GroovyLanguageClient.hoverText(new Hover(Either.forRight(
+                new MarkupContent(MarkupKind.MARKDOWN, 'Sample')))) == 'Sample'
+        assert GroovyLanguageClient.hoverText(new Hover(Either.forRight(
+                new MarkupContent(MarkupKind.MARKDOWN, '')))) == ''
+        assert GroovyLanguageClient.hoverText(new Hover(Either.forRight(null))) == ''
+        def leftNull = new Hover()
+        leftNull.contents = Either.forLeft(null)
+        assert GroovyLanguageClient.hoverText(leftNull) == ''
+        def mixed = GroovyLanguageClient.hoverText(new Hover(Either.forLeft([
+                Either.forLeft('plain'),
+                Either.forRight(new MarkupContent(MarkupKind.MARKDOWN, 'md')),
+                Either.forRight(new MarkupContent(MarkupKind.MARKDOWN, '')),
+                Either.forRight(null),
+                Either.forLeft(null),
+                null
+        ])))
+        assert mixed.contains('plain')
+        assert mixed.contains('md')
+
+        assert GroovyLanguageClient.scriptNameList(null).empty
+        assert GroovyLanguageClient.scriptNameList('x').empty
+        assert GroovyLanguageClient.scriptNameList([null, 'ping']) == ['', 'ping']
+
+        assert GroovyLanguageClient.tokenData(null).empty
+        assert GroovyLanguageClient.tokenData(new SemanticTokens([1, 0, 1, 8, 0])) == [1, 0, 1, 8, 0]
+
+        assert GroovyLanguageClient.diagnosticText(null) == ''
+        assert GroovyLanguageClient.diagnosticText(new Diagnostic()) == ''
+        def left = new Diagnostic()
+        left.message = Either.forLeft('plain')
+        assert GroovyLanguageClient.diagnosticText(left) == 'plain'
+        def leftEmpty = new Diagnostic()
+        leftEmpty.message = Either.forLeft(null)
+        assert GroovyLanguageClient.diagnosticText(leftEmpty) == ''
+        def right = new Diagnostic()
+        right.message = Either.forRight(new MarkupContent(MarkupKind.MARKDOWN, 'md'))
+        assert GroovyLanguageClient.diagnosticText(right) == 'md'
+        def rightEmpty = new Diagnostic()
+        rightEmpty.message = Either.forRight(new MarkupContent(MarkupKind.MARKDOWN, ''))
+        assert GroovyLanguageClient.diagnosticText(rightEmpty) == ''
+        def rightNull = new Diagnostic()
+        rightNull.message = Either.forRight(null)
+        assert GroovyLanguageClient.diagnosticText(rightNull) == ''
+        assert GroovyLanguageClient.keywordList().contains('class')
+        assert GroovyLanguageClient.keywordList().contains('int')
+        assert GroovyLanguageClient.keywordList().contains('true')
+        assert GroovyLanguageClient.keywordList().is(GroovyLanguageClient.keywordList())
+    }
+
+    @Test
+    void inProcessSessionSpeaksTheSameMessagesAsAnEditor() {
+        def dogfood = GroovyLanguageClient.open('groovy-buffer:dogfood', 'class Broken { def x = }', false)
+        try {
+            assert dogfood.publishedOrNull() == null
+            assert dogfood.semanticTokens('class Broken { def x = }')
+            assert dogfood.semanticTokens('class Broken { def x = }')
+            assert dogfood.compile('class Broken { def x = }')
+            assert dogfood.publishedOrNull() != null
+
+            dogfood.settings([compile: 'true', imports: '', starImports: '', scriptBaseClass: ''])
+            dogfood.parentLoader(null)
+            dogfood.parentLoader(GroovyLanguageClientTest.classLoader)
+            assert dogfood.hover('class Sample {\n    def ping() { 1 }\n}', 0, 6).contains('Sample')
+            assert dogfood.complete('class Sample {\n    def ping() { ping() }\n}', 1, 16) != null
+            assert dogfood.signatures('println(', 0, 8) != null
+            assert dogfood.scriptNames('int kept = 7\ndef ping() {}\n').contains('ping')
+
+            dogfood.telemetryEvent('t')
+            dogfood.showMessage(null)
+            assert dogfood.showMessageRequest(null).get() == null
+            dogfood.logMessage(null)
+            dogfood.publishDiagnostics(null)
+            dogfood.publishDiagnostics(new PublishDiagnosticsParams())
+
+            def tokens = dogfood.tokens('def x = 1')
+            assert tokens.any { it.name == 'DEF' }
+            assert dogfood.tokens('').empty
+            def tree = dogfood.cst('class C { }')
+            assert tree.name == 'compilationUnit'
+            assert tree.children
+            def broken = dogfood.cst('class {')
+            assert broken.name
+            assert dogfood.server.workspaceService.executeCommand(
+                    new ExecuteCommandParams('groovy.tokens', ['groovy-buffer:missing'])).get() == []
+            def missingTree = dogfood.server.workspaceService.executeCommand(
+                    new ExecuteCommandParams('groovy.cst', ['groovy-buffer:missing'])).get()
+            assert missingTree.name == 'compilationUnit' || missingTree.name == 'EOF' || missingTree.children != null
+        } finally {
+            dogfood.close()
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
index c5c5d2b489..e384992de1 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
@@ -60,9 +60,12 @@ import java.nio.charset.StandardCharsets
 import java.nio.file.Files
 import java.nio.file.Path
 import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CountDownLatch
 import java.util.concurrent.ExecutionException
 import java.util.concurrent.Future
 import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
 import org.apache.groovy.lsp.internal.compile.CompilationSnapshot
 import org.apache.groovy.lsp.internal.feature.LanguageFeatures
 import org.apache.groovy.lsp.internal.position.PositionEncoding
@@ -258,6 +261,74 @@ final class GroovyLanguageServerTest {
         assert fixture.diagnostics[brokenUri] == []
     }
 
+    @Test
+    void publishDiagnosticsSkipsANullSnapshot() {
+        fixture = new LspFixture()
+        int before = fixture.publishedParams.size()
+        def field = LanguageServerContext.getDeclaredField('snapshot')
+        field.accessible = true
+        (field.get(fixture.server.context) as AtomicReference).set(null)
+        fixture.server.context.publishDiagnostics()
+        assert fixture.publishedParams.size() == before
+    }
+
+    /**
+     * A compile that already observed a deleted file must not publish its
+     * diagnostics after a later compile has cleared that URI.
+     */
+    @Test
+    void staleCompileDoesNotRepublishDiagnosticsForARemovedFile() {
+        def entered = new CountDownLatch(1)
+        def release = new CountDownLatch(1)
+        def held = new AtomicBoolean()
+        def brokenUri = new AtomicReference<String>()
+        fixture = new LspFixture() {
+            @Override
+            void publishDiagnostics(PublishDiagnosticsParams params) {
+                if (params?.uri?.endsWith('Broken.groovy') && params.diagnostics
+                        && held.compareAndSet(false, true)) {
+                    brokenUri.set(params.uri)
+                    entered.countDown()
+                    assert release.await(10, TimeUnit.SECONDS)
+                }
+                super.publishDiagnostics(params)
+            }
+        }
+        def folder = Files.createTempDirectory('groovy-lsp-stale')
+        def broken = folder.resolve('Broken.groovy')
+        Files.writeString(broken, 'class Broken {')
+        def added = new WorkspaceFolder(folder.toUri().toString(), 'tmp')
+        def publisher = Thread.start {
+            fixture.server.workspaceService.didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(
+                    new WorkspaceFoldersChangeEvent([added], [])))
+            fixture.server.context.recompile()
+        }
+        Thread clearer = null
+        try {
+            assert entered.await(10, TimeUnit.SECONDS)
+            Files.delete(broken)
+            fixture.server.workspaceService.didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(
+                    new WorkspaceFoldersChangeEvent([], [added])))
+            clearer = Thread.start {
+                fixture.server.context.recompile()
+            }
+            // The held notification owns the publish lock. Give the clearer time
+            // to finish compiling and wait behind that lock before the hold ends.
+            Thread.sleep(300)
+            release.countDown()
+            publisher.join(10_000)
+            clearer.join(10_000)
+            assert !publisher.alive
+            assert !clearer.alive
+            assert fixture.diagnostics[brokenUri.get()] == []
+        } finally {
+            release.countDown()
+            publisher.join(10_000)
+            clearer?.join(10_000)
+            Files.deleteIfExists(broken)
+        }
+    }
+
     @Test
     void shutdownThenExitSetsZero() {
         fixture = new LspFixture()
@@ -521,6 +592,35 @@ final class GroovyLanguageServerTest {
         assert ws.executeCommand(new ExecuteCommandParams('unknown', [])).get() == null
     }
 
+    @Test
+    void inProcessCommandsPublishLoaderNamesAndSkipBackgroundCompile() {
+        fixture = new LspFixture()
+        def uri = fixture.open('Names.groovy', 'int kept = 7\ndef ping() {}\n')
+        def ws = fixture.server.workspaceService
+        assert ws.executeCommand(new ExecuteCommandParams('groovy.compile', [uri])).get() == null
+        def names = ws.executeCommand(new ExecuteCommandParams('groovy.scriptNames', [uri])).get()
+        assert names instanceof List
+        assert names.contains('ping')
+        assert ws.executeCommand(new ExecuteCommandParams('groovy.scriptNames', ['groovy-buffer:missing'])).get() == []
+        assert ws.executeCommand(new ExecuteCommandParams('groovy.scriptNames', [])).get() == null
+
+        def loader = new URLClassLoader(new URL[0], GroovyLanguageServerTest.classLoader)
+        try {
+            assert ws.executeCommand(new ExecuteCommandParams('groovy.parentLoader', [loader])).get() == null
+            assert fixture.server.context.parentLoader.is(loader)
+            assert ws.executeCommand(new ExecuteCommandParams('groovy.parentLoader', ['nope'])).get() == null
+            assert fixture.server.context.parentLoader.is(loader)
+            assert ws.executeCommand(new ExecuteCommandParams('groovy.parentLoader', [])).get() == null
+            assert fixture.server.context.parentLoader == null
+        } finally {
+            loader.close()
+        }
+
+        ws.didChangeConfiguration(new DidChangeConfigurationParams([groovy: [compile: 'false']]))
+        assert fixture.server.context.settings.extra.compile == 'false'
+        fixture.server.context.scheduleRecompile(50)
+    }
+
     @Test
     void launcherHelpAndVersionWriteToStdout() {
         def help = GroovyLanguageServerLauncher.ListArgs.parse(['--help'] as String[])
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/ScriptNamesTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/ScriptNamesTest.groovy
new file mode 100644
index 0000000000..da2b4e8f2d
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/ScriptNamesTest.groovy
@@ -0,0 +1,124 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.lsp.internal.compile
+
+import org.codehaus.groovy.ast.CompileUnit
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.Parameter
+import org.codehaus.groovy.ast.expr.BinaryExpression
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.DeclarationExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.LambdaExpression
+import org.codehaus.groovy.ast.expr.TupleExpression
+import org.codehaus.groovy.ast.expr.VariableExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.ast.stmt.EmptyStatement
+import org.codehaus.groovy.ast.stmt.ExpressionStatement
+import org.codehaus.groovy.syntax.Token
+import org.codehaus.groovy.syntax.Types
+import org.junit.jupiter.api.Test
+
+import static org.objectweb.asm.Opcodes.ACC_PUBLIC
+import static org.objectweb.asm.Opcodes.ACC_STATIC
+
+final class ScriptNamesTest {
+
+    @Test
+    void ofNullModuleIsEmpty() {
+        assert ScriptNames.of(null).isEmpty()
+    }
+
+    @Test
+    void scriptLevelMethodRejectsSyntheticAndGeneratedNames() {
+        assert !ScriptNames.scriptLevelMethod(null)
+        def synthetic = new MethodNode('ping', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null)
+        synthetic.synthetic = true
+        assert !ScriptNames.scriptLevelMethod(synthetic)
+        assert !ScriptNames.scriptLevelMethod(new MethodNode(null, ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        assert !ScriptNames.scriptLevelMethod(new MethodNode('', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        assert !ScriptNames.scriptLevelMethod(new MethodNode('<init>', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        assert !ScriptNames.scriptLevelMethod(new MethodNode('<clinit>', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        assert !ScriptNames.scriptLevelMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        assert ScriptNames.scriptLevelMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.VOID_TYPE,
+                [new Parameter(ClassHelper.OBJECT_TYPE, 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null))
+        assert ScriptNames.scriptLevelMethod(new MethodNode('main', ACC_PUBLIC, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        def generatedMain = new MethodNode('main', ACC_PUBLIC | ACC_STATIC, ClassHelper.VOID_TYPE,
+                [new Parameter(ClassHelper.STRING_TYPE.makeArray(), 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null)
+        assert !ScriptNames.scriptLevelMethod(generatedMain)
+        assert ScriptNames.scriptLevelMethod(new MethodNode('main', ACC_PUBLIC | ACC_STATIC, ClassHelper.VOID_TYPE,
+                [new Parameter(null, 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null))
+        assert ScriptNames.scriptLevelMethod(new MethodNode('main', ACC_PUBLIC | ACC_STATIC, ClassHelper.VOID_TYPE,
+                [new Parameter(ClassHelper.STRING_TYPE, 'args')] as Parameter[], ClassNode.EMPTY_ARRAY, null))
+    }
+
+    @Test
+    void addScriptAssignmentsSkipsMissingRunAndNullCode() {
+        def collected = new LinkedHashSet<String>()
+        ScriptNames.addScriptAssignments(null, collected)
+        ScriptNames.addScriptAssignments(new ClassNode('NoRun', ACC_PUBLIC, ClassHelper.OBJECT_TYPE), collected)
+        assert collected.isEmpty()
+        def script = new ClassNode('EmptyRun', ACC_PUBLIC, ClassHelper.SCRIPT_TYPE)
+        script.addMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null))
+        ScriptNames.addScriptAssignments(script, collected)
+        assert collected.isEmpty()
+        ScriptNames.addScriptAssignments(script, null)
+    }
+
+    @Test
+    void assignmentsVisitClosuresLambdasAndBlankNames() {
+        def names = new LinkedHashSet<String>()
+        def module = new ModuleNode((CompileUnit) null)
+        module.addClass(new ClassNode('', ACC_PUBLIC, ClassHelper.OBJECT_TYPE))
+        assert ScriptNames.of(module).isEmpty()
+
+        def script = new ClassNode('Walk', ACC_PUBLIC, ClassHelper.SCRIPT_TYPE)
+        def block = new BlockStatement()
+        block.addStatement(expression(assign('c', new ClosureExpression(Parameter.EMPTY_ARRAY, EmptyStatement.INSTANCE))))
+        block.addStatement(expression(assign('l', new LambdaExpression(Parameter.EMPTY_ARRAY, EmptyStatement.INSTANCE))))
+        block.addStatement(expression(new BinaryExpression(new ConstantExpression(1), Token.newSymbol(Types.PLUS, -1, -1), new ConstantExpression(2))))
+        def noOperation = new BinaryExpression(new VariableExpression('q'), Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)
+        BinaryExpression.getDeclaredField('operation').with {
+            accessible = true
+            set(noOperation, null)
+        }
+        block.addStatement(expression(noOperation))
+        block.addStatement(expression(new DeclarationExpression(new VariableExpression(''), Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)))
+        block.addStatement(expression(new DeclarationExpression(new VariableExpression((String) null), Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)))
+        block.addStatement(expression(new DeclarationExpression(new TupleExpression([new VariableExpression('t')]), Token.newSymbol(Types.ASSIGN, -1, -1), new TupleExpression())))
+        script.addMethod(new MethodNode('run', ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, block))
+        ScriptNames.addScriptAssignments(script, names)
+        assert names.contains('c')
+        assert names.contains('l')
+        assert names.contains('t')
+        assert !names.contains('')
+    }
+
+    private static ExpressionStatement expression(Expression expression) {
+        new ExpressionStatement(expression)
+    }
+
+    private static BinaryExpression assign(String name, Expression right) {
+        new BinaryExpression(new VariableExpression(name), Token.newSymbol(Types.ASSIGN, -1, -1), right)
+    }
+}
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/WorkspaceLayoutTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/WorkspaceLayoutTest.groovy
index 869180891d..7759836d1d 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/WorkspaceLayoutTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/compile/WorkspaceLayoutTest.groovy
@@ -534,6 +534,32 @@ final class WorkspaceLayoutTest {
     }
 
 
+    @Test
+    void clientImportSettingsBecomeCustomizers() {
+        def both = CompilerSettings.defaults().withExtra([
+                imports: 'java.util.concurrent.ConcurrentHashMap, ,',
+                starImports: ' java.nio.file , ',
+                scriptBaseClass: 'groovy.lang.Script'
+        ]).toConfiguration()
+        assert both.scriptBaseClass == 'groovy.lang.Script'
+        assert both.compilationCustomizers.size() == 1
+        def shell = new GroovyShell(both)
+        assert shell.evaluate('ConcurrentHashMap m = [:]; m != null')
+        assert shell.evaluate('Path p = null; true')
+
+        def baseOnly = CompilerSettings.defaults().withExtra([
+                scriptBaseClass: 'groovy.lang.Script', imports: '', starImports: ''
+        ]).toConfiguration()
+        assert baseOnly.scriptBaseClass == 'groovy.lang.Script'
+        assert baseOnly.compilationCustomizers.empty
+
+        assert CompilerSettings.defaults().withExtra([starImports: 'java.io']).toConfiguration()
+                .compilationCustomizers.size() == 1
+        assert CompilerSettings.defaults().withExtra([imports: ' , ']).toConfiguration()
+                .compilationCustomizers.size() == 1
+        assert CompilerSettings.defaults().toConfiguration().compilationCustomizers.empty
+    }
+
     @Test
     void privateConstructorCanBeInvoked() {
         [PackageGuess].each { Class type ->
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy
index 6b38b347b8..f69cffe1c0 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/engine/GroovyLanguageEngineBufferTest.groovy
@@ -19,8 +19,16 @@
 package org.apache.groovy.lsp.internal.engine
 
 import org.apache.groovy.lsp.internal.LanguageServerContext
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot
 import org.apache.groovy.lsp.internal.compile.CompilerSettings
 import org.apache.groovy.lsp.internal.feature.LanguageFeatures
+import org.apache.groovy.lsp.internal.feature.SemanticTokensService
+import org.apache.groovy.lsp.internal.position.PositionEncoding
+import org.apache.groovy.lsp.internal.workspace.DocumentStore
+import org.apache.groovy.lsp.internal.workspace.TextDocument
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ImportCustomizer
+import org.eclipse.lsp4j.TextDocumentItem
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.Test
 
@@ -120,6 +128,137 @@ final class GroovyLanguageEngineBufferTest {
         def one = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), new GroovyClassLoader())
         def two = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), new GroovyClassLoader())
         assert one != two
+        def loader = new GroovyClassLoader()
+        def before = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader)
+        loader.addURL(URI.create('file:/tmp/lsp-url-stamp.jar').toURL())
+        def afterUrls = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader)
+        assert before != afterUrls
+        def host = new CompilerConfiguration()
+        def withHost = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, host)
+        def otherHost = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, new CompilerConfiguration())
+        assert withHost != otherHost
+        assert withHost == LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, host)
+        def mutated = new CompilerConfiguration()
+        def beforeCustomizers = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, mutated)
+        mutated.addCompilationCustomizers(new ImportCustomizer())
+        def afterCustomizers = LanguageServerContext.fingerprint([], [], CompilerSettings.defaults(), loader, mutated)
+        assert beforeCustomizers != afterCustomizers
+    }
+
+    @Test
+    void hostCompilerConfigurationImportsAreVisibleOnTheNextQuery() {
+        engine = new GroovyLanguageEngine()
+        engine.openBuffer(BUFFER, 'groovy', 'ConcurrentHashMap m\n')
+        def before = engine.diagnostics(BUFFER)
+        assert before.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+        def configuration = new CompilerConfiguration()
+        def imports = new ImportCustomizer()
+        imports.addImports('java.util.concurrent.ConcurrentHashMap')
+        configuration.addCompilationCustomizers(imports)
+        engine.setHostConfiguration(configuration)
+        assert engine.context().hostConfiguration.is(configuration)
+        configuration.scriptBaseClass = 'groovy.lang.Script'
+        def copied = CompilerSettings.defaults().toConfiguration(configuration)
+        assert copied.compilationCustomizers.contains(imports)
+        assert copied.scriptBaseClass == 'groovy.lang.Script'
+        assert copied.tolerance == 0
+        def after = engine.diagnostics(BUFFER)
+        assert !after.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+        engine.setHostConfiguration(null)
+        def reset = engine.diagnostics(BUFFER)
+        assert reset.any { (it.message() ?: '').contains('ConcurrentHashMap') }
+    }
+
+    @Test
+    void sameTextKeepsTheBufferVersion() {
+        engine = new GroovyLanguageEngine()
+        engine.openBuffer(BUFFER, 'groovy', 'class A {}')
+        int version = engine.context().documents.get(BUFFER).version
+        engine.updateBuffer(BUFFER, 'class A {}')
+        assert engine.context().documents.get(BUFFER).version == version
+        engine.diagnostics(BUFFER, 'class A {}')
+        assert engine.context().documents.get(BUFFER).version == version
+        engine.updateBuffer(BUFFER, '')
+        assert engine.context().documents.get(BUFFER).version == version + 1
+        engine.updateBuffer(BUFFER, null)
+        assert engine.context().documents.get(BUFFER).text == ''
... 540 lines suppressed ...