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/09/26 11:58:33 UTC

(groovy) branch GROOVY-12411 updated (292fc1bb97 -> 85293321e6)

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

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


    omit 292fc1bb97 GROOVY-12411: Add a first-party Language Server Protocol implementation (groovy-lsp)
     add 95a2157b89 GROOVY-11964: add label and rest-binding to `DeclarationExpression` text
     add 04e40a6008 GROOVY-12416: SecureASTCustomizer indirect import check does not box primitive receiver types
     add c30eef307b GROOVY-12418: static extension methods are never registered on Android
     add ac2d6d803b GROOVY-12418: skip the System.Logger extension methods where the type is absent
     add b651968070 GROOVY-12419: reachability metadata omits ImmutableASTTransformation.checkPropNames
     add 46ae858d9b GROOVY-12421: Bump jline to 4.4.5
     add 1768e998d5 GROOVY-12422: Bump maven-resolver-supplier-mvn4 to 2.0.23
     add 25c467e827 GROOVY-12423: Bump asciidoctorj-pdf to 2.3.27
     add 40e59c5c11 GROOVY-12424: Bump versions gradle plugin to 0.64.0
     add 039af83dc9 format tweaks for swingbuilder widgets
     add 5a130715f3 Bump actions/setup-java from 6.0.0 to 6.0.1
     add 0520ad61a2 Bump github/codeql-action from 4.37.9 to 4.38.0
     add 053b9214b1 styling tweaks
     add 083406352c styling tweaks
     add 3c6fbc4f2f bump versions
     add cafab9086c GROOVY-12430: Bump logback to 1.6.4 (test dependency)
     add ac3babdee1 GROOVY-12432: Bump jackson to 2.22.3
     add 62467aaaf6 Bump gradle/wrapper version to 9.8.0
     new 8d89134e70 GROOVY-12411: Add a first-party Language Server Protocol implementation (groovy-lsp)
     new 85293321e6 GROOVY-12411: Fold STC into groovy-lsp core and drop the public SPI

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   (292fc1bb97)
            \
             N -- N -- N   refs/heads/GROOVY-12411 (85293321e6)

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

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

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


Summary of changes:
 .github/workflows/codeql-analysis.yml              |     8 +-
 .github/workflows/grails-joint-validation.yml      |     2 +-
 .github/workflows/groovy-build-artifacts.yml       |     2 +-
 .github/workflows/groovy-build-coverage.yml        |     2 +-
 .github/workflows/groovy-build-dist.yml            |     2 +-
 .github/workflows/groovy-build-performance.yml     |     2 +-
 .github/workflows/groovy-build-test.yml            |     8 +-
 .github/workflows/groovy-jmh-adhoc.yml             |     2 +-
 .github/workflows/groovy-jmh-classic.yml           |     2 +-
 .github/workflows/groovy-jmh-daily.yml             |     2 +-
 .github/workflows/groovy-jmh.yml                   |     2 +-
 .github/workflows/groovy-lint-gate.yml             |     2 +-
 .github/workflows/groovy-perf-daily.yml            |     2 +-
 .github/workflows/groovy-rat-check.yml             |     2 +-
 .github/workflows/micronaut-joint-validation.yml   |     2 +-
 .github/workflows/validate-actions.yml             |     2 +-
 LICENSE                                            |    42 +
 .../groovy/org.apache.groovy-asciidoctor.gradle    |    18 +-
 .../main/groovy/org.apache.groovy-common.gradle    |     3 +
 .../gradle/GenerateSwingBuilderWidgetDocs.groovy   |   108 +-
 build.gradle                                       |     2 +-
 gradle.properties                                  |     4 +-
 gradle/verification-metadata.xml                   |    13 +-
 gradle/wrapper/gradle-wrapper.jar                  |   Bin 43583 -> 43764 bytes
 gradle/wrapper/gradle-wrapper.properties           |     4 +-
 gradlew                                            |     9 +-
 gradlew.bat                                        |     4 +-
 licenses/LICENSE-DOC                               |    42 +
 licenses/fontawesome-DOC-SRC.txt                   |    10 +
 licenses/fontawesome-license.txt                   |    35 +
 licenses/prettify-DOC-SRC.txt                      |    11 +
 licenses/webfonts-DOC-SRC.txt                      |    12 +
 licenses/webfonts-license.txt                      |    98 +
 .../groovy/ast/expr/DeclarationExpression.java     |    15 +-
 .../codehaus/groovy/ast/expr/TupleExpression.java  |    36 +-
 .../control/customizers/SecureASTCustomizer.java   |    18 +-
 .../codehaus/groovy/reflection/CachedClass.java    |     7 +
 .../groovy/runtime/DefaultGroovyStaticMethods.java |    22 +-
 .../groovy/tools/NativeImageMetadataGenerator.java |     2 +
 .../org/codehaus/groovy/vmplugin/v9/Java9.java     |    15 +
 src/spec/doc/assets/css/fontawesome.min.css        |    14 +
 src/spec/doc/assets/css/style.css                  |    19 +-
 src/spec/doc/assets/css/webfonts.css               |    80 +
 src/spec/doc/assets/fonts/fa701/fa-solid-900.woff2 |   Bin 0 -> 113260 bytes
 .../fonts/googlefonts/open-sans-latin-ext.woff2    |   Bin 0 -> 35156 bytes
 .../assets/fonts/googlefonts/open-sans-latin.woff2 |   Bin 0 -> 48320 bytes
 .../googlefonts/source-code-pro-latin-ext.woff2    |   Bin 0 -> 33568 bytes
 .../fonts/googlefonts/source-code-pro-latin.woff2  |   Bin 0 -> 22044 bytes
 src/spec/doc/assets/js/prettify/prettify.min.css   |     1 +
 .../doc/assets/js/prettify/run_prettify.min.js     |     1 +
 .../ast/expr/DeclarationExpressionTest.groovy      |    40 +
 .../customizers/SecureASTCustomizerTest.groovy     |    27 +
 .../groovy/reflection/CachedClassTest.groovy       |    77 +
 .../runtime/DefaultGroovyStaticMethodsTest.groovy  |    34 +
 .../asciidoctor/DocLinkExtensionRegistry.java      |     2 +-
 .../groovy/asciidoctor/DocLinkMacroProcessor.java  |    97 +-
 subprojects/groovy-lsp/AGENTS.md                   |    15 +-
 subprojects/groovy-lsp/ARCHITECTURE.md             |    72 +-
 .../apache/groovy/lsp/GroovyLanguageServer.java    |     4 +-
 .../apache/groovy/lsp/internal/ExtensionHost.java  |   219 -
 .../groovy/lsp/internal/LanguageServerContext.java |    52 +-
 .../lsp/internal/compile/CompilerSettings.java     |    73 +-
 .../lsp/internal/compile/GroovyCompiler.java       |    44 +-
 .../groovy/lsp/internal/feature/TypeInference.java |     2 +-
 ...gExtension.java => StaticTypeCheckingPass.java} |    38 +-
 .../lsp/internal/protocol/ClientFeatures.java      |     5 +
 .../protocol/GroovyTextDocumentService.java        |     3 +-
 .../internal/protocol/GroovyWorkspaceService.java  |    97 +-
 .../internal/protocol/ServerCapabilityFactory.java |    29 +-
 .../apache/groovy/lsp/spi/GroovyLspExtension.java  |   184 -
 .../apache/groovy/lsp/spi/GroovyLspSession.java    |   112 -
 .../org/apache/groovy/lsp/spi/package-info.java    |    33 -
 .../org.apache.groovy.lsp.spi.GroovyLspExtension   |    19 -
 .../groovy-lsp/src/spec/doc/groovy-lsp.adoc        |    50 +-
 .../groovy/lsp/internal/HelperCoverageTest.groovy  |   147 +-
 .../apache/groovy/lsp/internal/LspFixture.groovy   |    27 +
 .../intelligence/StaticTypeCheckingPassTest.groovy |   232 +
 .../internal/protocol/ProtocolCoverageTest.groovy  |    15 +-
 .../lsp/internal/spi/GroovyLspExtensionTest.groovy |   250 -
 .../src/spec/doc/_swing-builder-widgets.adoc       | 17355 ++++++++++---------
 .../tests-native/src/main/groovy/probe.groovy      |    18 +
 .../tests-native/reachability-metadata.json        |    86 +-
 versions.properties                                |    16 +-
 83 files changed, 11079 insertions(+), 9084 deletions(-)
 create mode 100644 licenses/fontawesome-DOC-SRC.txt
 create mode 100644 licenses/fontawesome-license.txt
 create mode 100644 licenses/prettify-DOC-SRC.txt
 create mode 100644 licenses/webfonts-DOC-SRC.txt
 create mode 100644 licenses/webfonts-license.txt
 create mode 100644 src/spec/doc/assets/css/fontawesome.min.css
 create mode 100644 src/spec/doc/assets/css/webfonts.css
 create mode 100644 src/spec/doc/assets/fonts/fa701/fa-solid-900.woff2
 create mode 100644 src/spec/doc/assets/fonts/googlefonts/open-sans-latin-ext.woff2
 create mode 100644 src/spec/doc/assets/fonts/googlefonts/open-sans-latin.woff2
 create mode 100644 src/spec/doc/assets/fonts/googlefonts/source-code-pro-latin-ext.woff2
 create mode 100644 src/spec/doc/assets/fonts/googlefonts/source-code-pro-latin.woff2
 create mode 100644 src/spec/doc/assets/js/prettify/prettify.min.css
 create mode 100644 src/spec/doc/assets/js/prettify/run_prettify.min.js
 create mode 100644 src/test/groovy/org/codehaus/groovy/reflection/CachedClassTest.groovy
 delete mode 100644 subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
 rename subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/{StaticTypeCheckingExtension.java => StaticTypeCheckingPass.java} (80%)
 delete mode 100644 subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java
 delete mode 100644 subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java
 delete mode 100644 subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java
 delete mode 100644 subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension
 create mode 100644 subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy
 delete mode 100644 subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy


(groovy) 01/02: GROOVY-12411: Add a first-party Language Server Protocol implementation (groovy-lsp)

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-12411
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 8d89134e709378f974020b6081c8fa3b7f05f75e
Author: Daniel Sun <su...@apache.org>
AuthorDate: Tue Sep 15 23:20:55 2026 +0900

    GROOVY-12411: Add a first-party Language Server Protocol implementation (groovy-lsp)
---
 AGENTS.md                                          |    1 +
 ARCHITECTURE.md                                    |   10 +-
 config/checkstyle/gate-suppressions.xml            |    5 +
 gradle/verification-metadata.xml                   |   11 +
 licenses/LICENSE-BINZIP                            |   12 +
 licenses/LICENSE-SDK                               |   12 +
 licenses/lsp4j-BINZIP.txt                          |    9 +
 licenses/lsp4j-license.txt                         |  266 ++++
 notices/NOTICE-BINZIP                              |    7 +
 notices/NOTICE-SDK                                 |    7 +
 notices/lsp4j-BINZIP.txt                           |    6 +
 settings.gradle                                    |    1 +
 src/bin/groovylsp                                  |   46 +
 src/bin/groovylsp.bat                              |   34 +
 src/main/java/org/codehaus/groovy/ast/ASTNode.java |   22 +-
 .../groovy/control/messages/LocatedMessage.java    |    9 +-
 .../control/messages/LocatedMessageTest.groovy     |   39 +
 subprojects/groovy-binary/build.gradle             |    1 +
 subprojects/groovy-binary/src/spec/doc/index.adoc  |    2 +
 subprojects/groovy-lsp/AGENTS.md                   |   56 +
 subprojects/groovy-lsp/ARCHITECTURE.md             |  253 ++++
 subprojects/groovy-lsp/README.adoc                 |   29 +
 subprojects/groovy-lsp/build.gradle                |   45 +
 .../apache/groovy/lsp/GroovyLanguageServer.java    |  132 ++
 .../groovy/lsp/GroovyLanguageServerLauncher.java   |  135 ++
 .../apache/groovy/lsp/internal/ExtensionHost.java  |  219 ++++
 .../groovy/lsp/internal/LanguageServerContext.java |  524 ++++++++
 .../groovy/lsp/internal/compile/AstQuery.java      |  374 ++++++
 .../lsp/internal/compile/CompilationSnapshot.java  |  116 ++
 .../lsp/internal/compile/CompiledDocument.java     |  137 +++
 .../lsp/internal/compile/CompilerSettings.java     |  155 +++
 .../lsp/internal/compile/GroovyCompiler.java       |  440 +++++++
 .../groovy/lsp/internal/compile/Identifiers.java   |  152 +++
 .../groovy/lsp/internal/compile/ImportSupport.java |  311 +++++
 .../groovy/lsp/internal/compile/JavaMessage.java   |   32 +
 .../lsp/internal/compile/JavaSymbolIndex.java      |  261 ++++
 .../lsp/internal/compile/LspJavaCompiler.java      |  247 ++++
 .../groovy/lsp/internal/compile/PackageGuess.java  |   82 ++
 .../groovy/lsp/internal/compile/TypeIndex.java     |  156 +++
 .../lsp/internal/compile/WorkspaceLayout.java      |  150 +++
 .../lsp/internal/compile/WorkspaceScanner.java     |   95 ++
 .../internal/diagnostic/DiagnosticConverter.java   |  156 +++
 .../lsp/internal/feature/CodeActionService.java    |  542 +++++++++
 .../lsp/internal/feature/CompletionService.java    |  538 ++++++++
 .../lsp/internal/feature/HierarchyService.java     |  365 ++++++
 .../groovy/lsp/internal/feature/HoverService.java  |  223 ++++
 .../lsp/internal/feature/NavigationService.java    | 1005 +++++++++++++++
 .../groovy/lsp/internal/feature/RenameService.java |  297 +++++
 .../lsp/internal/feature/SourceGeneration.java     |  310 +++++
 .../lsp/internal/feature/SupportServices.java      | 1285 ++++++++++++++++++++
 .../lsp/internal/feature/SymbolIdentity.java       |  277 +++++
 .../groovy/lsp/internal/feature/SymbolService.java |  227 ++++
 .../groovy/lsp/internal/feature/TypeInference.java |  151 +++
 .../intelligence/StaticTypeCheckingExtension.java  |  128 ++
 .../lsp/internal/position/PositionEncoding.java    |   90 ++
 .../groovy/lsp/internal/position/Positions.java    |  426 +++++++
 .../lsp/internal/protocol/ClientFeatures.java      |   77 ++
 .../protocol/GroovyTextDocumentService.java        |  457 +++++++
 .../internal/protocol/GroovyWorkspaceService.java  |  229 ++++
 .../internal/protocol/ServerCapabilityFactory.java |  150 +++
 .../groovy/lsp/internal/util/GroovyKeywords.java   |  137 +++
 .../lsp/internal/util/GroovySourceTokens.java      |  111 ++
 .../org/apache/groovy/lsp/internal/util/Uris.java  |  203 ++++
 .../lsp/internal/workspace/DocumentStore.java      |  134 ++
 .../lsp/internal/workspace/TextDocument.java       |  199 +++
 .../apache/groovy/lsp/spi/GroovyLspExtension.java  |  184 +++
 .../apache/groovy/lsp/spi/GroovyLspSession.java    |  112 ++
 .../org/apache/groovy/lsp/spi/package-info.java    |   33 +
 .../org.apache.groovy.lsp.spi.GroovyLspExtension   |   19 +
 .../groovy-lsp/src/spec/doc/groovy-lsp.adoc        |  247 ++++
 .../groovy/lsp/GroovyLanguageServerTest.groovy     |  221 ++++
 .../groovy/lsp/internal/HelperCoverageTest.groovy  |  366 ++++++
 .../apache/groovy/lsp/internal/LspFixture.groovy   |  160 +++
 .../lsp/internal/compile/CompilerSafetyTest.groovy |  123 ++
 .../compile/JointCompilationLspTest.groovy         |  117 ++
 .../compile/JointCompileCoverageTest.groovy        |  224 ++++
 .../internal/compile/WorkspaceLayoutTest.groovy    |  141 +++
 .../diagnostic/DiagnosticConverterTest.groovy      |   87 ++
 .../feature/ConservativeNavigationTest.groovy      |  270 ++++
 .../internal/feature/FeatureCoverageTest.groovy    |  253 ++++
 .../lsp/internal/feature/JdtInspiredTest.groovy    |  161 +++
 .../internal/feature/LanguageQualityTest.groovy    |  220 ++++
 .../lsp/internal/feature/MetalsInspiredTest.groovy |  330 +++++
 .../feature/NavigationExecutionTest.groovy         |  171 +++
 .../internal/feature/NavigationIdentityTest.groovy |  219 ++++
 .../internal/feature/SupportServicesTest.groovy    |  287 +++++
 .../lsp/internal/position/PositionsTest.groovy     |  163 +++
 .../internal/protocol/ProtocolCoverageTest.groovy  |  213 ++++
 .../lsp/internal/spi/GroovyLspExtensionTest.groovy |  250 ++++
 .../lsp/internal/util/GroovyKeywordsTest.groovy    |   48 +
 .../internal/util/GroovySourceTokensTest.groovy    |   55 +
 .../groovy/lsp/internal/util/UrisTest.groovy       |   68 ++
 .../internal/workspace/DocumentStoreTest.groovy    |  116 ++
 versions.properties                                |    1 +
 94 files changed, 17162 insertions(+), 15 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 5067d09427..77f659c8e6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -297,6 +297,7 @@ guide when working in its directory tree.
 | Subproject | Scope |
 |---|---|
 | [`groovy-groovysh`](subprojects/groovy-groovysh/AGENTS.md) | Interactive REPL — vendored JLine forks, terminal-aware test infrastructure, JLine bump procedure (subproject `ARCHITECTURE.md` is the canonical contributor map) |
+| [`groovy-lsp`](subprojects/groovy-lsp/AGENTS.md) | Language Server Protocol — LSP4J transport, compiler-backed features, position encoding, capability policy (subproject `ARCHITECTURE.md` is the canonical contributor map) |
 
 ## Where to ask
 
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b4e34f0b2a..1bfd746a79 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -56,7 +56,9 @@ are kept stable for compatibility.
 Some subprojects with significant local complexity carry their own
 `ARCHITECTURE.md` alongside the source — see
 [`subprojects/groovy-groovysh/ARCHITECTURE.md`](subprojects/groovy-groovysh/ARCHITECTURE.md)
-for the worked example.
+and
+[`subprojects/groovy-lsp/ARCHITECTURE.md`](subprojects/groovy-lsp/ARCHITECTURE.md)
+for the worked examples.
 
 ## Compilation pipeline
 
@@ -159,7 +161,7 @@ verbatim keeps the reference precise; paraphrasing tends to drift.
   source of equality and resolution bugs.
 - Authored AST nodes carry source positions (`lineNumber` /
   `columnNumber` / `lastLineNumber` / `lastColumnNumber`; 1-based,
-  exclusive end column). Stamp them through
+  exclusive end column, Unicode code points). Stamp them through
   `PositionConfigureUtils` — do not copy an unpositioned node's end
   onto an authored parent (`DefaultGroovyMethods.asBoolean(Object)` is
   non-null, so a synthetic `ConstantExpression` would otherwise wipe
@@ -169,7 +171,9 @@ verbatim keeps the reference precise; paraphrasing tends to drift.
   singleton, `EmptyExpression.INSTANCE` / `EmptyStatement.INSTANCE`,
   TWR desugaring (`__$$tN`), `@Trait`, and `<clinit>`. Tooling tells
   authored from generated by `lineNumber > 0`
-  (`SecureASTCustomizer.isFromSource`).
+  (`SecureASTCustomizer.isFromSource`). Identifier selection for
+  rename lives in `groovy-lsp` (`Positions.toNameRange`), not as
+  extra fields on `AnnotatedNode`.
 - Visitors: `GroovyCodeVisitor` (expression + statement),
   `GroovyClassVisitor` (class members), with `ClassCodeVisitorSupport`
   / `CodeVisitorSupport` as bases, and
diff --git a/config/checkstyle/gate-suppressions.xml b/config/checkstyle/gate-suppressions.xml
index 7ff82a3ee4..b6e4041115 100644
--- a/config/checkstyle/gate-suppressions.xml
+++ b/config/checkstyle/gate-suppressions.xml
@@ -86,5 +86,10 @@
     <suppress id="FullyQualifiedName" files="src/main/java/org/codehaus/groovy/vmplugin/v7/IndyInterface\.java$"/>
     <!-- Java8 imports org.codehaus.groovy.ast.Parameter and reads java.lang.reflect.Parameters as well -->
     <suppress id="FullyQualifiedName" files="src/main/java/org/codehaus/groovy/vmplugin/v8/Java8\.java$"/>
+    <!-- DiagnosticConverter imports lsp4j Diagnostic and maps compiler Diagnostic -->
+    <suppress id="FullyQualifiedName" files="subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/diagnostic/DiagnosticConverter\.java$"/>
+    <!-- groovy-lsp AstQuery is named after the compiler AstQuery it wraps -->
+    <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$"/>
 
 </suppressions>
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 7531d5f374..cdb6c645cf 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -79,6 +79,7 @@
          <trust file=".*-sources[.]jar" regex="true" reason="IDEs download sources jars during Gradle import; they are not on compile/runtime classpaths"/>
       </trusted-artifacts>
       <ignored-keys>
+         <ignored-key id="386F8FCD70E6AF64" reason="Key couldn't be downloaded from any key server"/>
          <ignored-key id="80EF47E549604C05" reason="Key couldn't be downloaded from any key server"/>
          <ignored-key id="91C65920226E26BD" reason="Key couldn't be downloaded from any key server"/>
       </ignored-keys>
@@ -2671,6 +2672,16 @@
             <sha512 value="1e71c38fda4f794a7753e38c79091b4156d50d5319bbdca1b5233b7aa8bdca86cad53d4402ad69da497f5ba695ecafc9954be8e7f6ca725a1c82d852fa8a199f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
          </artifact>
       </component>
+      <component group="org.eclipse.lsp4j" name="org.eclipse.lsp4j" version="1.0.0">
+         <artifact name="org.eclipse.lsp4j-1.0.0.jar">
+            <sha512 value="056f55320b58a3ff1361c7eba6ec3b26210de063f04a2a5fd5260307abc09e1a5523fc27d5ec0dc9ebc5d2175299fd1bea49bf97bc86b850d4f591d6d447c9fa" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
+         </artifact>
+      </component>
+      <component group="org.eclipse.lsp4j" name="org.eclipse.lsp4j.jsonrpc" version="1.0.0">
+         <artifact name="org.eclipse.lsp4j.jsonrpc-1.0.0.jar">
+            <sha512 value="62b2ac5c4d7950f5f4330d5683fbabe801e377bb04a36cea715d9d071a0109ddc123d10e9e0225c05e7b38b53060ff13d2d3fe5121d6a8aa5b8e68d7d16f1892" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
+         </artifact>
+      </component>
       <component group="org.eclipse.sisu" name="org.eclipse.sisu.inject" version="0.9.0.M3">
          <artifact name="org.eclipse.sisu.inject-0.9.0.M3.jar">
             <sha512 value="76267078f3dd887fd7c4a0931d0ae057d5f51478f712ce4bb5530cfa5c2aa18a8dbe935b32c1afb01a4e2cde44d30579baf5868b8b63ee066ea0714fd9d8a81c" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
diff --git a/licenses/LICENSE-BINZIP b/licenses/LICENSE-BINZIP
index e945dbb540..f23e78c49c 100644
--- a/licenses/LICENSE-BINZIP
+++ b/licenses/LICENSE-BINZIP
@@ -304,6 +304,18 @@ Eclipse Public License v2.0.  For details, see licenses/junit5-license.txt.
 
 ------------------------------------------------------------------------
 
+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.
+
+Those artifacts are licensed under the Eclipse Public License 2.0.
+See licenses/lsp4j-license.txt for the full license text.
+The source of those artifacts is not mixed into Apache Groovy's source tree.
+
+------------------------------------------------------------------------
+
 MXParser License (dependency of the optional XStream dependency
 used when serializing AST as XML)
 
diff --git a/licenses/LICENSE-SDK b/licenses/LICENSE-SDK
index 7aed8b9f4e..c7cf037cb3 100644
--- a/licenses/LICENSE-SDK
+++ b/licenses/LICENSE-SDK
@@ -304,6 +304,18 @@ Eclipse Public License v2.0.  For details, see licenses/junit5-license.txt.
 
 ------------------------------------------------------------------------
 
+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.
+
+Those artifacts are licensed under the Eclipse Public License 2.0.
+See licenses/lsp4j-license.txt for the full license text.
+The source of those artifacts is not mixed into Apache Groovy's source tree.
+
+------------------------------------------------------------------------
+
 MXParser License (dependency of the optional XStream dependency
 used when serializing AST as XML)
 
diff --git a/licenses/lsp4j-BINZIP.txt b/licenses/lsp4j-BINZIP.txt
new file mode 100644
index 0000000000..56cc889a94
--- /dev/null
+++ b/licenses/lsp4j-BINZIP.txt
@@ -0,0 +1,9 @@
+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.
+
+Those artifacts are licensed under the Eclipse Public License 2.0.
+See licenses/lsp4j-license.txt for the full license text.
+The source of those artifacts is not mixed into Apache Groovy's source tree.
diff --git a/licenses/lsp4j-license.txt b/licenses/lsp4j-license.txt
new file mode 100644
index 0000000000..51fc4a979d
--- /dev/null
+++ b/licenses/lsp4j-license.txt
@@ -0,0 +1,266 @@
+Eclipse Public License - v 2.0
+
+    THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+    PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+    OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+  a) in the case of the initial Contributor, the initial content
+     Distributed under this Agreement, and
+
+  b) in the case of each subsequent Contributor:
+     i) changes to the Program, and
+     ii) additions to the Program;
+  where such changes and/or additions to the Program originate from
+  and are Distributed by that particular Contributor. A Contribution
+  "originates" from a Contributor if it was added to the Program by
+  such Contributor itself or anyone acting on such Contributor's behalf.
+  Contributions do not include changes or additions to the Program that
+  are not Modified Works.
+
+"Contributor" means any person or entity that Distributes the Program.
+
+"Licensed Patents" mean patent claims licensable by a Contributor which
+are necessarily infringed by the use or sale of its Contribution alone
+or when combined with the Program.
+
+"Program" means the Contributions Distributed in accordance with this
+Agreement.
+
+"Recipient" means anyone who receives the Program under this Agreement
+or any Secondary License (as applicable), including Contributors.
+
+"Derivative Works" shall mean any work, whether in Source Code or other
+form, that is based on (or derived from) the Program and for which the
+editorial revisions, annotations, elaborations, or other modifications
+represent, as a whole, an original work of authorship.
+
+"Modified Works" shall mean any work in Source Code or other form that
+results from an addition to, deletion from, or modification of the
+contents of the Program, including, for purposes of clarity any new file
+in Source Code form that contains any contents of the Program. Modified
+Works shall not include works that remain separable from, or merely
+link (or bind by name) to the interfaces of, the Program and Derivative
+Works thereof.
+
+"Distribute" means the acts of a) distributing or b) making available
+in any manner that enables the transfer of a copy.
+
+"Source Code" means the form of a Program preferred for making
+modifications, including but not limited to software source code,
+documentation source, and configuration files.
+
+"Secondary License" means either the GNU General Public License,
+Version 2.0, or any later versions of that license, including any
+exceptions or additional permissions as identified by the initial
+Contributor.
+
+2. GRANT OF RIGHTS
+
+  a) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free copyright
+  license to reproduce, prepare Derivative Works of, publicly display,
+  publicly perform, Distribute and sublicense the Contribution of such
+  Contributor, if any, and such Derivative Works.
+
+  b) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free patent
+  license under Licensed Patents to make, use, sell, offer to sell,
+  import and otherwise transfer the Contribution of such Contributor,
+  if any, in Source Code or other form. This patent license shall
+  apply to the combination of the Contribution and the Program if, at
+  the time the Contribution is added by the Contributor, such addition
+  of the Contribution causes such combination to be covered by the
+  Licensed Patents. The patent license shall not apply to any other
+  combinations which include the Contribution. No hardware per se is
+  licensed hereunder.
+
+  c) Recipient understands that although each Contributor grants the
+  licenses to its Contributions set forth herein, no assurances are
+  provided by any Contributor that the Program does not infringe the
+  patent or other intellectual property rights of any other entity.
+  Each Contributor disclaims any liability to Recipient for claims
+  brought by any other entity based on infringement of intellectual
+  property rights or otherwise. As a condition to exercising the
+  rights and licenses granted hereunder, each Recipient hereby
+  assumes sole responsibility to secure any other intellectual
+  property rights needed, if any. For example, if a third party
+  patent license is required to allow Recipient to Distribute the
+  Program, it is Recipient's responsibility to acquire that license
+  before distributing the Program.
+
+  d) Each Contributor represents that to its knowledge it has
+  sufficient copyright rights in its Contribution, if any, to grant
+  the copyright license set forth in this Agreement.
+
+  e) Notwithstanding the terms of any Secondary License, no
+  Contributor makes additional grants to any Recipient (other than
+  those set forth in this Agreement) as a result of such Recipient's
+  receipt of the Program under the terms of a Secondary License
+  (if permitted under the terms of Section 3).
+
+3. REQUIREMENTS
+
+3.1 If a Contributor Distributes the Program in any form, then:
+
+  a) the Program must also be made available as Source Code, in
+  accordance with section 3.2, and the Contributor must accompany
+  the Program with a statement that the Source Code for the Program
+  is available under this Agreement, and informs Recipients how to
+  obtain it in a reasonable manner on or through a medium customarily
+  used for software exchange; and
+
+  b) the Contributor may Distribute the Program under a license
+  different than this Agreement, provided that such license:
+     i) effectively disclaims on behalf of all other Contributors all
+     warranties and conditions, express and implied, including
+     warranties or conditions of title and non-infringement, and
+     implied warranties or conditions of merchantability and fitness
+     for a particular purpose;
+
+     ii) effectively excludes on behalf of all other Contributors all
+     liability for damages, including direct, indirect, special,
+     incidental and consequential damages, such as lost profits;
+
+     iii) does not attempt to limit or alter the recipients' rights
+     in the Source Code under section 3.2; and
+
+     iv) requires any subsequent distribution of the Program by any
+     party to be under a license that satisfies the requirements
+     of this section 3.
+
+3.2 When the Program is Distributed as Source Code:
+
+  a) it must be made available under this Agreement, or if the
+  Program (i) is combined with other material in a separate file or
+  files made available under a Secondary License, and (ii) the initial
+  Contributor attached to the Source Code the notice described in
+  Exhibit A of this Agreement, then the Program may be made available
+  under the terms of such Secondary Licenses, and
+
+  b) a copy of this Agreement must be included with each copy of
+  the Program.
+
+3.3 Contributors may not remove or alter any copyright, patent,
+trademark, attribution notices, disclaimers of warranty, or limitations
+of liability ("notices") contained within the Program from any copy of
+the Program which they Distribute, provided that Contributors may add
+their own appropriate notices.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain responsibilities
+with respect to end users, business partners and the like. While this
+license is intended to facilitate the commercial use of the Program,
+the Contributor who includes the Program in a commercial product
+offering should do so in a manner which does not create potential
+liability for other Contributors. Therefore, if a Contributor includes
+the Program in a commercial product offering, such Contributor
+("Commercial Contributor") hereby agrees to defend and indemnify every
+other Contributor ("Indemnified Contributor") against any losses,
+damages and costs (collectively "Losses") arising from claims, lawsuits
+and other legal actions brought by a third party against the Indemnified
+Contributor to the extent caused by the acts or omissions of such
+Commercial Contributor in connection with its distribution of the Program
+in a commercial product offering. The obligations in this section do not
+apply to any claims or Losses relating to any actual or alleged
+intellectual property infringement. In order to qualify, an Indemnified
+Contributor must: a) promptly notify the Commercial Contributor in
+writing of such claim, and b) allow the Commercial Contributor to control,
+and cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a commercial
+product offering, Product X. That Contributor is then a Commercial
+Contributor. If that Commercial Contributor then makes performance
+claims, or offers warranties related to Product X, those performance
+claims and warranties are such Commercial Contributor's responsibility
+alone. Under this section, the Commercial Contributor would have to
+defend claims against the other Contributors related to those performance
+claims and warranties, and if a court requires any other Contributor to
+pay any damages as a result, the Commercial Contributor must pay
+those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
+BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
+IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
+TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
+PURPOSE. Each Recipient is solely responsible for determining the
+appropriateness of using and distributing the Program and assumes all
+risks associated with its exercise of rights under this Agreement,
+including but not limited to the risks and costs of program errors,
+compliance with applicable laws, damage to or loss of data, programs
+or equipment, and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
+SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
+PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
+EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed to the
+minimum extent necessary to make such provision valid and enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging that the
+Program itself (excluding combinations of the Program with other software
+or hardware) infringes such Recipient's patent(s), then such Recipient's
+rights granted under Section 2(b) shall terminate as of the date such
+litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if it
+fails to comply with any of the material terms or conditions of this
+Agreement and does not cure such failure in a reasonable period of
+time after becoming aware of such noncompliance. If all Recipient's
+rights under this Agreement terminate, Recipient agrees to cease use
+and distribution of the Program as soon as reasonably practicable.
+However, Recipient's obligations under this Agreement and any licenses
+granted by Recipient relating to the Program shall continue and survive.
+
+Everyone is permitted to copy and distribute copies of this Agreement,
+but in order to avoid inconsistency the Agreement is copyrighted and
+may only be modified in the following manner. The Agreement Steward
+reserves the right to publish new versions (including revisions) of
+this Agreement from time to time. No one other than the Agreement
+Steward has the right to modify this Agreement. The Eclipse Foundation
+is the initial Agreement Steward. The Eclipse Foundation may assign the
+responsibility to serve as the Agreement Steward to a suitable separate
+entity. Each new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+Distributed subject to the version of the Agreement under which it was
+received. In addition, after a new version of the Agreement is published,
+Contributor may elect to Distribute the Program (including its
+Contributions) under the new version.
+
+Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
+receives no rights or licenses to the intellectual property of any
+Contributor under this Agreement, whether expressly, by implication,
+estoppel or otherwise. All rights in the Program not expressly granted
+under this Agreement are reserved. Nothing in this Agreement is intended
+to be enforceable by any entity that is not a Contributor or Recipient.
+No third-party beneficiary rights are created under this Agreement.
+
+Exhibit A - Form of Secondary Licenses Notice
+
+"This Source Code may also be made available under the following
+Secondary Licenses when the conditions for such availability set forth
+in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
+version(s), and exceptions or additional permissions here}."
diff --git a/notices/NOTICE-BINZIP b/notices/NOTICE-BINZIP
index c621fd313b..1b037b97e6 100644
--- a/notices/NOTICE-BINZIP
+++ b/notices/NOTICE-BINZIP
@@ -40,6 +40,13 @@ which is available under the terms of the Eclipse Public License v1.0
 This product bundles several of the JUnit5 jars (junit.org)
 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.
+Those artifacts are licensed under the Eclipse Public License 2.0.
+See licenses/lsp4j-license.txt.
+
 This product embeds the OpenBeans jar within its grooid jar artifacts
 OpenBeans includes/uses files from Apache Harmony and the following notice applies
 Copyright 2006, 2010 The Apache Software Foundation.
diff --git a/notices/NOTICE-SDK b/notices/NOTICE-SDK
index 3a579f7388..fce2cdf6ec 100644
--- a/notices/NOTICE-SDK
+++ b/notices/NOTICE-SDK
@@ -40,6 +40,13 @@ which is available under the terms of the Eclipse Public License v1.0
 This product bundles several of the JUnit5 jars (junit.org)
 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.
+Those artifacts are licensed under the Eclipse Public License 2.0.
+See licenses/lsp4j-license.txt.
+
 This product embeds the OpenBeans jar within its grooid jar artifacts
 OpenBeans includes/uses files from Apache Harmony and the following notice applies
 Copyright 2006, 2010 The Apache Software Foundation.
diff --git a/notices/lsp4j-BINZIP.txt b/notices/lsp4j-BINZIP.txt
new file mode 100644
index 0000000000..ebe16b2a60
--- /dev/null
+++ b/notices/lsp4j-BINZIP.txt
@@ -0,0 +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.
+Those artifacts are licensed under the Eclipse Public License 2.0.
+See licenses/lsp4j-license.txt.
diff --git a/settings.gradle b/settings.gradle
index 8c1c4d886b..19c23471e5 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -65,6 +65,7 @@ def subprojects = [
         'groovy-jmx',
         'groovy-json',
         'groovy-jsr223',
+        'groovy-lsp',
         'groovy-callsite',
         'groovy-logging-test',
         'groovy-ginq',
diff --git a/src/bin/groovylsp b/src/bin/groovylsp
new file mode 100755
index 0000000000..43e80cb362
--- /dev/null
+++ b/src/bin/groovylsp
@@ -0,0 +1,46 @@
+#!/usr/bin/env sh
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+##############################################################################
+##                                                                          ##
+##  Groovy Language Server script for UN*X                                  ##
+##                                                                          ##
+##############################################################################
+
+export GROOVY_APP_NAME=GroovyLsp
+
+# resolve links - $0 may be a soft-link
+PRG="$0"
+
+while [ -h "$PRG" ] ; do
+    ls="$(ls -ld "$PRG")"
+    link="$(expr "$ls" : '.*-> \(.*\)$')"
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG="$(dirname "$PRG")"/"$link"
+    fi
+done
+
+DIRNAME="$(dirname "$PRG")"
+
+. "$DIRNAME/startGroovy"
+
+startGroovy org.apache.groovy.lsp.GroovyLanguageServerLauncher "$@"
diff --git a/src/bin/groovylsp.bat b/src/bin/groovylsp.bat
new file mode 100644
index 0000000000..2b4ffef109
--- /dev/null
+++ b/src/bin/groovylsp.bat
@@ -0,0 +1,34 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@if "%DEBUG%" == "" @echo off
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+:begin
+@rem Determine what directory it is in.
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.\
+
+"%DIRNAME%\startGroovy.bat" "%DIRNAME%" org.apache.groovy.lsp.GroovyLanguageServerLauncher %*
+
+@rem End local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" endlocal
+%COMSPEC% /C exit /B %ERRORLEVEL%
diff --git a/src/main/java/org/codehaus/groovy/ast/ASTNode.java b/src/main/java/org/codehaus/groovy/ast/ASTNode.java
index 93479ddc6c..a6bb62638b 100644
--- a/src/main/java/org/codehaus/groovy/ast/ASTNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/ASTNode.java
@@ -96,8 +96,9 @@ public class ASTNode implements NodeMetaDataHandler {
 
     /**
      * Returns the column number where this AST node begins in the source file.
-     * Column numbers are 0-indexed. Returns -1 if position information is unavailable
-     * (for synthetic or generated nodes).
+     * Column numbers are 1-based and count Unicode code points (the lexer reads
+     * an ANTLR {@code CodePointCharStream}). Returns -1 if position information
+     * is unavailable (for synthetic or generated nodes).
      *
      * @return the starting column number, or -1 if not available
      */
@@ -107,7 +108,8 @@ public class ASTNode implements NodeMetaDataHandler {
 
     /**
      * Sets the starting column number for this AST node in the source file.
-     * Column numbers are 0-indexed. Use -1 to indicate unavailable position.
+     * Column numbers are 1-based and count Unicode code points. Use -1 to
+     * indicate unavailable position.
      *
      * @param columnNumber the starting column number to set
      */
@@ -118,7 +120,7 @@ public class ASTNode implements NodeMetaDataHandler {
     /**
      * Returns the line number where this AST node ends in the source file.
      * Line numbers start from 1. Returns -1 if position information is unavailable.
-     * The end position is typically inclusive (last line of the node's span).
+     * The end line is inclusive (the last line of the node's span).
      *
      * @return the ending line number, or -1 if not available
      */
@@ -129,7 +131,7 @@ public class ASTNode implements NodeMetaDataHandler {
     /**
      * Sets the ending line number for this AST node in the source file.
      * Line numbers are 1-indexed. Use -1 to indicate unavailable position.
-     * The end position should typically be inclusive (last line of the node's span).
+     * The end line should be inclusive (the last line of the node's span).
      *
      * @param lastLineNumber the ending line number to set
      */
@@ -139,8 +141,9 @@ public class ASTNode implements NodeMetaDataHandler {
 
     /**
      * Returns the column number where this AST node ends in the source file.
-     * Column numbers are 0-indexed. Returns -1 if position information is unavailable.
-     * The end position is typically exclusive (one past the last character).
+     * Column numbers are 1-based and count Unicode code points. Returns -1 if
+     * position information is unavailable. The end column is exclusive (one
+     * past the last code point of the node's span).
      *
      * @return the ending column number, or -1 if not available
      */
@@ -150,8 +153,9 @@ public class ASTNode implements NodeMetaDataHandler {
 
     /**
      * Sets the ending column number for this AST node in the source file.
-     * Column numbers are 0-indexed. Use -1 to indicate unavailable position.
-     * The end position should typically be exclusive (one past the last character).
+     * Column numbers are 1-based and count Unicode code points. Use -1 to
+     * indicate unavailable position. The end column should be exclusive
+     * (one past the last code point of the node's span).
      *
      * @param lastColumnNumber the ending column number to set
      */
diff --git a/src/main/java/org/codehaus/groovy/control/messages/LocatedMessage.java b/src/main/java/org/codehaus/groovy/control/messages/LocatedMessage.java
index 7dd2803142..e7ed319715 100644
--- a/src/main/java/org/codehaus/groovy/control/messages/LocatedMessage.java
+++ b/src/main/java/org/codehaus/groovy/control/messages/LocatedMessage.java
@@ -68,6 +68,9 @@ public class LocatedMessage extends SimpleMessage {
 
     @Override
     public Diagnostic toDiagnostic() {
+        if (context == null) {
+            return new Diagnostic(sourceName(owner), -1, -1, message);
+        }
         return new Diagnostic(sourceName(owner), context.getStartLine(), context.getStartColumn(), message);
     }
 
@@ -82,9 +85,9 @@ public class LocatedMessage extends SimpleMessage {
         if (owner instanceof SourceUnit source) {
 
             String name = source.getName();
-            int line = context.getStartLine();
-            int column = context.getStartColumn();
-            String sample = source.getSample(line, column, janitor);
+            int line = context == null ? -1 : context.getStartLine();
+            int column = context == null ? -1 : context.getStartColumn();
+            String sample = line > 0 ? source.getSample(line, column, janitor) : null;
 
             if (sample != null) {
                 writer.println(sample);
diff --git a/src/test/groovy/org/codehaus/groovy/control/messages/LocatedMessageTest.groovy b/src/test/groovy/org/codehaus/groovy/control/messages/LocatedMessageTest.groovy
new file mode 100644
index 0000000000..e3319b3b72
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/control/messages/LocatedMessageTest.groovy
@@ -0,0 +1,39 @@
+/*
+ *  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.control.messages
+
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.Janitor
+import org.codehaus.groovy.control.SourceUnit
+import org.junit.jupiter.api.Test
+
+final class LocatedMessageTest {
+
+    @Test
+    void toDiagnosticAllowsMissingContext() {
+        def source = new SourceUnit('A.groovy', 'x', new CompilerConfiguration(), null, null)
+        def message = new WarningMessage(WarningMessage.LIKELY_ERRORS, 'unused import', null, source)
+        def diagnostic = message.toDiagnostic()
+        assert diagnostic.text() == 'unused import'
+        assert diagnostic.line() == -1
+        def writer = new StringWriter()
+        message.write(new PrintWriter(writer), new Janitor())
+        assert writer.toString().contains('unused import')
+    }
+}
diff --git a/subprojects/groovy-binary/build.gradle b/subprojects/groovy-binary/build.gradle
index a2f623257c..4266482580 100644
--- a/subprojects/groovy-binary/build.gradle
+++ b/subprojects/groovy-binary/build.gradle
@@ -124,6 +124,7 @@ distribution {
             include 'commonmark-license.txt'
             include 'hamcrest-license.txt'
             include 'jline3-license.txt'
+            include 'lsp4j-license.txt'
             include 'jquery-js-license.txt'
             include 'jsr223-license.txt'
             include 'jsvg-license.txt'
diff --git a/subprojects/groovy-binary/src/spec/doc/index.adoc b/subprojects/groovy-binary/src/spec/doc/index.adoc
index 59c5856a96..580250031e 100644
--- a/subprojects/groovy-binary/src/spec/doc/index.adoc
+++ b/subprojects/groovy-binary/src/spec/doc/index.adoc
@@ -99,6 +99,8 @@ include::../../../../../subprojects/groovy-console/src/spec/doc/groovy-console.a
 
 include::../../../../../subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc[leveloffset=+2]
 
+include::../../../../../subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc[leveloffset=+2]
+
 include::../../../../../src/spec/doc/tools-ide.adoc[leveloffset=+2]
 
 == User Guides
diff --git a/subprojects/groovy-lsp/AGENTS.md b/subprojects/groovy-lsp/AGENTS.md
new file mode 100644
index 0000000000..30b51d7317
--- /dev/null
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -0,0 +1,56 @@
+<!--
+  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.
+-->
+
+# Agent Guide for groovy-lsp
+
+Subproject-specific supplement to the [root `AGENTS.md`](../../AGENTS.md).
+
+For architecture — layers, capability policy, position encoding,
+compile-without-classgen, scope vs other Groovy LSPs, rename
+guarantees, and the test harness — see
+[`ARCHITECTURE.md`](ARCHITECTURE.md).
+
+Rename of a method *call* is allowed only when `methodTarget` is
+set. Do not add an identifier-text fallback (that is how
+`groovy-language-server-edt`'s AST path rewrites the wrong `foo`).
+
+New Groovy-specific behaviour that is not core language support
+belongs in a `GroovyLspExtension`, not a fork of
+`LanguageServerContext`. Do not add OSGi, extra JSON-RPC methods, or
+a second hover/completion SPI — `configure` / `initialized` /
+`afterCompile` / extra diagnostics (including `ModuleNode`) / extra
+code actions / commands are the extension valves. Inject members in
+`afterCompile` so core hover and completion see them. The SPI must
+not take `internal` types; plugins see `GroovyLspSession` (document
+text, folders, classpath, extra settings, apply-edit) and groovy-core
+AST (`CompilationUnit`, `ModuleNode`).
+
+Do not add language-server fields or visitor special cases to
+groovy-core (`AnnotatedNode` name spans, `AstQuery` parameter walks).
+Identifier ranges and parameter caret hits live in groovy-lsp.
+
+Do not hard-code lexer keywords or default-import packages in
+groovy-lsp. Keywords come from `GroovyLexer.VOCABULARY`;
+default stars from `ResolveVisitor.DEFAULT_IMPORTS`.
+
+## References
+
+- [Root `AGENTS.md`](../../AGENTS.md)
+- [`ARCHITECTURE.md`](ARCHITECTURE.md)
+- [`src/spec/doc/groovy-lsp.adoc`](src/spec/doc/groovy-lsp.adoc)
diff --git a/subprojects/groovy-lsp/ARCHITECTURE.md b/subprojects/groovy-lsp/ARCHITECTURE.md
new file mode 100644
index 0000000000..2e18402ea4
--- /dev/null
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -0,0 +1,253 @@
+<!--
+  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.
+-->
+
+# groovy-lsp — Architecture
+
+Apache Groovy's Language Server Protocol implementation. The compiler AST
+is the source of truth; Eclipse LSP4J is the JSON-RPC transport.
+
+This document is the contributor map for `subprojects/groovy-lsp/`.
+User-facing usage lives in
+[`src/spec/doc/groovy-lsp.adoc`](src/spec/doc/groovy-lsp.adoc).
+
+## Why a first-party server
+
+Community servers (`groovy-language-server`, `gvy`/`gls`,
+`trustytrojan/groovyls`, `tomaszrup/groovy-language-server-edt`),
+`kotlin-lsp`, Metals, and Eclipse JDT LS informed the shape, but
+this module is original Apache License 2.0 code. It compiles with the
+in-tree Groovy compiler rather than a fork, so language changes land
+here automatically.
+
+### What it is for
+
+A **Groovy language core** that editors and AI tools can share:
+
+- Humans in an editor that speaks LSP (VS Code, IntelliJ via an LSP
+  client, Vim, …) get diagnostics, completion, hover, navigation and
+  conservative rename on `.groovy` sources.
+- AI agents get the same snapshot: structure, types where the compiler
+  knows them, and edits that refuse to guess. A later `groovy-mcp`
+  module is expected to wrap this server so a model can ask “what is
+  this identifier?” without re-implementing the compiler.
+
+It is intended for **direct use by an IDE or editor as an LSP
+server**, as the **semantic backend** for Groovy-aware AI, and as the
+**Groovy-AST compile + protocol loop** other Apache-2.0 Groovy LSPs
+can sit on. Plugins implement incubating
+[`GroovyLspExtension`](src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java)
+(`ServiceLoader` or tests register an `ExtensionHost`). That is how
+Jenkins GDSL, a forced STC pass, or `groovy-mcp` add behaviour
+without forking `LanguageServerContext`.
+
+It is not a complete IDE. Eclipse JDT / groovy-eclipse servers
+cannot replace this compiler through the SPI — they remain a
+parallel stack. GDSL and Maven downloads stay in the *plugin*, not
+in this module.
+
+### What it is not
+
+The server does **not** try to replace Groovy-Eclipse, IntelliJ IDEA,
+or a Gradle/Maven-aware IDE. Out of scope:
+
+- A full Java language server (completion, format, and Java-only rename).
+  Joint Groovy+Java *type resolution* uses the in-tree
+  `JavaAwareCompilationUnit` (`memStub`) so Groovy and Java sources
+  in the same workspace can see each other; go-to-definition and
+  javac diagnostics work across that pair. Eclipse JDT / groovy-eclipse
+  remains the stack for joint *IDE* features.
+- GDSL / Jenkins pipeline descriptors
+- Invoking Gradle or Maven (the client pushes `groovy.classpath`)
+- Debugger, test runner, build tool windows
+- Identifier-text “rename every `foo` in the file” fallbacks
+
+Those are the differentiator of `groovy-language-server-edt` (EPL,
+JDT + groovy-eclipse) and, for Jenkins GDSL, `groovyls` (Apache 2.0
+fork of Prominic). This module stays on the Groovy AST.
+
+### Positions (not an LSP break)
+
+GROOVY-12085 made **compiler AST columns** 1-based Unicode code
+points. LSP positions stay 0-based in the negotiated encoding. The
+server converts; clients never see AST columns. Existing LSP clients
+do not need to change.
+
+### Semantic guarantees
+
+| Feature | When `methodTarget` is set | When it is not (typical dynamic Groovy) |
+|---|---|---|
+| Hover | Bound signature | Unique arity match on a resolved receiver, labelled `_inferred_`; otherwise `name(...)` |
+| Definition | That method | Every arity-matching candidate with a source location; never a random first match |
+| Rename / prepareRename | Allowed | **Refused.** A heuristic must not rewrite the wrong overload. |
+
+Declarations (class, method, field, parameter, bound local) rename by
+identity (owner + name + descriptor), not the unqualified identifier.
+
+### Relation to other Groovy language servers
+
+- **Prominic `groovy-language-server`.** Earlier community server;
+  this module is a new implementation on the current compiler, not a
+  fork.
+- **`trustytrojan/groovyls`.** Apache 2.0 fork adding Jenkins GDSL and
+  a forced static-type-checking pass. Designs that fit (initializer
+  types, refuse unresolved rename) are reimplemented here. GDSL and
+  Aether downloads are not.
+- **`tomaszrup/groovy-language-server-edt`.** EPL 2.0, Equinox + JDT +
+  groovy-eclipse: library sources, file-move imports, a full Java LS.
+  Complementary product; none of its source is copied. groovy-lsp
+  joint-compiles with `JavaAwareCompilationUnit` instead of JDT.
+  Its identifier-scan rename fallback is intentionally **not** used.
+
+### AI / MCP
+
+A model using this server (directly over LSP, or later through
+`groovy-mcp`) can trust: document symbols, diagnostics, hover, and
+definition candidates. On `@TypeChecked` / `@CompileStatic` members
+it can also trust STC-bound calls and `INFERRED_TYPE` metadata.
+It cannot yet trust flow-sensitive types on unannotated `def`, Java
+members without a client-supplied classpath, or rename of a dynamic
+call. Wrong edits are worse for an agent than a refused rename.
+
+## Layers
+
+```
+┌──────────────────────────────────────────────┐
+│  GroovyLanguageServer / Launcher (stdio)     │
+├──────────────────────────────────────────────┤
+│  GroovyTextDocumentService                   │
+│  GroovyWorkspaceService                      │
+├──────────────────────────────────────────────┤
+│  feature/*   (completion, hover, nav, …)     │
+├──────────────────────────────────────────────┤
+│  compile/*   (CompilationUnit, AstQuery)     │
+│  position/*  (Groovy 1-based code points ↔   │
+│               LSP encoding)                  │
+│  workspace/* (open buffers)                  │
+└──────────────────────────────────────────────┘
+```
+
+- **Transport.** Eclipse LSP4J 1.0 (LSP 3.18 types). EPL 2.0 Category B
+  binary; not mixed into Groovy sources. See `licenses/lsp4j-BINZIP.txt`.
+- **Compile.** Groovy sources go through
+  `CompilationUnit.compile(Phases.SEMANTIC_ANALYSIS)` with
+  `tolerance = 0` (unlimited). Groovy class generation is skipped.
+  When `.java` files are present, the unit is a
+  `JavaAwareCompilationUnit` with `memStub`: Parrot to CONVERSION,
+  in-memory Java stubs, `javax.tools` javac (`-proc:none`), then Groovy
+  semantic analysis. Javac `.class` output is a per-compile temp
+  directory so `AsmDecompiler` can resolve Java types; the directory is
+  deleted on the next compile. Errors are
+  collected; a partial AST is still queried. `@Grab` is disabled via
+  `disabledGlobalASTTransformations`. `@ASTTest` is a local transform, so
+  it is disabled with `groovy.asttest.enable=false` (and Grape with
+  `groovy.grape.enable=false`) unless the client sets
+  `groovy.astTestEnabled` / `groovy.grapeEnabled`. Diagnostics are
+  filtered to the owning source unit — `CompilationUnit` shares one
+  `ErrorCollector` across files. The `GroovyClassLoader` is reused until
+  the classpath (or Grape / `@ASTTest` flags) change, and closed on
+  session shutdown.
+- **Navigation.** `AstQuery.nodeAt` lifts method/property name
+  `ConstantExpression` leaves to the enclosing call, and the compiler
+  query visits parameters so a caret on `bar` in `def foo(String bar)`
+  hits the parameter. Rename and references match declaration identity
+  (owner + name + descriptor), not the unqualified identifier. Call and
+  type hierarchy incoming/outgoing/super/sub walk the snapshot.
+  Types that resolved from Java sources jump via `JavaSymbolIndex`
+  (javac `Trees`, captured before `generate()`).
+- **Edits.** `groovy.lsp.organizeImports` preserves star, static and
+  alias imports and sorts them. Unused simple imports are a separate
+  "Remove unused imports" action. The organize command applies a
+  `WorkspaceEdit` when the client advertised `workspace.applyEdit`.
+  Extra actions (Metals / JDT LS inspired, original code): remove
+  unused imports, implement abstract methods, insert inferred field
+  types, add a package from the file path, create a missing class
+  stub, generate accessors / toString / equals / constructor, and
+  add {@code @Override}. Reference-count code lenses. Renaming a
+  class whose name matches the file stem also renames the file.
+  Completions of workspace types include `additionalTextEdits`;
+  `completionItem/resolve` fills Groovydoc. Groovy's default imports
+  are not re-inserted. Newline on-type formatting indents the next
+  line. Compile reports `window/workDoneProgress` when the client
+  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 extension ids skips a full recompile when nothing
+  changed. 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` (and friends) plus `lib/*.jar` — it never invokes
+  Gradle or Maven.
+- **Extensions.** `GroovyLspExtension` may `configure` the
+  `CompilerConfiguration` (with a `GroovyLspSession` so extra
+  `groovy.*` settings and folders are visible), run `initialized` /
+  `shutdown`, run `afterCompile` on the live `CompilationUnit`
+  (before the snapshot freezes), add diagnostics (URI + text +
+  optional `ModuleNode`) and code actions (with source text), and
+  handle extra commands through `GroovyLspSession`. The session is
+  apply-edit plus document text, folders, classpath, source paths,
+  extra settings and position encoding — not a compile handle. A
+  throwing plugin is isolated (`id()` / `commands()` included); it
+  must not compile or publish on its own. Hover and completion stay
+  in core: inject AST in `afterCompile`. The server ships a default
+  `groovy.lsp.stc` extension that runs `StaticTypeCheckingVisitor` on
+  `@TypeChecked` / `@CompileStatic` members only and copies
+  `DIRECT_METHOD_CALL_TARGET` onto `methodTarget`. `TypeInference`
+  also reads `StaticTypesMarker.INFERRED_TYPE` when a plugin wrote
+  it.
+- **Semantic tokens.** The advertised legend is append-only. Lexer
+  tokens (`GroovySourceTokens`) fill `keyword` / `string` / `number` /
+  `comment` even when the AST is missing. 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).
+- **Positions.** AST columns are 1-based Unicode code points (see
+  `PositionConfigureUtils` and GROOVY-12085). LSP characters are 0-based
+  in the negotiated encoding (`utf-16` default, `utf-8` / `utf-32`
+  optional). Identifier selection for rename/highlight is
+  `Positions.toNameRange`: it finds the node's name in its source
+  span so groovy-lsp does not add fields to `AnnotatedNode`.
+  Implicit-this calls without `methodTarget` resolve against the enclosing
+  class. Field references keep the declaring type, so two `name` fields
+  do not collide.
+
+## Capability policy
+
+Advertise only what is implemented. Groovy has no document colors or
+notebook cells, so those capabilities are omitted. Incoming 3.19 fields
+the server does not understand are ignored (LSP rule).
+
+## Concurrency
+
+Document store is concurrent. Compilation runs on a single daemon thread
+with 150 ms debounce on `didChange`. Feature requests read the last
+completed `CompilationSnapshot` and do not block the compile thread.
+
+## Testing
+
+In-process: construct `GroovyLanguageServer`, connect a collecting
+`LanguageClient`, `didOpen`, then `context.recompile()` (do not wait on
+the debounce in unit tests). Do not set `initialize.rootUri` to the
+process working directory — that would compile the whole checkout.
+JSON-RPC round-trips are not required to prove feature logic.
+
+## Related
+
+- LSP 3.18: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/
+- LSP 3.19 (draft): https://microsoft.github.io/language-server-protocol/specifications/lsp/3.19/specification/
+- Compiler pipeline: repository-root `ARCHITECTURE.md`
diff --git a/subprojects/groovy-lsp/README.adoc b/subprojects/groovy-lsp/README.adoc
new file mode 100644
index 0000000000..9865f86359
--- /dev/null
+++ b/subprojects/groovy-lsp/README.adoc
@@ -0,0 +1,29 @@
+//////////////////////////////////////////
+
+  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.
+
+//////////////////////////////////////////
+
+= Groovy Language Server Module
+
+Implements the Language Server Protocol for Apache Groovy.
+Editors and AI tools (and a later groovy-mcp wrapper) share this
+language core; it is not a full IDE.
+
+See link:src/spec/doc/groovy-lsp.adoc[groovy-lsp.adoc] for usage
+and link:ARCHITECTURE.md[ARCHITECTURE.md] for the contributor map.
diff --git a/subprojects/groovy-lsp/build.gradle b/subprojects/groovy-lsp/build.gradle
new file mode 100644
index 0000000000..eda47f58da
--- /dev/null
+++ b/subprojects/groovy-lsp/build.gradle
@@ -0,0 +1,45 @@
+/*
+ *  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.
+ */
+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/**'
+    ]
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
new file mode 100644
index 0000000000..7af63e49db
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.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;
+
+import org.apache.groovy.lsp.internal.LanguageServerContext;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.protocol.GroovyTextDocumentService;
+import org.apache.groovy.lsp.internal.protocol.GroovyWorkspaceService;
+import org.apache.groovy.lsp.internal.protocol.ServerCapabilityFactory;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.eclipse.lsp4j.InitializeParams;
+import org.eclipse.lsp4j.InitializeResult;
+import org.eclipse.lsp4j.InitializedParams;
+import org.eclipse.lsp4j.ServerInfo;
+import org.eclipse.lsp4j.WorkspaceFolder;
+import org.eclipse.lsp4j.services.LanguageClient;
+import org.eclipse.lsp4j.services.LanguageClientAware;
+import org.eclipse.lsp4j.services.LanguageServer;
+import org.eclipse.lsp4j.services.TextDocumentService;
+import org.eclipse.lsp4j.services.WorkspaceService;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Apache Groovy language server. Implements LSP 3.18 with capability
+ * negotiation for 3.19-preview clients. Compile-time execution paths
+ * ({@code @Grab}, {@code @ASTTest}) are off unless the client enables them.
+ */
+public final class GroovyLanguageServer implements LanguageServer, LanguageClientAware {
+
+    private final LanguageServerContext context;
+    private final GroovyTextDocumentService textDocuments;
+    private final GroovyWorkspaceService workspace;
+    private final ServerCapabilityFactory capabilities = new ServerCapabilityFactory();
+
+    /**
+     * Creates a server with a fresh session.
+     */
+    public GroovyLanguageServer() {
+        this(new LanguageServerContext());
+    }
+
+    /**
+     * Creates a server around an existing session. Intended for tests.
+     *
+     * @param context session state
+     */
+    public GroovyLanguageServer(final LanguageServerContext context) {
+        this.context = context;
+        this.textDocuments = new GroovyTextDocumentService(context);
+        this.workspace = new GroovyWorkspaceService(context);
+    }
+
+    /**
+     * @return the session state
+     */
+    public LanguageServerContext getContext() {
+        return context;
+    }
+
+    @Override
+    public CompletableFuture<InitializeResult> initialize(final InitializeParams params) {
+        if (params.getCapabilities() != null && params.getCapabilities().getGeneral() != null) {
+            context.setPositionEncoding(PositionEncoding.negotiate(
+                    params.getCapabilities().getGeneral().getPositionEncodings()));
+        } else {
+            context.setPositionEncoding(PositionEncoding.UTF16);
+        }
+        context.setClientCapabilities(params.getCapabilities());
+        if (params.getWorkspaceFolders() != null) {
+            for (WorkspaceFolder folder : params.getWorkspaceFolders()) {
+                context.addWorkspaceFolder(Uris.parse(folder.getUri()));
+            }
+        } else if (params.getRootUri() != null) {
+            context.addWorkspaceFolder(Uris.parse(params.getRootUri()));
+        }
+        InitializeResult result = new InitializeResult(capabilities.create(
+                context.getPositionEncoding(), context.getExtensions().commands()));
+        result.setServerInfo(new ServerInfo("groovy-lsp", GroovyLanguageServer.class.getPackage() == null
+                ? "dev" : String.valueOf(GroovyLanguageServer.class.getPackage().getImplementationVersion())));
+        return CompletableFuture.completedFuture(result);
+    }
+
+    @Override
+    public void initialized(final InitializedParams params) {
+        context.setInitialized(true);
+        context.scheduleRecompile(0);
+    }
+
+    @Override
+    public CompletableFuture<Object> shutdown() {
+        context.setShutdown(true);
+        context.close();
+        return CompletableFuture.completedFuture(null);
+    }
+
+    @Override
+    public void exit() {
+        context.setExitCode(context.isShutdown() ? 0 : 1);
+    }
+
+    @Override
+    public TextDocumentService getTextDocumentService() {
+        return textDocuments;
+    }
+
+    @Override
+    public WorkspaceService getWorkspaceService() {
+        return workspace;
+    }
+
+    @Override
+    public void connect(final LanguageClient client) {
+        context.setClient(client);
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServerLauncher.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServerLauncher.java
new file mode 100644
index 0000000000..5c3891a7f9
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServerLauncher.java
@@ -0,0 +1,135 @@
+/*
+ *  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.jsonrpc.Launcher;
+import org.eclipse.lsp4j.services.LanguageClient;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+/**
+ * Process entry point. Speaks LSP over stdio by default; {@code --socket
+ * <port>} listens for a single client connection. {@code System.out} is
+ * redirected to {@code System.err} so that incidental prints cannot
+ * corrupt the JSON-RPC stream.
+ */
+@SuppressWarnings("java:S106") // stdout is the JSON-RPC stream; --help/--version are CLI
+public final class GroovyLanguageServerLauncher {
+
+    private GroovyLanguageServerLauncher() {
+    }
+
+    /**
+     * Launches the language server.
+     *
+     * @param args {@code --help}, {@code --version}, or {@code --socket <port>}
+     * @throws IOException when the socket transport fails
+     * @throws InterruptedException when waiting for {@code exit} is interrupted
+     * @throws ExecutionException when the JSON-RPC listener fails
+     */
+    public static void main(final String[] args) throws IOException, InterruptedException, ExecutionException {
+        ListArgs parsed = ListArgs.parse(args);
+        if (parsed.help) {
+            System.out.println("""
+                    groovylsp - Apache Groovy Language Server (LSP 3.18)
+                    Usage: groovylsp [--stdio] [--socket <port>] [--version] [--help]
+                    Default transport is stdio.
+                    """);
+            return;
+        }
+        if (parsed.version) {
+            Package pkg = GroovyLanguageServer.class.getPackage();
+            String version = pkg == null || pkg.getImplementationVersion() == null
+                    ? "dev" : pkg.getImplementationVersion();
+            System.out.println("groovy-lsp " + version);
+            return;
+        }
+        if (parsed.port != null) {
+            try (ServerSocket serverSocket = new ServerSocket(parsed.port);
+                 Socket socket = serverSocket.accept()) {
+                run(socket.getInputStream(), socket.getOutputStream(), true);
+            }
+            return;
+        }
+        InputStream in = System.in;
+        OutputStream out = System.out;
+        System.setOut(new PrintStream(System.err, true, StandardCharsets.UTF_8));
+        System.exit(run(in, out, true));
+    }
+
+    /**
+     * Runs a server on the given streams. When {@code waitForExit} is true,
+     * the method blocks until {@code exit} is received.
+     *
+     * @param in JSON-RPC input
+     * @param out JSON-RPC output
+     * @param waitForExit whether to wait for the LSP {@code exit} notification
+     * @return the exit code, or {@code null} when not waiting
+     * @throws InterruptedException when waiting for {@code exit} is interrupted
+     * @throws ExecutionException when the JSON-RPC listener fails
+     */
+    public static Integer run(final InputStream in, final OutputStream out, final boolean waitForExit)
+            throws InterruptedException, ExecutionException {
+        GroovyLanguageServer server = new GroovyLanguageServer();
+        Launcher<LanguageClient> launcher = Launcher.createLauncher(server, LanguageClient.class, in, out);
+        server.connect(launcher.getRemoteProxy());
+        Future<Void> listening = launcher.startListening();
+        if (!waitForExit) {
+            return server.getContext().getExitCode();
+        }
+        listening.get();
+        Integer code = server.getContext().getExitCode();
+        return code == null ? 0 : code;
+    }
+
+    static final class ListArgs {
+        boolean help;
+        boolean version;
+        Integer port;
+
+        static ListArgs parse(final String[] args) {
+            ListArgs parsed = new ListArgs();
+            List<String> list = Arrays.asList(args == null ? new String[0] : args);
+            int i = 0;
+            while (i < list.size()) {
+                String arg = list.get(i);
+                i += 1;
+                if ("--help".equals(arg) || "-h".equals(arg)) {
+                    parsed.help = true;
+                } else if ("--version".equals(arg) || "-v".equals(arg)) {
+                    parsed.version = true;
+                } else if ("--socket".equals(arg) && i < list.size()) {
+                    parsed.port = Integer.parseInt(list.get(i));
+                    i += 1;
+                }
+            }
+            return parsed;
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
new file mode 100644
index 0000000000..cf829331c3
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
@@ -0,0 +1,219 @@
+/*
+ *  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;
+
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.apache.groovy.lsp.spi.GroovyLspExtension;
+import org.apache.groovy.lsp.spi.GroovyLspSession;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.eclipse.lsp4j.CodeAction;
+import org.eclipse.lsp4j.Command;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.ServiceLoader;
+import java.util.function.Consumer;
+
+/**
+ * Discovers and fans out {@link GroovyLspExtension} instances. One bad
+ * plugin must not abort compile or a request.
+ */
+public final class ExtensionHost {
+
+    private static final ExtensionHost NONE = new ExtensionHost(List.of());
+
+    private final List<GroovyLspExtension> extensions;
+
+    private ExtensionHost(final List<GroovyLspExtension> extensions) {
+        this.extensions = List.copyOf(extensions);
+    }
+
+    /**
+     * @return a host with no plugins
+     */
+    public static ExtensionHost none() {
+        return NONE;
+    }
+
+    /**
+     * @param extensions explicit plugins, typically from tests
+     * @return a host
+     */
+    public static ExtensionHost of(final GroovyLspExtension... extensions) {
+        if (extensions == null || extensions.length == 0) {
+            return NONE;
+        }
+        List<GroovyLspExtension> list = new ArrayList<>();
+        for (GroovyLspExtension extension : extensions) {
+            if (extension != null) {
+                list.add(extension);
+            }
+        }
+        return list.isEmpty() ? NONE : new ExtensionHost(list);
+    }
+
+    /**
+     * Loads {@link ServiceLoader} registrations from {@code loader}.
+     *
+     * @param loader class loader, or {@code null} for the SPI type's loader
+     * @return a host
+     */
+    public static ExtensionHost discover(final ClassLoader loader) {
+        ClassLoader use = loader == null ? GroovyLspExtension.class.getClassLoader() : loader;
+        List<GroovyLspExtension> found = new ArrayList<>();
+        for (GroovyLspExtension extension : ServiceLoader.load(GroovyLspExtension.class, use)) {
+            found.add(extension);
+        }
+        return found.isEmpty() ? NONE : new ExtensionHost(found);
+    }
+
+    /**
+     * @return plugin ids in registration order
+     */
+    public List<String> ids() {
+        List<String> ids = new ArrayList<>(extensions.size());
+        for (GroovyLspExtension extension : extensions) {
+            try {
+                String id = extension.id();
+                ids.add(id == null || id.isBlank() ? extension.getClass().getName() : id);
+            } catch (RuntimeException ignored) {
+                ids.add(extension.getClass().getName());
+            }
+        }
+        return ids;
+    }
+
+    /**
+     * @return advertised extra execute-command names
+     */
+    public List<String> commands() {
+        List<String> commands = new ArrayList<>();
+        for (GroovyLspExtension extension : extensions) {
+            try {
+                List<String> extra = extension.commands();
+                if (extra != null) {
+                    commands.addAll(extra);
+                }
+            } catch (RuntimeException ignored) {
+                // one plugin must not abort initialize
+            }
+        }
+        return commands;
+    }
+
+    /**
+     * @return the plugins
+     */
+    public List<GroovyLspExtension> all() {
+        return extensions;
+    }
+
+    public void configure(final CompilerConfiguration configuration) {
+        configure(configuration, (GroovyLspSession) null);
+    }
+
+    public void configure(final CompilerConfiguration configuration, final GroovyLspSession session) {
+        each(extension -> extension.configure(configuration, session));
+    }
+
+    public void initialized(final GroovyLspSession session) {
+        each(extension -> extension.initialized(session));
+    }
+
+    public void shutdown() {
+        each(GroovyLspExtension::shutdown);
+    }
+
+    public void afterCompile(final CompilationUnit unit) {
+        each(extension -> extension.afterCompile(unit));
+    }
+
+    public List<Diagnostic> extraDiagnostics(final CompiledDocument document) {
+        List<Diagnostic> extras = new ArrayList<>();
+        each(extension -> {
+            List<Diagnostic> extra = document == null
+                    ? null
+                    : extension.extraDiagnostics(document.getUri(), document.getText(), document.getModule());
+            if (extra != null) {
+                extras.addAll(extra);
+            }
+        });
+        return extras;
+    }
+
+    public List<Either<Command, CodeAction>> extraCodeActions(final TextDocument document,
+                                                              final List<Diagnostic> diagnostics,
+                                                              final Range range) {
+        List<Either<Command, CodeAction>> extras = new ArrayList<>();
+        each(extension -> {
+            List<Either<Command, CodeAction>> extra = document == null
+                    ? null
+                    : extension.extraCodeActions(document.getUri(), document.getText(), diagnostics, range);
+            if (extra != null) {
+                extras.addAll(extra);
+            }
+        });
+        return extras;
+    }
+
+    private void each(final Consumer<GroovyLspExtension> action) {
+        for (GroovyLspExtension extension : extensions) {
+            try {
+                action.accept(extension);
+            } catch (RuntimeException ignored) {
+                // one plugin must not abort the host
+            }
+        }
+    }
+
+    public Object executeCommand(final String command, final List<Object> arguments,
+                                 final GroovyLspSession session) {
+        if (command == null) {
+            return null;
+        }
+        for (GroovyLspExtension extension : extensions) {
+            try {
+                List<String> commands = extension.commands();
+                if (commands == null || !commands.contains(command)) {
+                    continue;
+                }
+                return extension.executeCommand(command, arguments, session);
+            } catch (RuntimeException ignored) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    public boolean equals(final Object other) {
+        return other instanceof ExtensionHost host && ids().equals(host.ids());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(ids());
+    }
+}
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
new file mode 100644
index 0000000000..a20961f509
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
@@ -0,0 +1,524 @@
+/*
+ *  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;
+
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.CompilerSettings;
+import org.apache.groovy.lsp.internal.compile.GroovyCompiler;
+import org.apache.groovy.lsp.internal.compile.WorkspaceScanner;
+import org.apache.groovy.lsp.internal.diagnostic.DiagnosticConverter;
+import org.apache.groovy.lsp.internal.feature.CompletionService;
+import org.apache.groovy.lsp.internal.feature.HoverService;
+import org.apache.groovy.lsp.internal.feature.NavigationService;
+import org.apache.groovy.lsp.internal.feature.RenameService;
+import org.apache.groovy.lsp.internal.feature.SupportServices;
+import org.apache.groovy.lsp.internal.feature.SymbolService;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.compile.ImportSupport;
+import org.apache.groovy.lsp.internal.compile.WorkspaceLayout;
+import org.apache.groovy.lsp.internal.feature.HierarchyService;
+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.apache.groovy.lsp.spi.GroovyLspSession;
+import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
+import org.eclipse.lsp4j.WorkspaceEdit;
+import org.eclipse.lsp4j.ClientCapabilities;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.ProgressParams;
+import org.eclipse.lsp4j.PublishDiagnosticsParams;
+import org.eclipse.lsp4j.WorkDoneProgressBegin;
+import org.eclipse.lsp4j.WorkDoneProgressCreateParams;
+import org.eclipse.lsp4j.WorkDoneProgressEnd;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import org.eclipse.lsp4j.services.LanguageClient;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Mutable session state for one language-server process.
+ */
+public final class LanguageServerContext implements GroovyLspSession {
+
+    private final ExtensionHost extensions;
+    private final DocumentStore documents = new DocumentStore();
+    private final GroovyCompiler compiler = new GroovyCompiler();
+    private final WorkspaceScanner scanner = new WorkspaceScanner();
+    private final DiagnosticConverter diagnostics = new DiagnosticConverter();
+    private final CompletionService completions = new CompletionService();
+    private final HoverService hovers = new HoverService();
+    private final NavigationService navigation = new NavigationService();
+    private final SymbolService symbols = new SymbolService();
+    private final RenameService rename = new RenameService();
+    private final SupportServices support = new SupportServices();
+    private final HierarchyService hierarchy = new HierarchyService();
+    private final Object compileLock = new Object();
+
+    private final AtomicReference<LanguageClient> client = new AtomicReference<>();
+    private final AtomicReference<ClientCapabilities> clientCapabilities = new AtomicReference<>();
+    private final AtomicReference<PositionEncoding> positionEncoding = new AtomicReference<>(PositionEncoding.UTF16);
+    private final AtomicReference<CompilerSettings> settings = new AtomicReference<>(CompilerSettings.defaults());
+    private final AtomicReference<CompilationSnapshot> snapshot = new AtomicReference<>(CompilationSnapshot.EMPTY);
+    private volatile boolean initialized;
+    private volatile boolean shutdown;
+    private volatile Integer exitCode;
+    private final Set<URI> workspaceFolders = Collections.synchronizedSet(new LinkedHashSet<>());
+
+    private final ScheduledExecutorService debounce = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread thread = new Thread(r, "groovy-lsp-compile");
+        thread.setDaemon(true);
+        return thread;
+    });
+    private ScheduledFuture<?> pendingCompile;
+    private long compileGeneration;
+    private String compileFingerprint;
+
+    /**
+     * Creates a session and loads {@code GroovyLspExtension} registrations
+     * from this class's loader.
+     */
+    public LanguageServerContext() {
+        this(ExtensionHost.discover(LanguageServerContext.class.getClassLoader()));
+    }
+
+    /**
+     * Creates a session with an explicit plugin host. Tests use this to
+     * register a fake extension without {@code META-INF/services}.
+     *
+     * @param extensions plugin host, or {@code null} for none
+     */
+    public LanguageServerContext(final ExtensionHost extensions) {
+        this.extensions = extensions == null ? ExtensionHost.none() : extensions;
+    }
+
+    /**
+     * Compiles open documents plus workspace sources and publishes diagnostics.
+     * Unchanged inputs reuse the last snapshot.
+     */
+    public CompilationSnapshot recompile() {
+        synchronized (compileLock) {
+            final long generation;
+            final CompilerSettings effective;
+            final List<Path> extra;
+            final Collection<TextDocument> open;
+            final String fingerprint;
+            final boolean reuse;
+            synchronized (this) {
+                if (shutdown) {
+                    return snapshot.get();
+                }
+                if (pendingCompile != null) {
+                    pendingCompile.cancel(false);
+                    pendingCompile = null;
+                }
+                List<URI> folders;
+                synchronized (workspaceFolders) {
+                    folders = new ArrayList<>(workspaceFolders);
+                }
+                effective = WorkspaceLayout.withInferred(settings.get(), folders);
+                extra = scanner.scan(folders, effective.getSourcePaths());
+                open = documents.snapshot();
+                fingerprint = fingerprint(open, extra, effective) + '|' + extensions.ids();
+                if (fingerprint.equals(compileFingerprint) && snapshot.get() != CompilationSnapshot.EMPTY) {
+                    // Another compile already committed this snapshot but may
+                    // not have published yet. Fall through to publishDiagnostics.
+                    reuse = true;
+                    generation = compileGeneration;
+                } else {
+                    reuse = false;
+                    generation = ++compileGeneration;
+                }
+            }
+            if (!reuse) {
+                String token = "groovy-compile-" + generation;
+                beginProgress(token, "Compiling Groovy");
+                CompilationSnapshot compiled;
+                try {
+                    compiled = compiler.compile(open, extra, effective, getClass().getClassLoader(), extensions, this);
+                } finally {
+                    endProgress(token);
+                }
+                synchronized (this) {
+                    if (generation == compileGeneration) {
+                        compileFingerprint = fingerprint;
+                        snapshot.set(compiled);
+                    }
+                }
+            }
+        }
+        publishDiagnostics();
+        return snapshot.get();
+    }
+
+    /**
+     * Schedules a recompile after {@code delayMs} of quiet.
+     *
+     * @param delayMs debounce delay
+     */
+    public synchronized void scheduleRecompile(final long delayMs) {
+        if (shutdown || debounce.isShutdown()) {
+            return;
+        }
+        if (pendingCompile != null) {
+            pendingCompile.cancel(false);
+        }
+        pendingCompile = debounce.schedule(this::recompile, delayMs, TimeUnit.MILLISECONDS);
+    }
+
+    /**
+     * Publishes diagnostics for every compiled document.
+     */
+    public void publishDiagnostics() {
+        LanguageClient languageClient = client.get();
+        if (languageClient == null) {
+            return;
+        }
+        for (var document : snapshot.get().documents()) {
+            languageClient.publishDiagnostics(new PublishDiagnosticsParams(
+                    document.getUri().toString(), diagnosticsFor(document)));
+        }
+    }
+
+    /**
+     * Compiler, unused-import and plugin diagnostics for one document.
+     * Used by both push and pull diagnostic paths.
+     *
+     * @param document compiled document
+     * @return diagnostics, never {@code null}
+     */
+    public List<Diagnostic> diagnosticsFor(final CompiledDocument document) {
+        if (document == null) {
+            return List.of();
+        }
+        PositionEncoding encoding = positionEncoding.get();
+        List<Diagnostic> converted = new ArrayList<>(diagnostics.convert(document, encoding));
+        converted.addAll(ImportSupport.unusedDiagnostics(document, encoding));
+        converted.addAll(extensions.extraDiagnostics(document));
+        return converted;
+    }
+
+    @Override
+    public void applyEdit(final WorkspaceEdit edit) {
+        LanguageClient languageClient = client.get();
+        if (languageClient == null || edit == null
+                || !ClientFeatures.applyEdit(clientCapabilities.get())) {
+            return;
+        }
+        languageClient.applyEdit(new ApplyWorkspaceEditParams(edit));
+    }
+
+    @Override
+    public String documentText(final URI uri) {
+        if (uri == null) {
+            return null;
+        }
+        TextDocument open = documents.get(uri);
+        if (open != null) {
+            return open.getText();
+        }
+        CompiledDocument compiled = snapshot.get().get(uri);
+        return compiled == null ? null : compiled.getText();
+    }
+
+    @Override
+    public List<URI> workspaceFolders() {
+        synchronized (workspaceFolders) {
+            return List.copyOf(workspaceFolders);
+        }
+    }
+
+    @Override
+    public List<String> classpath() {
+        return settings.get().getClasspath();
+    }
+
+    @Override
+    public List<String> sourcePaths() {
+        return settings.get().getSourcePaths();
+    }
+
+    @Override
+    public Map<String, String> extraSettings() {
+        return settings.get().getExtra();
+    }
+
+    @Override
+    public String positionEncoding() {
+        return positionEncoding.get().protocolName();
+    }
+
+    private void beginProgress(final String token, final String title) {
+        LanguageClient languageClient = client.get();
+        if (languageClient == null || !ClientFeatures.workDoneProgress(clientCapabilities.get())) {
+            return;
+        }
+        try {
+            WorkDoneProgressBegin begin = new WorkDoneProgressBegin();
+            begin.setTitle(title);
+            begin.setCancellable(false);
+            languageClient.createProgress(new WorkDoneProgressCreateParams(Either.forLeft(token)));
+            languageClient.notifyProgress(new ProgressParams(Either.forLeft(token), Either.forLeft(begin)));
+        } catch (RuntimeException ignored) {
+            // progress is optional
+        }
+    }
+
+    private void endProgress(final String token) {
+        LanguageClient languageClient = client.get();
+        if (languageClient == null || !ClientFeatures.workDoneProgress(clientCapabilities.get())) {
+            return;
+        }
+        try {
+            languageClient.notifyProgress(new ProgressParams(Either.forLeft(token),
+                    Either.forLeft(new WorkDoneProgressEnd())));
+        } catch (RuntimeException ignored) {
+            // progress is optional
+        }
+    }
+
+    /**
+     * Releases background threads.
+     */
+    public synchronized void close() {
+        shutdown = true;
+        if (pendingCompile != null) {
+            pendingCompile.cancel(false);
+            pendingCompile = null;
+        }
+        debounce.shutdownNow();
+        compiler.close();
+        documents.clear();
+        extensions.shutdown();
+    }
+
+    public DocumentStore getDocuments() {
+        return documents;
+    }
+
+    /**
+     * Open buffer for {@code uri}, or a snapshot of the last compiled text
+     * when the file is not open.
+     *
+     * @param uri document URI
+     * @return the document, or {@code null}
+     */
+    public TextDocument documentFor(final String uri) {
+        TextDocument document = documents.get(uri);
+        if (document != null) {
+            return document;
+        }
+        CompiledDocument compiled = snapshot.get().get(uri);
+        return compiled == null ? null : compiled.toTextDocument();
+    }
+
+    public GroovyCompiler getCompiler() {
+        return compiler;
+    }
+
+    public WorkspaceScanner getScanner() {
+        return scanner;
+    }
+
+    public DiagnosticConverter getDiagnostics() {
+        return diagnostics;
+    }
+
+    public CompletionService getCompletions() {
+        return completions;
+    }
+
+    public HoverService getHovers() {
+        return hovers;
+    }
+
+    public NavigationService getNavigation() {
+        return navigation;
+    }
+
+    public SymbolService getSymbols() {
+        return symbols;
+    }
+
+    public RenameService getRename() {
+        return rename;
+    }
+
+    public SupportServices getSupport() {
+        return support;
+    }
+
+    public HierarchyService getHierarchy() {
+        return hierarchy;
+    }
+
+    public ExtensionHost getExtensions() {
+        return extensions;
+    }
+
+    public LanguageClient getClient() {
+        return client.get();
+    }
+
+    public void setClient(final LanguageClient client) {
+        this.client.set(client);
+    }
+
+    public ClientCapabilities getClientCapabilities() {
+        return clientCapabilities.get();
+    }
+
+    public void setClientCapabilities(final ClientCapabilities clientCapabilities) {
+        this.clientCapabilities.set(clientCapabilities);
+    }
+
+    public PositionEncoding getPositionEncoding() {
+        return positionEncoding.get();
+    }
+
+    public void setPositionEncoding(final PositionEncoding positionEncoding) {
+        this.positionEncoding.set(positionEncoding);
+    }
+
+    public CompilerSettings getSettings() {
+        return settings.get();
+    }
+
+    public void setSettings(final CompilerSettings settings) {
+        this.settings.set(settings);
+        compileFingerprint = null;
+    }
+
+    public CompilationSnapshot getSnapshot() {
+        return snapshot.get();
+    }
+
+    public void setSnapshot(final CompilationSnapshot snapshot) {
+        this.snapshot.set(snapshot);
+    }
+
+    public boolean isInitialized() {
+        return initialized;
+    }
+
+    public void setInitialized(final boolean initialized) {
+        boolean was = this.initialized;
+        this.initialized = initialized;
+        if (initialized && !was) {
+            extensions.initialized(this);
+        }
+    }
+
+    public boolean isShutdown() {
+        return shutdown;
+    }
+
+    public void setShutdown(final boolean shutdown) {
+        this.shutdown = shutdown;
+    }
+
+    public Integer getExitCode() {
+        return exitCode;
+    }
+
+    public void setExitCode(final Integer exitCode) {
+        this.exitCode = exitCode;
+    }
+
+    public Set<URI> getWorkspaceFolders() {
+        return workspaceFolders;
+    }
+
+    /**
+     * Adds a workspace folder after normalizing {@code file:} drive letters.
+     *
+     * @param uri folder URI
+     */
+    public void addWorkspaceFolder(final URI uri) {
+        if (uri != null) {
+            workspaceFolders.add(Uris.normalize(uri));
+        }
+    }
+
+    /**
+     * Removes a workspace folder using the same normalization as add.
+     *
+     * @param uri folder URI
+     */
+    public void removeWorkspaceFolder(final URI uri) {
+        if (uri != null) {
+            workspaceFolders.remove(Uris.normalize(uri));
+        }
+    }
+
+    /**
+     * Stable key for the current compile inputs. Equal keys skip recompilation.
+     *
+     * @param open open documents
+     * @param extra workspace files
+     * @param settings effective settings
+     * @return a fingerprint
+     */
+    public static String fingerprint(final Collection<TextDocument> open, final Collection<Path> extra,
+                                     final CompilerSettings settings) {
+        StringBuilder builder = new StringBuilder();
+        builder.append(settings.getClasspath()).append('|')
+                .append(settings.getSourcePaths()).append('|')
+                .append(settings.isGrapeEnabled()).append('|')
+                .append(settings.isAstTestEnabled()).append('|')
+                .append(settings.getThroughPhase()).append('|')
+                .append(settings.getExtra());
+        if (open != null) {
+            for (TextDocument document : open) {
+                builder.append('\n').append(document.getUri()).append(':')
+                        .append(document.getVersion()).append(':')
+                        .append(document.getText().hashCode());
+            }
+        }
+        if (extra != null) {
+            for (Path path : extra) {
+                long mtime = 0L;
+                long size = 0L;
+                try {
+                    mtime = Files.getLastModifiedTime(path).toMillis();
+                    size = Files.size(path);
+                } catch (Exception ignored) {
+                    // missing files still participate as a zero stamp
+                }
+                builder.append('\n').append(path).append(':').append(mtime).append(':').append(size);
+            }
+        }
+        return builder.toString();
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/AstQuery.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/AstQuery.java
new file mode 100644
index 0000000000..73d51ca18d
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/AstQuery.java
@@ -0,0 +1,374 @@
+/*
+ *  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.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.ConstructorNode;
+import org.codehaus.groovy.ast.InnerClassNode;
+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.AttributeExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.query.AstContext;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.CatchStatement;
+import org.codehaus.groovy.ast.stmt.ForStatement;
+import org.eclipse.lsp4j.Position;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+/**
+ * Position queries over a {@link ModuleNode}, built on the compiler's
+ * {@link org.codehaus.groovy.ast.query.AstQuery}.
+ */
+public final class AstQuery {
+
+    private AstQuery() {
+    }
+
+    /**
+     * Returns the innermost node at {@code (line, column)}, or {@code null}.
+     *
+     * @param module module AST
+     * @param line 1-based line
+     * @param column 1-based column
+     * @return the hit, or {@code null}
+     */
+    public static ASTNode nodeAt(final ModuleNode module, final int line, final int column) {
+        if (module == null) {
+            return null;
+        }
+        Hit best = new Hit();
+        consider(best, module.getPackage(), null, line, column);
+        for (ImportNode imp : module.getImports()) {
+            consider(best, imp, null, line, column);
+        }
+        for (ImportNode imp : module.getStarImports()) {
+            consider(best, imp, null, line, column);
+        }
+        for (ImportNode imp : module.getStaticImports().values()) {
+            consider(best, imp, null, line, column);
+        }
+        for (ImportNode imp : module.getStaticStarImports().values()) {
+            consider(best, imp, null, line, column);
+        }
+        walk(module, (node, ctx) -> consider(best, node, ctx.parent(), line, column));
+        return liftNameLeaf(best.node, best.parent);
+    }
+
+    /**
+     * Innermost node at the LSP caret, or {@code null}.
+     *
+     * @param module module AST
+     * @param document open document
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return the hit, or {@code null}
+     */
+    public static ASTNode nodeAt(final ModuleNode module, final TextDocument document,
+                                 final Position position, final PositionEncoding encoding) {
+        int[] groovy = groovyAt(document, position, encoding);
+        return groovy.length < 2 ? null : nodeAt(module, groovy[0], groovy[1]);
+    }
+
+    /**
+     * Walks every class and the module statement block, including inner classes.
+     *
+     * @param module module AST
+     * @param action node plus enclosing context
+     */
+    public static void walk(final ModuleNode module, final BiConsumer<ASTNode, AstContext> action) {
+        if (module == null || action == null) {
+            return;
+        }
+        for (ClassNode classNode : module.getClasses()) {
+            if (classNode instanceof InnerClassNode) {
+                continue;
+            }
+            org.codehaus.groovy.ast.query.AstQuery.from(classNode)
+                    .andSelf()
+                    .into(ClassNode.class)
+                    .forEach((node, ctx) -> emit(node, ctx, action));
+        }
+        BlockStatement block = module.getStatementBlock();
+        if (block != null) {
+            walk(block, action);
+        }
+    }
+
+    /**
+     * Walks {@code node} and its descendants, including {@code node} itself.
+     *
+     * @param node any AST node
+     * @param action node plus enclosing context
+     */
+    public static void walk(final ASTNode node, final BiConsumer<ASTNode, AstContext> action) {
+        if (node == null || action == null) {
+            return;
+        }
+        org.codehaus.groovy.ast.query.AstQuery.from(node)
+                .andSelf()
+                .forEach((hit, ctx) -> emit(hit, ctx, action));
+    }
+
+    /**
+     * {@code ClassCodeVisitorSupport} does not treat {@link Parameter} as
+     * a child node. The language server still needs caret hits on
+     * parameter names, so parameters are emitted here instead of changing
+     * the compiler query.
+     */
+    private static void emit(final ASTNode node, final AstContext ctx,
+                             final BiConsumer<ASTNode, AstContext> action) {
+        action.accept(node, ctx);
+        if (node instanceof MethodNode method) {
+            for (Parameter parameter : method.getParameters()) {
+                emitParameter(parameter, ctx, action);
+            }
+        } else if (node instanceof ClosureExpression closure && closure.isParameterSpecified()) {
+            for (Parameter parameter : closure.getParameters()) {
+                emitParameter(parameter, ctx, action);
+            }
+        } else if (node instanceof ForStatement loop) {
+            emitParameter(loop.getIndexVariable(), ctx, action);
+            emitParameter(loop.getValueVariable(), ctx, action);
+        } else if (node instanceof CatchStatement catchStatement) {
+            emitParameter(catchStatement.getVariable(), ctx, action);
+        }
+    }
+
+    private static void emitParameter(final Parameter parameter, final AstContext ctx,
+                                      final BiConsumer<ASTNode, AstContext> action) {
+        if (parameter == null || "forLoopDummyParameter".equals(parameter.getName())) {
+            return;
+        }
+        action.accept(parameter, ctx);
+    }
+
+    /**
+     * Nodes containing the LSP caret, innermost first.
+     *
+     * @param module module AST
+     * @param document open document
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return containing nodes, possibly empty
+     */
+    public static List<ASTNode> containing(final ModuleNode module, final TextDocument document,
+                                           final Position position, final PositionEncoding encoding) {
+        int[] groovy = groovyAt(document, position, encoding);
+        return groovy.length < 2 ? List.of() : containing(module, groovy[0], groovy[1]);
+    }
+
+    /**
+     * Nodes containing the caret, innermost first.
+     *
+     * @param module module AST
+     * @param line 1-based line
+     * @param column 1-based column
+     * @return containing nodes
+     */
+    public static List<ASTNode> containing(final ModuleNode module, final int line, final int column) {
+        List<ASTNode> hits = new ArrayList<>();
+        if (module == null) {
+            return hits;
+        }
+        if (Positions.contains(module.getPackage(), line, column)) {
+            hits.add(module.getPackage());
+        }
+        for (ImportNode imp : ImportSupport.allImports(module)) {
+            if (Positions.contains(imp, line, column)) {
+                hits.add(imp);
+            }
+        }
+        walk(module, (node, ctx) -> {
+            if (Positions.contains(node, line, column)) {
+                hits.add(node);
+            }
+        });
+        hits.sort((left, right) -> Long.compare(Positions.innerScore(right), Positions.innerScore(left)));
+        return hits;
+    }
+
+    /**
+     * @param module module AST
+     * @param line 1-based line
+     * @param column 1-based column
+     * @return enclosing method, or {@code null}
+     */
+    public static MethodNode enclosingMethod(final ModuleNode module, final int line, final int column) {
+        if (module == null) {
+            return null;
+        }
+        MethodNode[] found = new MethodNode[1];
+        walk(module, (node, ctx) -> {
+            if (Positions.contains(node, line, column) && ctx.enclosingMethod() != null) {
+                found[0] = ctx.enclosingMethod();
+            }
+        });
+        return found[0];
+    }
+
+    /**
+     * Returns the innermost call or constructor invocation containing the caret.
+     *
+     * @param module module AST
+     * @param line 1-based line
+     * @param column 1-based column
+     * @return the call, or {@code null}
+     */
+    public static ASTNode enclosingCall(final ModuleNode module, final int line, final int column) {
+        if (module == null) {
+            return null;
+        }
+        Hit best = new Hit();
+        walk(module, (node, ctx) -> {
+            if (node instanceof MethodCallExpression
+                    || node instanceof ConstructorCallExpression
+                    || node instanceof StaticMethodCallExpression) {
+                consider(best, node, ctx.parent(), line, column);
+            }
+        });
+        return best.node;
+    }
+
+    /**
+     * Innermost call or constructor containing the LSP caret.
+     *
+     * @param module module AST
+     * @param document open document
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return the call, or {@code null}
+     */
+    public static ASTNode enclosingCall(final ModuleNode module, final TextDocument document,
+                                        final Position position, final PositionEncoding encoding) {
+        int[] groovy = groovyAt(document, position, encoding);
+        return groovy.length < 2 ? null : enclosingCall(module, groovy[0], groovy[1]);
+    }
+
+    private static int[] groovyAt(final TextDocument document, final Position position,
+                                  final PositionEncoding encoding) {
+        if (document == null || position == null) {
+            return new int[0];
+        }
+        return document.toGroovy(position, encoding);
+    }
+
+    /**
+     * Collects classes, methods, fields and properties in source order.
+     *
+     * @param module module AST
+     * @return declaration nodes
+     */
+    public static List<ASTNode> declarations(final ModuleNode module) {
+        List<ASTNode> nodes = new ArrayList<>();
+        if (module == null) {
+            return nodes;
+        }
+        if (module.getPackage() != null) {
+            nodes.add(module.getPackage());
+        }
+        nodes.addAll(module.getImports());
+        nodes.addAll(module.getStarImports());
+        nodes.addAll(module.getStaticImports().values());
+        nodes.addAll(module.getStaticStarImports().values());
+        for (ClassNode classNode : module.getClasses()) {
+            addClassDeclarations(nodes, classNode);
+        }
+        return nodes;
+    }
+
+    private static void addClassDeclarations(final List<ASTNode> nodes, final ClassNode classNode) {
+        if (classNode.isScript() && classNode.getLineNumber() <= 0) {
+            return;
+        }
+        nodes.add(classNode);
+        for (PropertyNode property : classNode.getProperties()) {
+            if (!property.isSynthetic()) {
+                nodes.add(property);
+            }
+        }
+        for (FieldNode field : classNode.getFields()) {
+            if (!field.isSynthetic()) {
+                nodes.add(field);
+            }
+        }
+        for (ConstructorNode constructor : classNode.getDeclaredConstructors()) {
+            if (!constructor.isSynthetic()) {
+                nodes.add(constructor);
+            }
+        }
+        for (MethodNode method : classNode.getMethods()) {
+            if (!method.isSynthetic() && method.getLineNumber() > 0) {
+                nodes.add(method);
+            }
+        }
+    }
+
+    /**
+     * Call and property names are {@link ConstantExpression} children. Hover,
+     * definition and rename want the enclosing call or property.
+     */
+    private static ASTNode liftNameLeaf(final ASTNode node, final ASTNode parent) {
+        if (!(node instanceof ConstantExpression)) {
+            return node;
+        }
+        if (parent instanceof MethodCallExpression call && call.getMethod() == node) {
+            return call;
+        }
+        if (parent instanceof PropertyExpression property && property.getProperty() == node) {
+            return property;
+        }
+        if (parent instanceof AttributeExpression attribute && attribute.getProperty() == node) {
+            return attribute;
+        }
+        return node;
+    }
+
+    private static void consider(final Hit best, final ASTNode candidate, final ASTNode parent,
+                                 final int line, final int column) {
+        if (candidate == null || !Positions.contains(candidate, line, column)) {
+            return;
+        }
+        if (best.node == null || Positions.innerScore(candidate) >= Positions.innerScore(best.node)) {
+            best.node = candidate;
+            best.parent = parent;
+        }
+    }
+
+    private static final class Hit {
+        private ASTNode node;
+        private ASTNode parent;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilationSnapshot.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilationSnapshot.java
new file mode 100644
index 0000000000..438d0dc22a
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilationSnapshot.java
@@ -0,0 +1,116 @@
+/*
+ *  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.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Immutable result of one workspace compile.
+ */
+public final class CompilationSnapshot {
+
+    public static final CompilationSnapshot EMPTY = new CompilationSnapshot(Map.of(), JavaSymbolIndex.EMPTY);
+
+    private final Map<URI, CompiledDocument> documents;
+    private final JavaSymbolIndex javaSymbols;
+    private final AtomicReference<TypeIndex> types = new AtomicReference<>();
+
+    /**
+     * @param documents compiled documents keyed by normalized URI
+     */
+    public CompilationSnapshot(final Map<URI, CompiledDocument> documents) {
+        this(documents, JavaSymbolIndex.EMPTY);
+    }
+
+    /**
+     * @param documents compiled documents keyed by normalized URI
+     * @param javaSymbols types and members from joint-compiled Java sources
+     */
+    public CompilationSnapshot(final Map<URI, CompiledDocument> documents, final JavaSymbolIndex javaSymbols) {
+        this.documents = Map.copyOf(documents);
+        this.javaSymbols = javaSymbols == null ? JavaSymbolIndex.EMPTY : javaSymbols;
+    }
+
+    /**
+     * @return Java types and members from the last joint compile
+     */
+    public JavaSymbolIndex javaSymbols() {
+        return javaSymbols;
+    }
+
+    /**
+     * @return types declared in this snapshot
+     */
+    public TypeIndex types() {
+        TypeIndex index = types.get();
+        if (index != null) {
+            return index;
+        }
+        TypeIndex created = TypeIndex.of(this);
+        if (types.compareAndSet(null, created)) {
+            return created;
+        }
+        return types.get();
+    }
+
+    /**
+     * @param uri document URI
+     * @return the compiled document, or {@code null}
+     */
+    public CompiledDocument get(final URI uri) {
+        return documents.get(Uris.normalize(uri));
+    }
+
+    /**
+     * @param document open or reconstructed buffer
+     * @return the compiled document, or {@code null}
+     */
+    public CompiledDocument get(final TextDocument document) {
+        return document == null ? null : get(document.getUri());
+    }
+
+    /**
+     * @param uriString document URI string
+     * @return the compiled document, or {@code null}
+     */
+    public CompiledDocument get(final String uriString) {
+        return get(Uris.parse(uriString));
+    }
+
+    /**
+     * @return every compiled document
+     */
+    public Collection<CompiledDocument> documents() {
+        return documents.values();
+    }
+
+    /**
+     * @return a mutable copy of the map, for builders
+     */
+    static Map<URI, CompiledDocument> newMap() {
+        return new LinkedHashMap<>();
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompiledDocument.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompiledDocument.java
new file mode 100644
index 0000000000..a7a9f7bab4
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompiledDocument.java
@@ -0,0 +1,137 @@
+/*
+ *  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.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ModuleNode;
+import org.codehaus.groovy.control.ErrorCollector;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.control.messages.Message;
+import org.codehaus.groovy.control.messages.WarningMessage;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * One compiled Groovy source: its AST, originating unit, and collected
+ * messages.
+ */
+public final class CompiledDocument {
+
+    private final URI uri;
+    private final int version;
+    private final String text;
+    private final ModuleNode module;
+    private final SourceUnit sourceUnit;
+    private final List<Message> errors;
+    private final List<WarningMessage> warnings;
+    private final List<JavaMessage> javaMessages;
+
+    /**
+     * Creates a compiled-document snapshot, copying messages from {@code collector}.
+     *
+     * @param uri document URI
+     * @param version LSP version, or {@code -1} when compiled from disk
+     * @param text source text used for this compile
+     * @param module AST module, possibly {@code null} when parse failed hard
+     * @param sourceUnit compiler source unit
+     * @param collector error collector, possibly {@code null}
+     */
+    public CompiledDocument(final URI uri, final int version, final String text, final ModuleNode module,
+                            final SourceUnit sourceUnit, final ErrorCollector collector) {
+        this.uri = uri;
+        this.version = version;
+        this.text = text == null ? "" : text;
+        this.module = module;
+        this.sourceUnit = sourceUnit;
+        this.errors = collector != null && collector.getErrors() != null
+                ? List.copyOf(new ArrayList<>(collector.getErrors())) : List.of();
+        this.warnings = collector != null && collector.getWarnings() != null
+                ? List.copyOf(new ArrayList<>(collector.getWarnings())) : List.of();
+        this.javaMessages = List.of();
+    }
+
+    private CompiledDocument(final CompiledDocument base, final List<JavaMessage> javaMessages) {
+        this.uri = base.uri;
+        this.version = base.version;
+        this.text = base.text;
+        this.module = base.module;
+        this.sourceUnit = base.sourceUnit;
+        this.errors = base.errors;
+        this.warnings = base.warnings;
+        this.javaMessages = javaMessages == null || javaMessages.isEmpty() ? List.of() : List.copyOf(javaMessages);
+    }
+
+    /**
+     * @param extra javac diagnostics
+     * @return a copy carrying {@code extra}
+     */
+    public CompiledDocument withJavaMessages(final List<JavaMessage> extra) {
+        return new CompiledDocument(this, extra);
+    }
+
+    public URI getUri() {
+        return uri;
+    }
+
+    public int getVersion() {
+        return version;
+    }
+
+    public String getText() {
+        return text;
+    }
+
+    /**
+     * Buffer view of this compile, used when the file is not open in the
+     * editor (workspace sources compiled from disk).
+     *
+     * @return a text document with language id {@code groovy}
+     */
+    public TextDocument toTextDocument() {
+        String path = uri == null ? null : uri.getPath();
+        String language = Uris.isJavaFileName(path) ? "java" : "groovy";
+        return new TextDocument(uri, language, version, text);
+    }
+
+    /**
+     * @return javac diagnostics for this file, never {@code null}
+     */
+    public List<JavaMessage> getJavaMessages() {
+        return javaMessages;
+    }
+
+    public ModuleNode getModule() {
+        return module;
+    }
+
+    public SourceUnit getSourceUnit() {
+        return sourceUnit;
+    }
+
+    public List<Message> getErrors() {
+        return errors;
+    }
+
+    public List<WarningMessage> getWarnings() {
+        return warnings;
+    }
+}
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
new file mode 100644
index 0000000000..252d1b2c60
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
@@ -0,0 +1,155 @@
+/*
+ *  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.control.CompilerConfiguration;
+import org.codehaus.groovy.control.Phases;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+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
+ * untrusted compile-time code.
+ */
+public record CompilerSettings(List<String> classpath, List<String> sourcePaths, int throughPhase,
+                               boolean grapeEnabled, boolean astTestEnabled, Map<String, String> extra) {
+
+    /**
+     * Creates settings with the supplied classpath and source paths.
+     *
+     * @param classpath jar and directory entries
+     * @param sourcePaths extra Groovy source roots
+     * @param throughPhase {@link Phases} constant to compile through
+     * @param grapeEnabled whether {@code @Grab} runs
+     * @param astTestEnabled whether {@code @ASTTest} closures run
+     * @param extra other {@code groovy.*} keys the core does not interpret
+     */
+    public CompilerSettings {
+        classpath = classpath == null ? List.of() : List.copyOf(classpath);
+        sourcePaths = sourcePaths == null ? List.of() : List.copyOf(sourcePaths);
+        extra = extra == null || extra.isEmpty() ? Map.of() : Map.copyOf(extra);
+    }
+
+    /**
+     * Settings without extra plugin keys.
+     *
+     * @param classpath jar and directory entries
+     * @param sourcePaths extra Groovy source roots
+     * @param throughPhase {@link Phases} constant to compile through
+     * @param grapeEnabled whether {@code @Grab} runs
+     * @param astTestEnabled whether {@code @ASTTest} closures run
+     */
+    public CompilerSettings(final List<String> classpath, final List<String> sourcePaths, final int throughPhase,
+                            final boolean grapeEnabled, final boolean astTestEnabled) {
+        this(classpath, sourcePaths, throughPhase, grapeEnabled, astTestEnabled, Map.of());
+    }
+
+    /**
+     * @return defaults: semantic analysis, no Grape, no {@code @ASTTest}
+     */
+    public static CompilerSettings defaults() {
+        return new CompilerSettings(List.of(), List.of(), Phases.SEMANTIC_ANALYSIS, false, false, Map.of());
+    }
+
+    public List<String> getClasspath() {
+        return classpath;
+    }
+
+    public List<String> getSourcePaths() {
+        return sourcePaths;
+    }
+
+    public int getThroughPhase() {
+        return throughPhase;
+    }
+
+    public boolean isGrapeEnabled() {
+        return grapeEnabled;
+    }
+
+    public boolean isAstTestEnabled() {
+        return astTestEnabled;
+    }
+
+    /**
+     * @return extra {@code groovy.*} settings, never {@code null}
+     */
+    public Map<String, String> getExtra() {
+        return extra;
+    }
+
+    /**
+     * Builds a {@link CompilerConfiguration} for a tooling compile.
+     *
+     * @return the configuration
+     */
+    public CompilerConfiguration toConfiguration() {
+        CompilerConfiguration configuration = new CompilerConfiguration();
+        configuration.setTolerance(0);
+        configuration.setTargetBytecode(CompilerConfiguration.JDK17);
+        if (!classpath.isEmpty()) {
+            configuration.setClasspathList(new ArrayList<>(classpath));
+        }
+        Set<String> disabled = new LinkedHashSet<>();
+        if (configuration.getDisabledGlobalASTTransformations() != null) {
+            disabled.addAll(configuration.getDisabledGlobalASTTransformations());
+        }
+        if (!grapeEnabled) {
+            disabled.add("groovy.grape.GrabAnnotationTransformation");
+        }
+        if (!astTestEnabled) {
+            disabled.add("org.codehaus.groovy.transform.ASTTestTransformation");
+        }
+        configuration.setDisabledGlobalASTTransformations(disabled);
+        return configuration;
+    }
+
+    /**
+     * Creates a class loader covering the configured classpath.
+     *
+     * @param parent parent loader
+     * @return a loader
+     */
+    public URLClassLoader createClassLoader(final ClassLoader parent) {
+        List<URL> urls = new ArrayList<>();
+        for (String entry : classpath) {
+            try {
+                Path path = Path.of(entry);
+                File file = path.toFile();
+                if (file.exists()) {
+                    urls.add(file.toURI().toURL());
+                }
+            } catch (MalformedURLException ignored) {
+                // skip unusable entries
+            }
+        }
+        return new URLClassLoader(urls.toArray(URL[]::new), parent);
+    }
+}
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
new file mode 100644
index 0000000000..5a2bc5686b
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
@@ -0,0 +1,440 @@
+/*
+ *  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 groovy.lang.GroovyClassLoader;
+import org.apache.groovy.lsp.internal.ExtensionHost;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.apache.groovy.lsp.spi.GroovyLspSession;
+import org.codehaus.groovy.control.CompilationFailedException;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Compiles Groovy sources through semantic analysis (no class files) so
+ * the language server can offer diagnostics and navigation on a partial
+ * AST. Errors are collected rather than aborting the compile.
+ */
+public final class GroovyCompiler implements AutoCloseable {
+
+    private static final String ASTTEST_ENABLE = "groovy.asttest.enable";
+    private static final String GRAPE_ENABLE = "groovy.grape.enable";
+
+    private GroovyClassLoader groovyLoader;
+    private URLClassLoader classpathLoader;
+    private LoaderKey loaderKey;
+    private Path javacOutput;
+
+    /**
+     * Compiles {@code openDocuments} together with extra files from disk
+     * that are not already open. Open buffers overlay the filesystem.
+     *
+     * @param openDocuments open editor buffers
+     * @param extraFiles additional Groovy files
+     * @param settings compiler settings
+     * @param parentLoader parent class loader
+     * @return an immutable snapshot
+     */
+    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
+                                       final CompilerSettings settings, final ClassLoader parentLoader) {
+        return compile(openDocuments, extraFiles, settings, parentLoader, ExtensionHost.none());
+    }
+
+    /**
+     * Compiles like {@link #compile(Collection, Collection, CompilerSettings, ClassLoader)}
+     * and then runs {@code extensions} so plugins can inject AST or run an
+     * intelligence pass before the snapshot is frozen.
+     *
+     * @param openDocuments open editor buffers
+     * @param extraFiles additional Groovy files
+     * @param settings compiler settings
+     * @param parentLoader parent class loader
+     * @param extensions plugins, never {@code null}
+     * @return an immutable snapshot
+     */
+    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
+                                       final CompilerSettings settings, final ClassLoader parentLoader,
+                                       final ExtensionHost extensions) {
+        return compile(openDocuments, extraFiles, settings, parentLoader, extensions, null);
+    }
+
+    /**
+     * Compiles like
+     * {@link #compile(Collection, Collection, CompilerSettings, ClassLoader, ExtensionHost)}
+     * and passes {@code session} to {@code configure} so plugins can read
+     * folders and extra settings.
+     *
+     * @param session session, possibly {@code null}
+     * @return an immutable snapshot
+     */
+    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
+                                       final CompilerSettings settings, final ClassLoader parentLoader,
+                                       final ExtensionHost extensions, final GroovyLspSession session) {
+        CompilerConfiguration configuration = settings.toConfiguration();
+        ExtensionHost host = extensions == null ? ExtensionHost.none() : extensions;
+        host.configure(configuration, session);
+        String previousAstTest = System.getProperty(ASTTEST_ENABLE);
+        String previousGrape = System.getProperty(GRAPE_ENABLE);
+        applyProcessFlags(settings);
+        try {
+            return compileUnit(openDocuments, extraFiles, settings, parentLoader, host, configuration);
+        } finally {
+            restoreProperty(ASTTEST_ENABLE, previousAstTest);
+            restoreProperty(GRAPE_ENABLE, previousGrape);
+        }
+    }
+
+    private CompilationSnapshot compileUnit(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
+                                            final CompilerSettings settings, final ClassLoader parentLoader,
+                                            final ExtensionHost host, final CompilerConfiguration configuration) {
+        Map<URI, Integer> versions = new LinkedHashMap<>();
+        Map<URI, String> texts = new LinkedHashMap<>();
+        Map<URI, String> javaOverlays = new LinkedHashMap<>();
+        List<File> javaFiles = collectJavaSources(openDocuments, extraFiles, versions, texts, javaOverlays);
+        LspJavaCompiler javaCompiler = javaFiles.isEmpty() ? null : new LspJavaCompiler(javaOverlays);
+        CompilationUnit unit = compilationUnit(settings, parentLoader, configuration, javaCompiler, !javaFiles.isEmpty());
+        addOpenDocuments(unit, openDocuments, versions, texts);
+        addExtraFiles(unit, extraFiles, versions, texts);
+        if (unit instanceof JavaAwareCompilationUnit javaUnit && !javaFiles.isEmpty()) {
+            javaUnit.addSources(javaFiles.toArray(File[]::new));
+        }
+
+        try {
+            unit.compile(settings.getThroughPhase());
+        } catch (CompilationFailedException ignored) {
+            // keep the partial AST and collected messages
+        }
+        host.afterCompile(unit);
+
+        Map<URI, CompiledDocument> compiled = CompilationSnapshot.newMap();
+        var sourceIterator = unit.iterator();
+        while (sourceIterator.hasNext()) {
+            addCompiled(compiled, versions, texts, sourceIterator.next());
+        }
+        Map<URI, List<JavaMessage>> javaMessages = javaCompiler == null ? Map.of() : javaCompiler.messages();
+        for (Map.Entry<URI, String> entry : texts.entrySet()) {
+            compiled.computeIfAbsent(entry.getKey(), uri -> new CompiledDocument(uri,
+                    versions.getOrDefault(uri, -1), entry.getValue(), null, null, null));
+        }
+        for (Map.Entry<URI, List<JavaMessage>> entry : javaMessages.entrySet()) {
+            compiled.compute(entry.getKey(), (uri, current) -> {
+                CompiledDocument base = current == null
+                        ? new CompiledDocument(uri, versions.getOrDefault(uri, -1),
+                        texts.getOrDefault(uri, ""), null, null, null) : current;
+                return base.withJavaMessages(entry.getValue());
+            });
+        }
+        JavaSymbolIndex javaSymbols = javaCompiler == null ? JavaSymbolIndex.EMPTY : javaCompiler.index();
+        return new CompilationSnapshot(compiled, javaSymbols);
+    }
+
+    private CompilationUnit compilationUnit(final CompilerSettings settings, final ClassLoader parentLoader,
+                                            final CompilerConfiguration configuration, final LspJavaCompiler javaCompiler,
+                                            final boolean joint) {
+        if (!joint || javaCompiler == null) {
+            return new CompilationUnit(configuration, null, loaderFor(settings, parentLoader, configuration));
+        }
+        closeQuietly();
+        if (!prepareJavacOutput(configuration)) {
+            return new CompilationUnit(configuration, null, loaderFor(settings, parentLoader, configuration));
+        }
+        Map<String, Object> jointOptions = new HashMap<>();
+        jointOptions.put(CompilerConfiguration.MEM_STUB, Boolean.TRUE);
+        configuration.setJointCompilationOptions(jointOptions);
+        GroovyClassLoader loader = loaderFor(settings, parentLoader, configuration);
+        JavaAwareCompilationUnit unit = new JavaAwareCompilationUnit(configuration, loader);
+        unit.setCompilerFactory(javaCompiler);
+        return unit;
+    }
+
+    private synchronized boolean prepareJavacOutput(final CompilerConfiguration configuration) {
+        deleteJavacOutput();
+        try {
+            Path base = Files.createDirectories(Path.of(System.getProperty("user.home"), ".groovy", "lsp-javac"));
+            restrictOwner(base);
+            javacOutput = Files.createTempDirectory(base, "jc-");
+            restrictOwner(javacOutput);
+            configuration.setTargetDirectory(javacOutput.toFile());
+            return true;
+        } catch (IOException | RuntimeException ignored) {
+            javacOutput = null;
+            return false;
+        }
+    }
+
+    private static void restrictOwner(final Path directory) {
+        try {
+            Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwx------"));
+        } catch (UnsupportedOperationException | IOException ignored) {
+            // NTFS and similar have no POSIX bits
+        }
+    }
+
+    private List<File> collectJavaSources(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
+                                          final Map<URI, Integer> versions, final Map<URI, String> texts,
+                                          final Map<URI, String> javaOverlays) {
+        List<File> javaFiles = new ArrayList<>();
+        addOpenJava(openDocuments, versions, texts, javaOverlays, javaFiles);
+        addDiskJava(extraFiles, versions, texts, javaFiles);
+        javaFiles.sort(Comparator.comparing(File::getAbsolutePath));
+        return javaFiles;
+    }
+
+    private static void addOpenJava(final Collection<TextDocument> openDocuments, final Map<URI, Integer> versions,
+                                    final Map<URI, String> texts, final Map<URI, String> javaOverlays,
+                                    final List<File> javaFiles) {
+        if (openDocuments == null) {
+            return;
+        }
+        for (TextDocument document : openDocuments) {
+            if (Uris.isJavaDocument(document.getUri(), document.getLanguageId())) {
+                URI uri = Uris.normalize(document.getUri());
+                javaOverlays.put(uri, document.getText());
+                versions.put(uri, document.getVersion());
+                texts.put(uri, document.getText());
+                addJavaFile(javaFiles, uri);
+            }
+        }
+    }
+
+    private static void addDiskJava(final Collection<Path> extraFiles, final Map<URI, Integer> versions,
+                                    final Map<URI, String> texts, final List<File> javaFiles) {
+        if (extraFiles == null) {
+            return;
+        }
+        for (Path path : extraFiles) {
+            if (path == null || !Files.isRegularFile(path) || !Uris.isJavaFileName(path.getFileName().toString())) {
+                continue;
+            }
+            URI uri = Uris.normalize(path.toUri());
+            if (texts.containsKey(uri)) {
+                addJavaFile(javaFiles, uri);
+            } else {
+                addReadableJava(path, uri, versions, texts, javaFiles);
+            }
+        }
+    }
+
+    private static void addReadableJava(final Path path, final URI uri, final Map<URI, Integer> versions,
+                                        final Map<URI, String> texts, final List<File> javaFiles) {
+        try {
+            texts.put(uri, Files.readString(path, StandardCharsets.UTF_8));
+            versions.put(uri, -1);
+            addJavaFile(javaFiles, uri);
+        } catch (IOException ignored) {
+            // skip unreadable files
+        }
+    }
+
+    private static void addJavaFile(final List<File> javaFiles, final URI uri) {
+        if (uri == null || !"file".equalsIgnoreCase(uri.getScheme())) {
+            return;
+        }
+        File file = Uris.toPath(uri).toFile();
+        if (!javaFiles.contains(file)) {
+            javaFiles.add(file);
+        }
+    }
+
+    private static void addCompiled(final Map<URI, CompiledDocument> compiled, final Map<URI, Integer> versions,
+                                    final Map<URI, String> texts, final SourceUnit sourceUnit) {
+        URI uri;
+        try {
+            uri = Uris.parse(sourceUnit.getName());
+        } catch (IllegalArgumentException ex) {
+            return;
+        }
+        compiled.put(uri, new CompiledDocument(uri, versions.getOrDefault(uri, -1),
+                texts.getOrDefault(uri, ""), sourceUnit.getAST(), sourceUnit, sourceUnit.getErrorCollector()));
+    }
+
+    private static void addOpenDocuments(final CompilationUnit unit, final Collection<TextDocument> openDocuments,
+                                         final Map<URI, Integer> versions, final Map<URI, String> texts) {
+        if (openDocuments == null) {
+            return;
+        }
+        for (TextDocument document : openDocuments) {
+            if (Uris.isGroovyDocument(document.getUri(), document.getLanguageId())) {
+                URI uri = Uris.normalize(document.getUri());
+                unit.addSource(uri.toString(), document.getText());
+                versions.put(uri, document.getVersion());
+                texts.put(uri, document.getText());
+            }
+        }
+    }
+
+    private static void addExtraFiles(final CompilationUnit unit, final Collection<Path> extraFiles,
+                                      final Map<URI, Integer> versions, final Map<URI, String> texts) {
+        if (extraFiles == null) {
+            return;
+        }
+        for (Path path : extraFiles) {
+            addExtraFile(unit, path, versions, texts);
+        }
+    }
+
+    private static void addExtraFile(final CompilationUnit unit, final Path path,
+                                     final Map<URI, Integer> versions, final Map<URI, String> texts) {
+        if (path == null || !Files.isRegularFile(path)) {
+            return;
+        }
+        URI uri = Uris.normalize(path.toUri());
+        if (texts.containsKey(uri) || !Uris.isGroovyDocument(uri, null)) {
+            return;
+        }
+        try {
+            String text = Files.readString(path, StandardCharsets.UTF_8);
+            unit.addSource(uri.toString(), text);
+            versions.put(uri, -1);
+            texts.put(uri, text);
+        } catch (IOException ignored) {
+            // skip unreadable files
+        }
+    }
+
+    /**
+     * {@code @ASTTest} is a local transform; {@code disabledGlobalASTTransformations}
+     * does not turn it off. The documented process flags do.
+     *
+     * @param settings compiler settings
+     */
+    static void applyProcessFlags(final CompilerSettings settings) {
+        System.setProperty(ASTTEST_ENABLE, Boolean.toString(settings.isAstTestEnabled()));
+        System.setProperty(GRAPE_ENABLE, Boolean.toString(settings.isGrapeEnabled()));
+    }
+
+    private static void restoreProperty(final String key, final String previous) {
+        if (previous == null) {
+            System.clearProperty(key);
+        } else {
+            System.setProperty(key, previous);
+        }
+    }
+
+    private synchronized GroovyClassLoader loaderFor(final CompilerSettings settings, final ClassLoader parentLoader,
+                                                     final CompilerConfiguration configuration) {
+        LoaderKey key = new LoaderKey(settings.getClasspath(), configuration.getClasspath(),
+                settings.getExtra(), settings.isGrapeEnabled(), settings.isAstTestEnabled(), parentLoader);
+        if (groovyLoader != null && key.equals(loaderKey)) {
+            return groovyLoader;
+        }
+        closeQuietly();
+        loaderKey = key;
+        classpathLoader = settings.createClassLoader(parentLoader);
+        groovyLoader = new GroovyClassLoader(classpathLoader, configuration);
+        return groovyLoader;
+    }
+
+    /**
+     * Closes the reused class loaders.
+     */
+    @Override
+    public synchronized void close() {
+        closeQuietly();
+    }
+
+    private void closeQuietly() {
+        if (groovyLoader != null) {
+            try {
+                groovyLoader.close();
+            } catch (IOException ignored) {
+                // best-effort close
+            }
+            groovyLoader = null;
+        }
+        if (classpathLoader != null) {
+            try {
+                classpathLoader.close();
+            } catch (IOException ignored) {
+                // best-effort close
+            }
+            classpathLoader = null;
+        }
+        loaderKey = null;
+        deleteJavacOutput();
+    }
+
+    private void deleteJavacOutput() {
+        if (javacOutput == null) {
+            return;
+        }
+        try (var stream = Files.walk(javacOutput)) {
+            stream.sorted(Comparator.reverseOrder()).forEach(path -> {
+                try {
+                    Files.deleteIfExists(path);
+                } catch (IOException ignored) {
+                    // best-effort cleanup
+                }
+            });
+        } catch (IOException ignored) {
+            // best-effort cleanup
+        }
+        javacOutput = null;
+    }
+
+    private record LoaderKey(List<String> classpath, List<String> configuredClasspath, Map<String, String> extra,
+                             boolean grape, boolean astTest, ClassLoader parent) {
+
+        LoaderKey {
+            classpath = classpath == null ? List.of() : List.copyOf(classpath);
+            configuredClasspath = configuredClasspath == null ? List.of() : List.copyOf(configuredClasspath);
+            extra = extra == null || extra.isEmpty() ? Map.of() : Map.copyOf(extra);
+        }
+
+        @Override
+            public boolean equals(final Object other) {
+                if (this == other) {
+                    return true;
+                }
+                if (!(other instanceof LoaderKey key)) {
+                    return false;
+                }
+                return grape == key.grape && astTest == key.astTest
+                    && Objects.equals(classpath, key.classpath)
+                    && Objects.equals(configuredClasspath, key.configuredClasspath)
+                    && Objects.equals(extra, key.extra) && parent == key.parent;
+            }
+
+            @Override
+            public int hashCode() {
+                return Objects.hash(classpath, configuredClasspath, extra, grape, astTest,
+                        System.identityHashCode(parent));
+            }
+        }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/Identifiers.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/Identifiers.java
new file mode 100644
index 0000000000..2b85b1d4de
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/Identifiers.java
@@ -0,0 +1,152 @@
+/*
+ *  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.ASTNode;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+
+/**
+ * Word-boundary identifier scans, used to skip files that cannot contain a
+ * reference and to detect unused imports. Also maps an AST node to the
+ * identifier language servers should select, without extra fields on
+ * {@code AnnotatedNode}.
+ */
+public final class Identifiers {
+
+    private Identifiers() {
+    }
+
+    /**
+     * @param text source
+     * @param ident identifier
+     * @return {@code true} when {@code ident} occurs as a Java identifier
+     */
+    public static boolean containsWord(final String text, final String ident) {
+        return count(text, ident) > 0;
+    }
+
+    /**
+     * Counts identifier occurrences with Java identifier boundaries.
+     *
+     * @param text source, possibly {@code null}
+     * @param ident identifier, possibly {@code null}
+     * @return the count
+     */
+    public static int count(final String text, final String ident) {
+        if (text == null || ident == null || ident.isEmpty()) {
+            return 0;
+        }
+        int count = 0;
+        int from = 0;
+        while (from < text.length()) {
+            int at = text.indexOf(ident, from);
+            if (at < 0) {
+                return count;
+            }
+            boolean startOk = at == 0 || !Character.isJavaIdentifierPart(text.codePointBefore(at));
+            int end = at + ident.length();
+            boolean endOk = end >= text.length() || !Character.isJavaIdentifierPart(text.codePointAt(end));
+            if (startOk && endOk) {
+                count += 1;
+            }
+            from = at + ident.length();
+        }
+        return count;
+    }
+
+    /**
+     * Identifier covering {@code utf16Offset} on {@code line}, including
+     * when the caret sits at the start of the word.
+     *
+     * @param line a source line
+     * @param utf16Offset offset in the line
+     * @return the identifier, or {@code ""}
+     */
+    public static String wordAround(final String line, final int utf16Offset) {
+        if (line == null || line.isEmpty()) {
+            return "";
+        }
+        int idx = Math.min(Math.max(utf16Offset, 0), line.length());
+        if (idx < line.length() && Character.isJavaIdentifierPart(line.codePointAt(idx))) {
+            idx += Character.charCount(line.codePointAt(idx));
+        }
+        int start = idx;
+        while (start > 0 && Character.isJavaIdentifierPart(line.codePointBefore(start))) {
+            start -= Character.charCount(line.codePointBefore(start));
+        }
+        int end = idx;
+        while (end < line.length() && Character.isJavaIdentifierPart(line.codePointAt(end))) {
+            end += Character.charCount(line.codePointAt(end));
+        }
+        return start < end ? line.substring(start, end) : "";
+    }
+
+    /**
+     * The identifier a language server should highlight for {@code node}.
+     *
+     * @param node any AST node
+     * @return the name, or {@code null}
+     */
+    public static String nameOf(final ASTNode node) {
+        if (node instanceof VariableExpression variable) {
+            return variable.getName();
+        }
+        if (node instanceof Parameter parameter) {
+            return parameter.getName();
+        }
+        if (node instanceof MethodNode method) {
+            return method.getName();
+        }
+        if (node instanceof FieldNode field) {
+            return field.getName();
+        }
+        if (node instanceof PropertyNode property) {
+            return property.getName();
+        }
+        if (node instanceof ClassNode classNode) {
+            return classNode.getNameWithoutPackage();
+        }
+        if (node instanceof MethodCallExpression call) {
+            return call.getMethodAsString();
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return call.getMethodAsString();
+        }
+        if (node instanceof PropertyExpression property) {
+            return property.getPropertyAsString();
+        }
+        if (node instanceof ClassExpression classExpression && classExpression.getType() != null) {
+            return classExpression.getType().getNameWithoutPackage();
+        }
+        if (node instanceof ConstructorCallExpression ctor && ctor.getType() != null) {
+            return ctor.getType().getNameWithoutPackage();
+        }
+        return null;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ImportSupport.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ImportSupport.java
new file mode 100644
index 0000000000..abd59e41e6
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ImportSupport.java
@@ -0,0 +1,311 @@
+/*
+ *  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.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ImportNode;
+import org.codehaus.groovy.ast.ModuleNode;
+import org.codehaus.groovy.control.ResolveVisitor;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.DiagnosticTag;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Import insertion shared by completion additional-text-edits and the
+ * add-import code action.
+ */
+public final class ImportSupport {
+
+    private static final Set<String> DEFAULT_STAR_PACKAGES = defaultStarPackages();
+    private static final Set<String> DEFAULT_TYPES = Set.of(
+            ClassHelper.BigDecimal_TYPE.getName(), ClassHelper.BigInteger_TYPE.getName());
+
+    private ImportSupport() {
+    }
+
+    private static Set<String> defaultStarPackages() {
+        Set<String> packages = new LinkedHashSet<>();
+        for (String prefix : ResolveVisitor.DEFAULT_IMPORTS) {
+            if (prefix != null && !prefix.isEmpty()) {
+                packages.add(prefix.endsWith(".") ? prefix.substring(0, prefix.length() - 1) : prefix);
+            }
+        }
+        return Set.copyOf(packages);
+    }
+
+    /**
+     * @param module module AST
+     * @return every import, including star and static forms
+     */
+    public static List<ImportNode> allImports(final ModuleNode module) {
+        List<ImportNode> imports = new ArrayList<>();
+        if (module == null) {
+            return imports;
+        }
+        imports.addAll(module.getImports());
+        imports.addAll(module.getStarImports());
+        imports.addAll(module.getStaticImports().values());
+        imports.addAll(module.getStaticStarImports().values());
+        return imports;
+    }
+
+    /**
+     * @param module module AST
+     * @return package without a trailing dot, or {@code ""}
+     */
+    public static String packageName(final ModuleNode module) {
+        if (module == null || module.getPackageName() == null) {
+            return "";
+        }
+        String name = module.getPackageName();
+        while (name.endsWith(".")) {
+            name = name.substring(0, name.length() - 1);
+        }
+        return name;
+    }
+
+    /**
+     * @param fqcn fully qualified class name
+     * @return the package, or {@code ""}
+     */
+    public static String packageOf(final String fqcn) {
+        if (fqcn == null) {
+            return "";
+        }
+        int dot = fqcn.lastIndexOf('.');
+        return dot < 0 ? "" : fqcn.substring(0, dot);
+    }
+
+    /**
+     * @param module current module
+     * @param fqcn type to import
+     * @return {@code true} when an {@code import} statement is required
+     */
+    public static boolean needsImport(final ModuleNode module, final String fqcn) {
+        if (fqcn == null || fqcn.isBlank() || !fqcn.contains(".")) {
+            return false;
+        }
+        String pkg = packageOf(fqcn);
+        if (isDefaultImported(fqcn, pkg, module)) {
+            return false;
+        }
+        for (ImportNode existing : allImports(module)) {
+            if (fqcn.equals(existing.getClassName()) || coversStar(existing, pkg)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean isDefaultImported(final String fqcn, final String pkg, final ModuleNode module) {
+        return DEFAULT_TYPES.contains(fqcn) || DEFAULT_STAR_PACKAGES.contains(pkg) || pkg.equals(packageName(module));
+    }
+
+    private static boolean coversStar(final ImportNode existing, final String pkg) {
+        if (!existing.isStar() || existing.isStatic()) {
+            return false;
+        }
+        String star = existing.getPackageName();
+        if (star == null) {
+            return false;
+        }
+        while (star.endsWith(".")) {
+            star = star.substring(0, star.length() - 1);
+        }
+        return pkg.equals(star);
+    }
+
+    /**
+     * @param document compiled file
+     * @param fqcn type to import
+     * @param encoding negotiated encoding
+     * @return a single insert edit, or empty
+     */
+    public static List<TextEdit> addImport(final CompiledDocument document, final String fqcn,
+                                           final PositionEncoding encoding) {
+        if (document == null || document.getModule() == null || fqcn == null || fqcn.isBlank()) {
+            return List.of();
+        }
+        if (!needsImport(document.getModule(), fqcn)) {
+            return List.of();
+        }
+        Position insert = insertPosition(document, encoding);
+        return List.of(new TextEdit(new Range(insert, insert), "import " + fqcn + "\n"));
+    }
+
+    /**
+     * Simple and static imports whose identifier occurs only on the import line.
+     *
+     * @param document compiled file
+     * @return unused import nodes
+     */
+    public static List<ImportNode> unused(final CompiledDocument document) {
+        List<ImportNode> unused = new ArrayList<>();
+        if (document == null || document.getModule() == null) {
+            return unused;
+        }
+        String text = document.getText();
+        for (ImportNode imp : allImports(document.getModule())) {
+            if (isUnusedImport(imp, text)) {
+                unused.add(imp);
+            }
+        }
+        return unused;
+    }
+
+    private static boolean isUnusedImport(final ImportNode imp, final String text) {
+        if (imp.isStar()) {
+            return false;
+        }
+        String ident = importedName(imp);
+        return ident != null && !ident.isEmpty() && Identifiers.count(text, ident) <= 1;
+    }
+
+    /**
+     * @param document compiled file
+     * @param encoding negotiated encoding
+     * @return hint diagnostics for unused imports
+     */
+    public static List<Diagnostic> unusedDiagnostics(final CompiledDocument document,
+                                                     final PositionEncoding encoding) {
+        List<Diagnostic> diagnostics = new ArrayList<>();
+        for (ImportNode imp : unused(document)) {
+            Range range = rangeOf(imp, document, encoding);
+            if (range == null) {
+                continue;
+            }
+            Diagnostic diagnostic = new Diagnostic();
+            diagnostic.setRange(range);
+            diagnostic.setSeverity(DiagnosticSeverity.Hint);
+            diagnostic.setSource("groovy");
+            diagnostic.setMessage("Unused import");
+            diagnostic.setTags(List.of(DiagnosticTag.Unnecessary));
+            diagnostics.add(diagnostic);
+        }
+        return diagnostics;
+    }
+
+    /**
+     * @param document compiled file
+     * @param encoding negotiated encoding
+     * @return deletions covering unused import lines
+     */
+    public static List<TextEdit> removeUnused(final CompiledDocument document, final PositionEncoding encoding) {
+        List<TextEdit> edits = new ArrayList<>();
+        for (ImportNode imp : unused(document)) {
+            Range range = rangeOf(imp, document, encoding);
+            if (range == null) {
+                continue;
+            }
+            Position end = range.getEnd();
+            edits.add(new TextEdit(new Range(range.getStart(), new Position(end.getLine() + 1, 0)), ""));
+        }
+        return edits;
+    }
+
+    private static Range rangeOf(final ImportNode imp, final CompiledDocument document,
+                                 final PositionEncoding encoding) {
+        Range range = Positions.toRange(imp, document.getText(), encoding);
+        if (range != null) {
+            return range;
+        }
+        String snippet = imp.getText();
+        if (snippet == null || snippet.isEmpty()) {
+            return null;
+        }
+        int at = document.getText().indexOf(snippet);
+        if (at < 0) {
+            return null;
+        }
+        int line = 1;
+        for (int i = 0; i < at; i++) {
+            if (document.getText().charAt(i) == '\n') {
+                line += 1;
+            }
+        }
+        String lineText = Positions.lineText(document.getText(), line);
+        return new Range(Positions.toLsp(line, 1, lineText, encoding),
+                Positions.toLsp(line, lineText.codePointCount(0, lineText.length()) + 1, lineText, encoding));
+    }
+
+    /**
+     * @param imp import node
+     * @return the identifier introduced into the file
+     */
+    public static String importedName(final ImportNode imp) {
+        if (imp == null) {
+            return null;
+        }
+        if (imp.isStatic() && !imp.isStar()) {
+            String alias = imp.getAlias();
+            if (alias != null && !alias.isEmpty() && !alias.equals(imp.getFieldName())) {
+                return alias;
+            }
+            return imp.getFieldName();
+        }
+        String alias = imp.getAlias();
+        if (alias != null && !alias.isEmpty()) {
+            String simple = simpleName(imp.getClassName());
+            if (!alias.equals(simple)) {
+                return alias;
+            }
+        }
+        return simpleName(imp.getClassName());
+    }
+
+    private static String simpleName(final String fqcn) {
+        if (fqcn == null) {
+            return null;
+        }
+        int dot = fqcn.lastIndexOf('.');
+        return dot < 0 ? fqcn : fqcn.substring(dot + 1);
+    }
+
+    /**
+     * @param document compiled file
+     * @param encoding negotiated encoding
+     * @return insertion point after the package or before existing imports
+     */
+    public static Position insertPosition(final CompiledDocument document, final PositionEncoding encoding) {
+        if (document.getModule().getPackage() != null) {
+            Range range = Positions.toRange(document.getModule().getPackage(), document.getText(), encoding);
+            if (range != null) {
+                return new Position(range.getEnd().getLine() + 1, 0);
+            }
+        }
+        List<ImportNode> imports = allImports(document.getModule());
+        if (!imports.isEmpty()) {
+            Range range = Positions.toRange(imports.get(0), document.getText(), encoding);
+            if (range != null) {
+                return range.getStart();
+            }
+        }
+        return new Position(0, 0);
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/JavaMessage.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/JavaMessage.java
new file mode 100644
index 0000000000..09abae3e81
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/JavaMessage.java
@@ -0,0 +1,32 @@
+/*
+ *  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;
+
+/**
+ * One javac diagnostic, using Groovy's 1-based caret coordinates.
+ *
+ * @param line start line
+ * @param column start column
+ * @param endLine end line
+ * @param endColumn exclusive end column
+ * @param text message
+ * @param error {@code true} for errors
+ */
+public record JavaMessage(int line, int column, int endLine, int endColumn, String text, boolean error) {
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/JavaSymbolIndex.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/JavaSymbolIndex.java
new file mode 100644
index 0000000000..0915d93a31
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/JavaSymbolIndex.java
@@ -0,0 +1,261 @@
+/*
+ *  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 com.sun.source.tree.ClassTree;
+import com.sun.source.tree.CompilationUnitTree;
+import com.sun.source.tree.LineMap;
+import com.sun.source.tree.MethodTree;
+import com.sun.source.tree.Tree;
+import com.sun.source.tree.VariableTree;
+import com.sun.source.util.SourcePositions;
+import com.sun.source.util.TreePathScanner;
+import com.sun.source.util.Trees;
+import org.apache.groovy.lsp.internal.util.Uris;
+
+import javax.lang.model.element.Name;
+import javax.tools.JavaFileObject;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Types and members compiled from workspace {@code .java} sources.
+ * Coordinates are Groovy 1-based carets.
+ */
+public final class JavaSymbolIndex {
+
+    public static final JavaSymbolIndex EMPTY = new JavaSymbolIndex(Map.of(), Map.of(), Map.of());
+
+    private final Map<String, JavaSymbol> byName;
+    private final Map<String, List<JavaSymbol>> bySimpleName;
+    private final Map<String, List<JavaSymbol>> members;
+
+    private JavaSymbolIndex(final Map<String, JavaSymbol> byName, final Map<String, List<JavaSymbol>> bySimpleName,
+                            final Map<String, List<JavaSymbol>> members) {
+        this.byName = byName;
+        this.bySimpleName = bySimpleName;
+        this.members = members;
+    }
+
+    /**
+     * Walks {@code units} using {@code trees} captured from the javac task
+     * before {@code generate()}.
+     *
+     * @param trees javac tree API
+     * @param units parsed compilation units
+     * @return an index, never {@code null}
+     */
+    public static JavaSymbolIndex of(final Trees trees, final Iterable<? extends CompilationUnitTree> units) {
+        if (trees == null || units == null) {
+            return EMPTY;
+        }
+        Map<String, JavaSymbol> names = new LinkedHashMap<>();
+        Map<String, List<JavaSymbol>> simple = new LinkedHashMap<>();
+        Map<String, List<JavaSymbol>> members = new LinkedHashMap<>();
+        SourcePositions positions = trees.getSourcePositions();
+        for (CompilationUnitTree unit : units) {
+            if (unit == null || isStub(unit.getSourceFile())) {
+                continue;
+            }
+            URI uri = Uris.normalize(unit.getSourceFile().toUri());
+            LineMap lineMap = unit.getLineMap();
+            new IndexingScanner(uri, unit, positions, lineMap, new IndexMaps(names, simple, members)).scan(unit, null);
+        }
+        return new JavaSymbolIndex(Map.copyOf(names), copyLists(simple), copyLists(members));
+    }
+
+    /**
+     * @param qualifiedName FQCN
+     * @return the type, or {@code null}
+     */
+    public JavaSymbol type(final String qualifiedName) {
+        return qualifiedName == null ? null : byName.get(qualifiedName);
+    }
+
+    /**
+     * @return every indexed type
+     */
+    public List<JavaSymbol> types() {
+        return List.copyOf(byName.values());
+    }
+
+    /**
+     * @param simpleName unqualified name
+     * @return hits, possibly empty
+     */
+    public List<JavaSymbol> typesNamed(final String simpleName) {
+        if (simpleName == null) {
+            return List.of();
+        }
+        List<JavaSymbol> hits = bySimpleName.get(simpleName);
+        return hits == null ? List.of() : hits;
+    }
+
+    /**
+     * Members of {@code owner} with {@code simpleName}.
+     *
+     * @param owner FQCN
+     * @param simpleName member name
+     * @return hits, possibly empty
+     */
+    public List<JavaSymbol> members(final String owner, final String simpleName) {
+        if (owner == null || simpleName == null) {
+            return List.of();
+        }
+        List<JavaSymbol> hits = members.get(owner + "#" + simpleName);
+        return hits == null ? List.of() : hits;
+    }
+
+    private static boolean isStub(final JavaFileObject file) {
+        if (file == null || file.toUri() == null) {
+            return true;
+        }
+        String scheme = file.toUri().getScheme();
+        return scheme != null && "string".equalsIgnoreCase(scheme);
+    }
+
+    private static Map<String, List<JavaSymbol>> copyLists(final Map<String, List<JavaSymbol>> source) {
+        Map<String, List<JavaSymbol>> copy = new LinkedHashMap<>();
+        for (Map.Entry<String, List<JavaSymbol>> entry : source.entrySet()) {
+            copy.put(entry.getKey(), List.copyOf(entry.getValue()));
+        }
+        return Map.copyOf(copy);
+    }
+
+    /**
+     * One Java type or member.
+     *
+     * @param qualifiedName FQCN for types; {@code owner#name} for members
+     * @param simpleName identifier
+     * @param owner FQCN of the enclosing type, or {@code null} for a top-level type
+     * @param uri source file
+     * @param line 1-based start line
+     * @param column 1-based start column
+     * @param endLine 1-based end line
+     * @param endColumn exclusive 1-based end column
+     */
+    public record JavaSymbol(String qualifiedName, String simpleName, String owner, URI uri,
+                             int line, int column, int endLine, int endColumn) {
+    }
+
+    private record IndexMaps(Map<String, JavaSymbol> names, Map<String, List<JavaSymbol>> simple,
+                             Map<String, List<JavaSymbol>> members) {
+    }
+
+    private static final class IndexingScanner extends TreePathScanner<Void, Void> {
+        private final URI uri;
+        private final CompilationUnitTree unit;
+        private final SourcePositions positions;
+        private final LineMap lineMap;
+        private final IndexMaps maps;
+        private final List<String> enclosing = new ArrayList<>();
+
+        private IndexingScanner(final URI uri, final CompilationUnitTree unit, final SourcePositions positions,
+                                final LineMap lineMap, final IndexMaps maps) {
+            this.uri = uri;
+            this.unit = unit;
+            this.positions = positions;
+            this.lineMap = lineMap;
+            this.maps = maps;
+        }
+
+        @Override
+        public Void visitClass(final ClassTree node, final Void unused) {
+            String simpleName = text(node.getSimpleName());
+            if (simpleName.isEmpty()) {
+                return super.visitClass(node, unused);
+            }
+            String qualified = qualified(simpleName);
+            JavaSymbol symbol = symbol(qualified, simpleName, enclosing.isEmpty() ? null : enclosing.get(enclosing.size() - 1),
+                    node);
+            maps.names().put(qualified, symbol);
+            maps.simple().computeIfAbsent(simpleName, key -> new ArrayList<>()).add(symbol);
+            enclosing.add(qualified);
+            try {
+                return super.visitClass(node, unused);
+            } finally {
+                enclosing.remove(enclosing.size() - 1);
+            }
+        }
+
+        @Override
+        public Void visitMethod(final MethodTree node, final Void unused) {
+            addMember(text(node.getName()), node);
+            return super.visitMethod(node, unused);
+        }
+
+        @Override
+        public Void visitVariable(final VariableTree node, final Void unused) {
+            Tree.Kind parent = getCurrentPath() == null || getCurrentPath().getParentPath() == null
+                    ? null : getCurrentPath().getParentPath().getLeaf().getKind();
+            if (parent == Tree.Kind.CLASS || parent == Tree.Kind.INTERFACE || parent == Tree.Kind.ENUM
+                    || parent == Tree.Kind.RECORD) {
+                addMember(text(node.getName()), node);
+            }
+            return super.visitVariable(node, unused);
+        }
+
+        private void addMember(final String simpleName, final Tree node) {
+            if (simpleName.isEmpty() || "<init>".equals(simpleName) || enclosing.isEmpty()) {
+                return;
+            }
+            String owner = enclosing.get(enclosing.size() - 1);
+            JavaSymbol symbol = symbol(owner + "#" + simpleName, simpleName, owner, node);
+            maps.members().computeIfAbsent(owner + "#" + simpleName, key -> new ArrayList<>()).add(symbol);
+        }
+
+        private String qualified(final String simpleName) {
+            if (enclosing.isEmpty()) {
+                String pkg = packageName();
+                return pkg.isEmpty() ? simpleName : pkg + "." + simpleName;
+            }
+            return enclosing.get(enclosing.size() - 1) + "$" + simpleName;
+        }
+
+        private String packageName() {
+            if (unit.getPackageName() == null) {
+                return "";
+            }
+            return unit.getPackageName().toString();
+        }
+
+        private JavaSymbol symbol(final String qualified, final String simpleName, final String owner, final Tree node) {
+            long start = positions.getStartPosition(unit, node);
+            long end = positions.getEndPosition(unit, node);
+            if (start < 0) {
+                start = 0;
+            }
+            if (end < start) {
+                end = start;
+            }
+            int line = (int) lineMap.getLineNumber(start);
+            int column = (int) lineMap.getColumnNumber(start);
+            int endLine = (int) lineMap.getLineNumber(end);
+            int endColumn = (int) lineMap.getColumnNumber(end);
+            return new JavaSymbol(qualified, simpleName, owner, uri, line, column, endLine, endColumn);
+        }
+
+        private static String text(final Name name) {
+            return name == null ? "" : name.toString();
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/LspJavaCompiler.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/LspJavaCompiler.java
new file mode 100644
index 0000000000..3f00f48452
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/LspJavaCompiler.java
@@ -0,0 +1,247 @@
+/*
+ *  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 com.sun.source.tree.CompilationUnitTree;
+import com.sun.source.util.JavacTask;
+import com.sun.source.util.Trees;
+import org.apache.groovy.lsp.internal.util.Uris;
+import groovy.lang.GroovyObject;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.tools.javac.JavaCompiler;
+import org.codehaus.groovy.tools.javac.JavaCompilerFactory;
+
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaFileObject;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Joint-compilation javac backend for groovy-lsp. Uses the public
+ * {@code javax.tools} / {@code com.sun.source} APIs (no {@code --add-exports}).
+ * Open buffers overlay the filesystem; stub sources stay in memory
+ * ({@code memStub}).
+ */
+public final class LspJavaCompiler implements JavaCompiler, JavaCompilerFactory {
+
+    private final Map<URI, String> overlays;
+    private JavaSymbolIndex index = JavaSymbolIndex.EMPTY;
+    private final Map<URI, List<JavaMessage>> messages = new LinkedHashMap<>();
+
+    /**
+     * @param overlays open Java buffers keyed by normalized URI
+     */
+    public LspJavaCompiler(final Map<URI, String> overlays) {
+        this.overlays = overlays == null ? Map.of() : Map.copyOf(overlays);
+    }
+
+    /**
+     * @return types and members from the last javac run
+     */
+    public JavaSymbolIndex index() {
+        return index;
+    }
+
+    /**
+     * @return javac diagnostics from the last run, keyed by source URI
+     */
+    public Map<URI, List<JavaMessage>> messages() {
+        return Map.copyOf(messages);
+    }
+
+    @Override
+    public JavaCompiler createCompiler(final CompilerConfiguration config) {
+        return this;
+    }
+
+    @Override
+    public void compile(final List<String> files, final CompilationUnit cu) {
+        index = JavaSymbolIndex.EMPTY;
+        messages.clear();
+        var javac = ToolProvider.getSystemJavaCompiler();
+        if (javac == null || cu == null) {
+            return;
+        }
+        CompilerConfiguration config = cu.getConfiguration();
+        DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
+        Charset charset = Charset.forName(config.getSourceEncoding());
+        try (StandardJavaFileManager fileManager = javac.getStandardFileManager(diagnostics, Locale.ENGLISH, charset)) {
+            Set<JavaFileObject> units = new LinkedHashSet<>();
+            if (cu.getJavaCompilationUnitSet() != null) {
+                units.addAll(cu.getJavaCompilationUnitSet());
+            }
+            addSources(units, files, fileManager);
+            JavacTask task = (JavacTask) javac.getTask(null, fileManager, diagnostics, parameters(config),
+                    null, units);
+            Trees treeApi = Trees.instance(task);
+            Iterable<? extends CompilationUnitTree> trees = task.parse();
+            task.analyze();
+            task.generate();
+            index = JavaSymbolIndex.of(treeApi, trees);
+            collectDiagnostics(diagnostics);
+        } catch (IOException | RuntimeException ignored) {
+            // keep whatever diagnostics were collected
+        }
+    }
+
+    private void addSources(final Set<JavaFileObject> units, final List<String> files,
+                            final StandardJavaFileManager fileManager) {
+        Set<URI> seen = recordedUris(units);
+        addOverlays(units, seen);
+        addRemainingFiles(units, files, fileManager, seen);
+    }
+
+    private static Set<URI> recordedUris(final Set<JavaFileObject> units) {
+        Set<URI> seen = new LinkedHashSet<>();
+        for (JavaFileObject unit : units) {
+            if (unit != null && unit.toUri() != null) {
+                seen.add(Uris.normalize(unit.toUri()));
+            }
+        }
+        return seen;
+    }
+
+    private void addOverlays(final Set<JavaFileObject> units, final Set<URI> seen) {
+        for (Map.Entry<URI, String> overlay : overlays.entrySet()) {
+            URI uri = Uris.normalize(overlay.getKey());
+            if (seen.add(uri)) {
+                units.add(new BufferJavaFileObject(uri, overlay.getValue()));
+            }
+        }
+    }
+
+    private void addRemainingFiles(final Set<JavaFileObject> units, final List<String> files,
+                                   final StandardJavaFileManager fileManager, final Set<URI> seen) {
+        if (files == null) {
+            return;
+        }
+        List<File> remaining = new ArrayList<>();
+        for (String file : files) {
+            if (file != null) {
+                addIfNewFile(remaining, file, seen);
+            }
+        }
+        if (!remaining.isEmpty()) {
+            for (JavaFileObject object : fileManager.getJavaFileObjectsFromFiles(remaining)) {
+                units.add(object);
+            }
+        }
+    }
+
+    private void addIfNewFile(final List<File> remaining, final String file, final Set<URI> seen) {
+        Path path = Path.of(file);
+        URI uri = Uris.normalize(path.toUri());
+        if (!overlays.containsKey(uri) && seen.add(uri) && Files.isRegularFile(path)) {
+            remaining.add(path.toFile());
+        }
+    }
+
+    private void collectDiagnostics(final DiagnosticCollector<JavaFileObject> diagnostics) {
+        for (Diagnostic<? extends JavaFileObject> diagnostic : diagnostics.getDiagnostics()) {
+            URI uri = diagnosticUri(diagnostic);
+            if (uri != null) {
+                messages.computeIfAbsent(uri, key -> new ArrayList<>()).add(toMessage(diagnostic));
+            }
+        }
+    }
+
+    private static URI diagnosticUri(final Diagnostic<? extends JavaFileObject> diagnostic) {
+        if (diagnostic == null || diagnostic.getKind() == Diagnostic.Kind.NOTE) {
+            return null;
+        }
+        JavaFileObject source = diagnostic.getSource();
+        if (source == null || source.toUri() == null) {
+            return null;
+        }
+        URI uri = Uris.normalize(source.toUri());
+        return "string".equalsIgnoreCase(uri.getScheme()) ? null : uri;
+    }
+
+    private static JavaMessage toMessage(final Diagnostic<? extends JavaFileObject> diagnostic) {
+        int line = bounded(diagnostic.getLineNumber());
+        int column = bounded(diagnostic.getColumnNumber());
+        String text = diagnostic.getMessage(Locale.ENGLISH);
+        boolean error = diagnostic.getKind() == Diagnostic.Kind.ERROR;
+        return new JavaMessage(line, column, line, column + 1, text == null ? "" : text, error);
+    }
+
+    private static int bounded(final long value) {
+        if (value <= 0L || value > Integer.MAX_VALUE) {
+            return 1;
+        }
+        return (int) value;
+    }
+
+    private static List<String> parameters(final CompilerConfiguration config) {
+        List<String> params = new ArrayList<>();
+        File target = config.getTargetDirectory();
+        if (target == null) {
+            target = new File(".");
+        }
+        params.add("-d");
+        params.add(target.getAbsolutePath());
+        params.add("-proc:none");
+        params.add("--release");
+        params.add(config.getTargetBytecode());
+        List<String> classpath = new ArrayList<>(config.getClasspath());
+        try {
+            var codeSource = GroovyObject.class.getProtectionDomain().getCodeSource();
+            if (codeSource != null && codeSource.getLocation() != null) {
+                classpath.add(new File(codeSource.getLocation().toURI()).getPath());
+            }
+        } catch (Exception ignored) {
+            // groovy runtime already on the compiler classpath in tests
+        }
+        if (!classpath.isEmpty()) {
+            params.add("-classpath");
+            params.add(String.join(File.pathSeparator, classpath));
+        }
+        return params;
+    }
+
+    private static final class BufferJavaFileObject extends SimpleJavaFileObject {
+        private final String content;
+
+        private BufferJavaFileObject(final URI uri, final String content) {
+            super(uri, Kind.SOURCE);
+            this.content = content == null ? "" : content;
+        }
+
+        @Override
+        public CharSequence getCharContent(final boolean ignoreEncodingErrors) {
+            return content;
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/PackageGuess.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/PackageGuess.java
new file mode 100644
index 0000000000..67061a5dcd
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/PackageGuess.java
@@ -0,0 +1,82 @@
+/*
+ *  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.apache.groovy.lsp.internal.util.Uris;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Infers a Groovy package from a file's path under conventional source roots,
+ * the same idea as Metals' package provider.
+ */
+public final class PackageGuess {
+
+    private PackageGuess() {
+    }
+
+    /**
+     * @param file document URI
+     * @param folders workspace folders
+     * @param sourcePaths client source paths
+     * @return a dotted package, or {@code ""}
+     */
+    public static String fromUri(final URI file, final Collection<URI> folders, final List<String> sourcePaths) {
+        if (file == null || !"file".equalsIgnoreCase(file.getScheme()) || folders == null) {
+            return "";
+        }
+        Path path;
+        try {
+            path = Uris.toPath(file);
+        } catch (RuntimeException ex) {
+            return "";
+        }
+        Path parent = path.getParent();
+        if (parent == null) {
+            return "";
+        }
+        for (URI folder : folders) {
+            String guessed = packageUnder(parent, folder, sourcePaths);
+            if (guessed != null) {
+                return guessed;
+            }
+        }
+        return "";
+    }
+
+    private static String packageUnder(final Path parent, final URI folder, final List<String> sourcePaths) {
+        if (folder == null || !"file".equalsIgnoreCase(folder.getScheme())) {
+            return null;
+        }
+        Path root = Uris.toPath(folder);
+        for (Path sourceRoot : WorkspaceLayout.searchRoots(root, sourcePaths)) {
+            if (parent.startsWith(sourceRoot)) {
+                Path relative = sourceRoot.relativize(parent);
+                if (relative.getNameCount() == 0 || ".".equals(relative.toString())) {
+                    return "";
+                }
+                return relative.toString().replace('\\', '/').replace('/', '.');
+            }
+        }
+        return null;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/TypeIndex.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/TypeIndex.java
new file mode 100644
index 0000000000..d5c039e674
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/TypeIndex.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.apache.groovy.lsp.internal.compile;
+
+import groovy.lang.groovydoc.Groovydoc;
+import org.codehaus.groovy.ast.ClassNode;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Simple-name and FQCN index of types in a {@link CompilationSnapshot}.
+ */
+public final class TypeIndex {
+
+    public static final TypeIndex EMPTY = new TypeIndex(Map.of(), Map.of());
+
+    private final Map<String, List<TypeHit>> bySimpleName;
+    private final Map<String, TypeHit> byName;
+
+    private TypeIndex(final Map<String, List<TypeHit>> bySimpleName, final Map<String, TypeHit> byName) {
+        this.bySimpleName = bySimpleName;
+        this.byName = byName;
+    }
+
+    /**
+     * Builds an index over every non-synthetic class in {@code snapshot}.
+     *
+     * @param snapshot compiled workspace
+     * @return the index
+     */
+    public static TypeIndex of(final CompilationSnapshot snapshot) {
+        if (snapshot == null) {
+            return EMPTY;
+        }
+        Map<String, List<TypeHit>> simple = new LinkedHashMap<>();
+        Map<String, TypeHit> names = new LinkedHashMap<>();
+        for (CompiledDocument document : snapshot.documents()) {
+            if (document.getModule() == null) {
+                continue;
+            }
+            for (ClassNode classNode : document.getModule().getClasses()) {
+                if (classNode.isScript() && classNode.getLineNumber() <= 0) {
+                    continue;
+                }
+                TypeHit hit = TypeHit.of(classNode, document.getUri());
+                names.put(hit.name(), hit);
+                simple.computeIfAbsent(hit.simpleName(), key -> new ArrayList<>()).add(hit);
+            }
+        }
+        for (JavaSymbolIndex.JavaSymbol symbol : snapshot.javaSymbols().types()) {
+            TypeHit hit = new TypeHit(symbol.qualifiedName(), symbol.simpleName(), symbol.uri(), false, false, "");
+            names.putIfAbsent(hit.name(), hit);
+            simple.computeIfAbsent(hit.simpleName(), key -> new ArrayList<>()).add(hit);
+        }
+        return new TypeIndex(Map.copyOf(simple), Map.copyOf(names));
+    }
+
+    /**
+     * @param simpleName unqualified name
+     * @return hits, possibly empty
+     */
+    public List<TypeHit> bySimpleName(final String simpleName) {
+        if (simpleName == null) {
+            return List.of();
+        }
+        List<TypeHit> hits = bySimpleName.get(simpleName);
+        return hits == null ? List.of() : hits;
+    }
+
+    /**
+     * @param simpleName unqualified name
+     * @return the only hit, or {@code null} when missing or ambiguous
+     */
+    public TypeHit uniqueBySimpleName(final String simpleName) {
+        List<TypeHit> hits = bySimpleName(simpleName);
+        return hits.size() == 1 ? hits.get(0) : null;
+    }
+
+    /**
+     * @param name FQCN
+     * @return the hit, or {@code null}
+     */
+    public TypeHit byName(final String name) {
+        return name == null ? null : byName.get(name);
+    }
+
+    /**
+     * Types whose simple name starts with {@code prefix}, in document order.
+     *
+     * @param prefix case-insensitive prefix, possibly empty
+     * @param limit maximum hits
+     * @return matching types
+     */
+    public List<TypeHit> matchingPrefix(final String prefix, final int limit) {
+        String needle = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
+        List<TypeHit> hits = new ArrayList<>();
+        for (TypeHit hit : byName.values()) {
+            if (needle.isEmpty() || hit.simpleName().toLowerCase(Locale.ROOT).startsWith(needle)) {
+                hits.add(hit);
+                if (hits.size() >= limit) {
+                    break;
+                }
+            }
+        }
+        return hits;
+    }
+
+    /**
+     * One indexed type.
+     *
+     * @param name FQCN
+     * @param simpleName unqualified name
+     * @param uri declaring file
+     * @param iface whether the type is an interface
+     * @param enumeration whether the type is an enum
+     * @param documentation Groovydoc markdown, possibly empty
+     */
+    public record TypeHit(String name, String simpleName, URI uri, boolean iface, boolean enumeration,
+                          String documentation) {
+
+        static TypeHit of(final ClassNode classNode, final URI uri) {
+            return new TypeHit(classNode.getName(), classNode.getNameWithoutPackage(), uri,
+                    classNode.isInterface(), classNode.isEnum(), documentation(classNode));
+        }
+
+        private static String documentation(final ClassNode classNode) {
+            Groovydoc groovydoc = classNode.getGroovydoc();
+            if (groovydoc == null || !groovydoc.isPresent()) {
+                return "";
+            }
+            String content = groovydoc.getContent();
+            return content == null ? "" : content;
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceLayout.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceLayout.java
new file mode 100644
index 0000000000..83db1f35f1
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceLayout.java
@@ -0,0 +1,150 @@
+/*
+ *  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.apache.groovy.lsp.internal.util.Uris;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Stream;
+
+/**
+ * Best-effort source-root and jar discovery when the client leaves
+ * {@code groovy.sourcePaths} / {@code groovy.classpath} empty. Does not
+ * invoke Gradle or Maven.
+ */
+public final class WorkspaceLayout {
+
+    private WorkspaceLayout() {
+    }
+
+    /**
+     * Search roots for Groovy sources under {@code folder}.
+     *
+     * @param folder workspace folder
+     * @param sourcePaths client source paths, possibly empty
+     * @return directories to walk
+     */
+    public static List<Path> searchRoots(final Path folder, final List<String> sourcePaths) {
+        List<Path> roots = new ArrayList<>();
+        if (folder == null || !Files.isDirectory(folder)) {
+            return roots;
+        }
+        if (sourcePaths != null && !sourcePaths.isEmpty()) {
+            return explicitRoots(folder, sourcePaths);
+        }
+        addIfDirectory(roots, folder.resolve("src/main/groovy"));
+        addIfDirectory(roots, folder.resolve("src/test/groovy"));
+        addIfDirectory(roots, folder.resolve("src/main/java"));
+        addIfDirectory(roots, folder.resolve("src/test/java"));
+        if (roots.isEmpty()) {
+            addIfDirectory(roots, folder.resolve("src"));
+        }
+        if (roots.isEmpty()) {
+            roots.add(folder);
+        }
+        return roots;
+    }
+
+    private static List<Path> explicitRoots(final Path folder, final List<String> sourcePaths) {
+        List<Path> roots = new ArrayList<>();
+        for (String sourcePath : sourcePaths) {
+            Path extra = Path.of(sourcePath);
+            roots.add(extra.isAbsolute() ? extra : folder.resolve(sourcePath));
+        }
+        return roots;
+    }
+
+    private static void addIfDirectory(final List<Path> roots, final Path path) {
+        if (Files.isDirectory(path)) {
+            roots.add(path);
+        }
+    }
+
+    /**
+     * Jars under {@code lib/} and {@code libs/} of {@code folder}.
+     *
+     * @param folder workspace folder
+     * @return absolute jar paths
+     */
+    public static List<String> classpathJars(final Path folder) {
+        List<String> jars = new ArrayList<>();
+        if (folder == null || !Files.isDirectory(folder)) {
+            return jars;
+        }
+        for (String dir : List.of("lib", "libs")) {
+            Path path = folder.resolve(dir);
+            if (!Files.isDirectory(path)) {
+                continue;
+            }
+            try (Stream<Path> stream = Files.list(path)) {
+                stream.filter(Files::isRegularFile)
+                        .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
+                        .forEach(p -> jars.add(p.toAbsolutePath().toString()));
+            } catch (IOException ignored) {
+                // skip unreadable lib dirs
+            }
+        }
+        return jars;
+    }
+
+    /**
+     * Fills empty classpath / source-path lists from conventional layout.
+     *
+     * @param settings client settings
+     * @param folders workspace folders
+     * @return settings to compile with
+     */
+    public static CompilerSettings withInferred(final CompilerSettings settings, final Collection<URI> folders) {
+        CompilerSettings current = settings == null ? CompilerSettings.defaults() : settings;
+        List<String> classpath = new ArrayList<>(current.getClasspath());
+        List<String> sourcePaths = new ArrayList<>(current.getSourcePaths());
+        if (folders != null) {
+            for (URI folder : folders) {
+                inferFromFolder(folder, classpath, sourcePaths);
+            }
+        }
+        return new CompilerSettings(classpath, sourcePaths, current.getThroughPhase(),
+                current.isGrapeEnabled(), current.isAstTestEnabled(), current.getExtra());
+    }
+
+    private static void inferFromFolder(final URI folder, final List<String> classpath,
+                                        final List<String> sourcePaths) {
+        if (folder == null || !"file".equalsIgnoreCase(folder.getScheme())) {
+            return;
+        }
+        Path path = Uris.toPath(folder);
+        if (sourcePaths.isEmpty()) {
+            for (Path root : searchRoots(path, List.of())) {
+                if (!root.equals(path)) {
+                    sourcePaths.add(Uris.toUnix(path.relativize(root)));
+                }
+            }
+        }
+        if (classpath.isEmpty()) {
+            classpath.addAll(classpathJars(path));
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceScanner.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceScanner.java
new file mode 100644
index 0000000000..5c73049d77
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceScanner.java
@@ -0,0 +1,95 @@
+/*
+ *  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.apache.groovy.lsp.internal.util.Uris;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Finds Groovy and Java sources under workspace folders and configured source paths.
+ */
+public final class WorkspaceScanner {
+
+    private static final int MAX_FILES = 4000;
+
+    /**
+     * Scans {@code roots} plus {@code sourcePaths} (resolved against each root)
+     * for Groovy files, skipping {@code build}, {@code .git} and similar.
+     *
+     * @param roots workspace folders
+     * @param sourcePaths extra relative or absolute source roots
+     * @return discovered files
+     */
+    public List<Path> scan(final Collection<URI> roots, final List<String> sourcePaths) {
+        List<Path> files = new ArrayList<>();
+        List<Path> searchRoots = new ArrayList<>();
+        if (roots != null) {
+            for (URI root : roots) {
+                if (root == null || !"file".equalsIgnoreCase(root.getScheme())) {
+                    continue;
+                }
+                searchRoots.addAll(WorkspaceLayout.searchRoots(Uris.toPath(root), sourcePaths));
+            }
+        }
+        for (Path root : searchRoots) {
+            if (files.size() >= MAX_FILES) {
+                break;
+            }
+            addGroovyFiles(files, root);
+        }
+        return files;
+    }
+
+    private void addGroovyFiles(final List<Path> files, final Path root) {
+        if (!Files.isDirectory(root)) {
+            return;
+        }
+        try (Stream<Path> stream = Files.walk(root)) {
+            stream.filter(Files::isRegularFile)
+                    .filter(p -> {
+                        String name = p.getFileName().toString();
+                        return Uris.isGroovyFileName(name) || Uris.isJavaFileName(name);
+                    })
+                    .filter(p -> !isIgnored(root.relativize(p)))
+                    .limit(MAX_FILES - (long) files.size())
+                    .forEach(files::add);
+        } catch (IOException ignored) {
+            // skip unreadable trees
+        }
+    }
+
+    private boolean isIgnored(final Path relative) {
+        for (Path part : relative) {
+            String name = part.toString();
+            if ("build".equals(name) || "out".equals(name) || "target".equals(name)
+                    || ".git".equals(name) || ".gradle".equals(name) || "node_modules".equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/diagnostic/DiagnosticConverter.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/diagnostic/DiagnosticConverter.java
new file mode 100644
index 0000000000..c58f640f72
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/diagnostic/DiagnosticConverter.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.apache.groovy.lsp.internal.diagnostic;
+
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.JavaMessage;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.codehaus.groovy.control.messages.Message;
+import org.codehaus.groovy.control.messages.SyntaxErrorMessage;
+import org.codehaus.groovy.control.messages.WarningMessage;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.DiagnosticTag;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Maps Groovy {@link Message} instances onto LSP diagnostics.
+ */
+public final class DiagnosticConverter {
+
+    public static final String SOURCE = "groovy";
+
+    /**
+     * Converts errors and warnings of {@code document} to LSP diagnostics.
+     *
+     * @param document compiled document
+     * @param encoding negotiated encoding
+     * @return diagnostics, never {@code null}
+     */
+    public List<Diagnostic> convert(final CompiledDocument document, final PositionEncoding encoding) {
+        List<Diagnostic> diagnostics = new ArrayList<>();
+        if (document == null) {
+            return diagnostics;
+        }
+        for (Message message : document.getErrors()) {
+            if (belongsTo(message, document)) {
+                diagnostics.add(toDiagnostic(message, document.getText(), encoding, DiagnosticSeverity.Error));
+            }
+        }
+        for (WarningMessage warning : document.getWarnings()) {
+            if (belongsTo(warning, document)) {
+                diagnostics.add(toDiagnostic(warning, document.getText(), encoding, DiagnosticSeverity.Warning));
+            }
+        }
+        for (JavaMessage javaMessage : document.getJavaMessages()) {
+            diagnostics.add(toJavaDiagnostic(javaMessage, document.getText(), encoding));
+        }
+        return diagnostics;
+    }
+
+    static boolean belongsTo(final Message message, final CompiledDocument document) {
+        if (document == null || message == null) {
+            return false;
+        }
+        org.codehaus.groovy.control.messages.Diagnostic groovy;
+        try {
+            groovy = message.toDiagnostic();
+        } catch (RuntimeException ignored) {
+            return false;
+        }
+        String file = groovy.file();
+        if (file == null || file.isBlank()) {
+            return false;
+        }
+        String uri = document.getUri() == null ? "" : document.getUri().toString();
+        String unit = document.getSourceUnit() == null ? "" : document.getSourceUnit().getName();
+        return file.equals(uri) || file.equals(unit);
+    }
+
+    Diagnostic toDiagnostic(final Message message, final String text, final PositionEncoding encoding,
+                            final DiagnosticSeverity fallback) {
+        org.codehaus.groovy.control.messages.Diagnostic groovy;
+        try {
+            groovy = message.toDiagnostic();
+        } catch (RuntimeException ignored) {
+            groovy = new org.codehaus.groovy.control.messages.Diagnostic(null, -1, -1, message.toString());
+        }
+        Range range = rangeOf(message, groovy, text, encoding);
+        String msg = messageText(message, groovy);
+        Diagnostic diagnostic = new Diagnostic(range, msg, fallback, SOURCE);
+        tag(diagnostic, msg, fallback);
+        return diagnostic;
+    }
+
+    private static Range rangeOf(final Message message, final org.codehaus.groovy.control.messages.Diagnostic groovy,
+                                 final String text, final PositionEncoding encoding) {
+        Range range;
+        if (message instanceof SyntaxErrorMessage syntax) {
+            SyntaxException cause = syntax.getCause();
+            range = Positions.toRange(cause.getStartLine(), cause.getStartColumn(),
+                    cause.getEndLine(), cause.getEndColumn(), text, encoding);
+        } else {
+            int line = groovy.line() > 0 ? groovy.line() : 1;
+            int column = groovy.column() > 0 ? groovy.column() : 1;
+            range = Positions.toRange(line, column, line, column + 1, text, encoding);
+        }
+        return range == null ? new Range(new Position(0, 0), new Position(0, 0)) : range;
+    }
+
+    private static String messageText(final Message message, final org.codehaus.groovy.control.messages.Diagnostic groovy) {
+        String msg;
+        if (message instanceof SyntaxErrorMessage syntax) {
+            msg = syntax.getCause().getOriginalMessage();
+        } else {
+            msg = groovy.text();
+        }
+        return msg == null || msg.isBlank() ? message.toString() : msg;
+    }
+
+    private static Diagnostic toJavaDiagnostic(final JavaMessage message,
+                                               final String text, final PositionEncoding encoding) {
+        Range range = Positions.toRange(message.line(), message.column(), message.endLine(), message.endColumn(),
+                text, encoding);
+        if (range == null) {
+            range = new Range(new Position(0, 0), new Position(0, 0));
+        }
+        DiagnosticSeverity severity = message.error() ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning;
+        Diagnostic diagnostic = new Diagnostic(range, message.text(), severity, "javac");
+        tag(diagnostic, message.text(), severity);
+        return diagnostic;
+    }
+
+    private static void tag(final Diagnostic diagnostic, final String msg, final DiagnosticSeverity fallback) {
+        if (msg != null && msg.toLowerCase().contains("deprecated")) {
+            diagnostic.setTags(List.of(DiagnosticTag.Deprecated));
+        }
+        if (msg != null && msg.toLowerCase().contains("unused")) {
+            diagnostic.setTags(List.of(DiagnosticTag.Unnecessary));
+            if (fallback == DiagnosticSeverity.Warning) {
+                diagnostic.setSeverity(DiagnosticSeverity.Hint);
+            }
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CodeActionService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CodeActionService.java
new file mode 100644
index 0000000000..79ca35ed1a
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CodeActionService.java
@@ -0,0 +1,542 @@
+/*
+ *  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.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.ImportSupport;
+import org.apache.groovy.lsp.internal.compile.PackageGuess;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+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.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.eclipse.lsp4j.CodeAction;
+import org.eclipse.lsp4j.CodeActionKind;
+import org.eclipse.lsp4j.Command;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.WorkspaceEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Extra code actions inspired by Metals: unused imports, implement
+ * abstracts, insert inferred types (fields and locals), named arguments,
+ * add package, create missing class.
+ */
+public final class CodeActionService {
+
+    private static final String CANONICAL = "Canonical";
+
+    /**
+     * @param document open document
+     * @param snapshot latest compile
+     * @param diagnostics client diagnostics
+     * @param range requested range
+     * @param encoding negotiated encoding
+     * @param folders workspace folders
+     * @param sourcePaths client source paths
+     * @return extra actions
+     */
+    public List<Either<Command, CodeAction>> contribute(final TextDocument document,
+                                                        final CompilationSnapshot snapshot,
+                                                        final List<Diagnostic> diagnostics,
+                                                        final Range range, final PositionEncoding encoding,
+                                                        final Collection<URI> folders, final List<String> sourcePaths) {
+        List<Either<Command, CodeAction>> actions = new ArrayList<>();
+        CompiledDocument compiled = snapshot == null || document == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return actions;
+        }
+        addRemoveUnused(actions, compiled, encoding);
+        addPackage(actions, compiled, folders, sourcePaths, encoding);
+        addImplementAbstracts(actions, compiled, range, encoding);
+        addInferredType(actions, compiled, range, encoding);
+        addNamedArguments(actions, compiled, snapshot, range, encoding);
+        addCreateClass(actions, compiled, diagnostics, encoding);
+        addSourceGeneration(actions, compiled, range, encoding);
+        return actions;
+    }
+
+    private static void addSourceGeneration(final List<Either<Command, CodeAction>> actions,
+                                            final CompiledDocument compiled, final Range range,
+                                            final PositionEncoding encoding) {
+        for (ClassNode classNode : compiled.getModule().getClasses()) {
+            addSourceGenerationFor(actions, compiled, classNode, range, encoding);
+        }
+    }
+
+    private static void addSourceGenerationFor(final List<Either<Command, CodeAction>> actions,
+                                               final CompiledDocument compiled, final ClassNode classNode,
+                                               final Range range, final PositionEncoding encoding) {
+        if (classNode.isInterface() || classNode.isScript() || classNode.getLineNumber() <= 0) {
+            return;
+        }
+        Range full = Positions.toRange(classNode, compiled.getText(), encoding);
+        if (range != null && full != null && !overlaps(full, range)) {
+            return;
+        }
+        addGenerate(actions, compiled, "Generate getters and setters", SourceGeneration.GENERATE_ACCESSORS,
+                SourceGeneration.accessors(classNode, compiled, encoding));
+        if (!hasAst(classNode, "ToString") && !hasAst(classNode, CANONICAL)) {
+            addGenerate(actions, compiled, "Generate toString()", SourceGeneration.GENERATE_TO_STRING,
+                    SourceGeneration.toStringMethod(classNode, compiled, encoding));
+        }
+        if (!hasAst(classNode, "EqualsAndHashCode") && !hasAst(classNode, CANONICAL)) {
+            addGenerate(actions, compiled, "Generate equals() and hashCode()", SourceGeneration.GENERATE_EQUALS,
+                    SourceGeneration.equalsAndHashCode(classNode, compiled, encoding));
+        }
+        if (!hasAst(classNode, "TupleConstructor") && !hasAst(classNode, CANONICAL)
+                && !hasAst(classNode, "MapConstructor")) {
+            addGenerate(actions, compiled, "Generate constructor", SourceGeneration.GENERATE_CONSTRUCTORS,
+                    SourceGeneration.constructor(classNode, compiled, encoding));
+        }
+        addGenerate(actions, compiled, "Add @Override annotations", CodeActionKind.Source,
+                SourceGeneration.overrideAnnotations(classNode, compiled, encoding));
+    }
+
+    private static boolean hasAst(final ClassNode classNode, final String simpleName) {
+        return classNode.getAnnotations().stream()
+                .anyMatch(a -> a.getClassNode() != null && simpleName.equals(a.getClassNode().getNameWithoutPackage()));
+    }
+
+    private static void addGenerate(final List<Either<Command, CodeAction>> actions, final CompiledDocument compiled,
+                                    final String title, final String kind, final List<TextEdit> edits) {
+        addGenerate(actions, compiled, title, kind, edits, null);
+    }
+
+    private static void addGenerate(final List<Either<Command, CodeAction>> actions, final CompiledDocument compiled,
+                                    final String title, final String kind, final List<TextEdit> edits,
+                                    final List<Diagnostic> diagnostics) {
+        if (edits == null || edits.isEmpty()) {
+            return;
+        }
+        CodeAction action = new CodeAction(title);
+        action.setKind(kind);
+        action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(), edits)));
+        if (diagnostics != null && !diagnostics.isEmpty()) {
+            action.setDiagnostics(diagnostics);
+        }
+        actions.add(Either.forRight(action));
+    }
+
+    private static void addRemoveUnused(final List<Either<Command, CodeAction>> actions, final CompiledDocument compiled,
+                                        final PositionEncoding encoding) {
+        List<TextEdit> edits = ImportSupport.removeUnused(compiled, encoding);
+        if (edits.isEmpty()) {
+            return;
+        }
+        addGenerate(actions, compiled, "Remove unused imports", CodeActionKind.QuickFix, edits);
+        addGenerate(actions, compiled, "Remove unused imports", CodeActionKind.SourceFixAll, edits);
+    }
+
+    private static void addPackage(final List<Either<Command, CodeAction>> actions, final CompiledDocument compiled,
+                                   final Collection<URI> folders, final List<String> sourcePaths,
+                                   final PositionEncoding encoding) {
+        String guessed = PackageGuess.fromUri(compiled.getUri(), folders, sourcePaths);
+        if (guessed.isEmpty() || guessed.equals(ImportSupport.packageName(compiled.getModule()))) {
+            return;
+        }
+        TextEdit edit;
+        if (compiled.getModule().getPackage() == null) {
+            edit = new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "package " + guessed + "\n\n");
+        } else {
+            Range range = Positions.toRange(compiled.getModule().getPackage(), compiled.getText(), encoding);
+            if (range == null) {
+                return;
+            }
+            edit = new TextEdit(range, "package " + guessed);
+        }
+        addGenerate(actions, compiled, "Add package " + guessed, CodeActionKind.Source, List.of(edit));
+    }
+
+    private static void addImplementAbstracts(final List<Either<Command, CodeAction>> actions,
+                                              final CompiledDocument compiled, final Range range,
+                                              final PositionEncoding encoding) {
+        for (ClassNode classNode : compiled.getModule().getClasses()) {
+            addImplementAbstractsFor(actions, compiled, classNode, range, encoding);
+        }
+    }
+
+    private static void addImplementAbstractsFor(final List<Either<Command, CodeAction>> actions,
+                                                 final CompiledDocument compiled, final ClassNode classNode,
+                                                 final Range range, final PositionEncoding encoding) {
+        if (classNode.isInterface() || classNode.isAbstract() || classNode.isEnum() || classNode.isScript()) {
+            return;
+        }
+        if (range != null && !overlapsName(classNode, compiled.getText(), encoding, range)) {
+            return;
+        }
+        List<MethodNode> missing = missingAbstracts(classNode);
+        if (missing.isEmpty()) {
+            return;
+        }
+        Position insert = SourceGeneration.beforeClose(classNode, compiled, encoding);
+        if (insert == null) {
+            return;
+        }
+        addGenerate(actions, compiled, "Implement abstract methods", CodeActionKind.QuickFix,
+                List.of(new TextEdit(new Range(insert, insert), stubs(missing))));
+    }
+
+    private static void addInferredType(final List<Either<Command, CodeAction>> actions,
+                                        final CompiledDocument compiled, final Range range,
+                                        final PositionEncoding encoding) {
+        for (ClassNode classNode : compiled.getModule().getClasses()) {
+            List<FieldNode> fields = new ArrayList<>(classNode.getFields());
+            for (PropertyNode property : classNode.getProperties()) {
+                if (property.getField() != null) {
+                    fields.add(property.getField());
+                }
+            }
+            for (FieldNode field : fields) {
+                addInferredTypeFor(actions, compiled, field, range, encoding);
+            }
+        }
+        AstQuery.walk(compiled.getModule(), (node, ctx) -> {
+            if (node instanceof DeclarationExpression declaration
+                    && !declaration.isMultipleAssignmentDeclaration()
+                    && declaration.getVariableExpression() != null) {
+                addInferredTypeFor(actions, compiled, declaration.getVariableExpression(),
+                        compiled.getModule(), range, encoding);
+            }
+        });
+    }
+
+    private static void addCreateClass(final List<Either<Command, CodeAction>> actions,
+                                       final CompiledDocument compiled,
+                                       final List<Diagnostic> diagnostics,
+                                       final PositionEncoding encoding) {
+        if (diagnostics == null) {
+            return;
+        }
+        for (Diagnostic diagnostic : diagnostics) {
+            addCreateClassFor(actions, compiled, diagnostic, encoding);
+        }
+    }
+
+    private static void addInferredTypeFor(final List<Either<Command, CodeAction>> actions,
+                                           final CompiledDocument compiled, final FieldNode field,
+                                           final Range range, final PositionEncoding encoding) {
+        ClassNode type = TypeInference.of(field);
+        if (field == null || field.getLineNumber() <= 0 || !SymbolIdentity.isResolvedType(type)) {
+            return;
+        }
+        Range name = Positions.toNameRange(field, compiled.getText(), encoding);
+        replaceDef(actions, compiled, field.getLineNumber(), type, name, range, encoding);
+    }
+
+    private static void addInferredTypeFor(final List<Either<Command, CodeAction>> actions,
+                                           final CompiledDocument compiled, final VariableExpression variable,
+                                           final ModuleNode module, final Range range,
+                                           final PositionEncoding encoding) {
+        if (variable == null || !variable.isDynamicTyped()) {
+            return;
+        }
+        ClassNode type = TypeInference.of(variable, module);
+        if (variable.getLineNumber() <= 0 || !SymbolIdentity.isResolvedType(type)) {
+            return;
+        }
+        Range name = Positions.toNameRange(variable, compiled.getText(), encoding);
+        replaceDef(actions, compiled, variable.getLineNumber(), type, name, range, encoding);
+    }
+
+    private static void replaceDef(final List<Either<Command, CodeAction>> actions,
+                                   final CompiledDocument compiled, final int groovyLine, final ClassNode type,
+                                   final Range name, final Range range, final PositionEncoding encoding) {
+        if (name == null || (range != null && !overlaps(name, range))) {
+            return;
+        }
+        String line = Positions.lineText(compiled.getText(), groovyLine);
+        int defAt = wordIndex(line, "def");
+        if (defAt < 0) {
+            return;
+        }
+        Position start = Positions.toLsp(groovyLine, defAt + 1, line, encoding);
+        Position end = Positions.toLsp(groovyLine, defAt + 4, line, encoding);
+        addGenerate(actions, compiled, "Insert inferred type " + type.getNameWithoutPackage(),
+                CodeActionKind.QuickFix, List.of(new TextEdit(new Range(start, end),
+                        type.getNameWithoutPackage())));
+    }
+
+    private static void addNamedArguments(final List<Either<Command, CodeAction>> actions,
+                                          final CompiledDocument compiled, final CompilationSnapshot snapshot,
+                                          final Range range, final PositionEncoding encoding) {
+        if (compiled.getModule() == null) {
+            return;
+        }
+        AstQuery.walk(compiled.getModule(), (node, ctx) -> {
+            if (node instanceof MethodCallExpression call) {
+                addNamedArgumentsFor(actions, compiled, call, call.getArguments(),
+                        NavigationService.resolveMethod(call, snapshot), range, encoding);
+            } else if (node instanceof StaticMethodCallExpression call) {
+                addNamedArgumentsFor(actions, compiled, call, call.getArguments(),
+                        NavigationService.resolveStatic(call, snapshot), range, encoding);
+            } else if (node instanceof ConstructorCallExpression ctor) {
+                addNamedArgumentsFor(actions, compiled, ctor, ctor.getArguments(),
+                        NavigationService.resolveConstructor(ctor), range, encoding);
+            }
+        });
+    }
+
+    private static void addNamedArgumentsFor(final List<Either<Command, CodeAction>> actions,
+                                             final CompiledDocument compiled, final ASTNode call,
+                                             final Expression arguments, final MethodNode target,
+                                             final Range range, final PositionEncoding encoding) {
+        if (target == null || arguments == null || hasNamedArgs(arguments)) {
+            return;
+        }
+        Parameter[] parameters = target.getParameters();
+        List<Expression> args = argumentList(arguments);
+        if (parameters == null || parameters.length == 0 || args.size() != parameters.length) {
+            return;
+        }
+        Range ident = Positions.toIdentifierRange(call, compiled.getText(), encoding);
+        if (range != null && ident != null && !overlaps(ident, range)) {
+            return;
+        }
+        Range first = Positions.toRange(args.get(0), compiled.getText(), encoding);
+        Range last = Positions.toRange(args.get(args.size() - 1), compiled.getText(), encoding);
+        String named = namedArgumentText(compiled.getText(), parameters, args, encoding);
+        if (first == null || last == null || named == null) {
+            return;
+        }
+        addGenerate(actions, compiled, "Convert to named arguments", CodeActionKind.RefactorRewrite,
+                List.of(new TextEdit(new Range(first.getStart(), last.getEnd()), named)));
+    }
+
+    private static String namedArgumentText(final String text, final Parameter[] parameters,
+                                            final List<Expression> args, final PositionEncoding encoding) {
+        StringBuilder named = new StringBuilder();
+        for (int i = 0; i < parameters.length; i++) {
+            if (parameters[i] == null || parameters[i].getName() == null
+                    || parameters[i].getName().startsWith("$")) {
+                return null;
+            }
+            Range argRange = Positions.toRange(args.get(i), text, encoding);
+            if (argRange == null) {
+                return null;
+            }
+            if (i > 0) {
+                named.append(", ");
+            }
+            named.append(parameters[i].getName()).append(": ").append(slice(text, argRange, encoding));
+        }
+        return named.toString();
+    }
+
+    private static boolean hasNamedArgs(final Expression arguments) {
+        if (arguments instanceof MapExpression) {
+            return true;
+        }
+        if (arguments instanceof TupleExpression tuple) {
+            for (Expression arg : tuple.getExpressions()) {
+                if (arg instanceof MapExpression) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static List<Expression> argumentList(final Expression arguments) {
+        if (arguments instanceof TupleExpression tuple) {
+            return tuple.getExpressions();
+        }
+        return arguments == null ? List.of() : List.of(arguments);
+    }
+
+    private static String slice(final String text, final Range range, final PositionEncoding encoding) {
+        if (text == null || range == null) {
+            return "";
+        }
+        int start = TextDocument.offsetOf(text, range.getStart(), encoding);
+        int end = TextDocument.offsetOf(text, range.getEnd(), encoding);
+        if (start < 0) {
+            start = 0;
+        }
+        if (end < start) {
+            end = start;
+        }
+        if (end > text.length()) {
+            end = text.length();
+        }
+        return text.substring(start, end);
+    }
+
+    private static void addCreateClassFor(final List<Either<Command, CodeAction>> actions,
+                                          final CompiledDocument compiled, final Diagnostic diagnostic,
+                                          final PositionEncoding encoding) {
+        String className = SupportServices.unresolvedClassName(SupportServices.diagnosticMessage(diagnostic));
+        if (className == null) {
+            return;
+        }
+        if (className.contains(".")) {
+            className = className.substring(className.lastIndexOf('.') + 1);
+        }
+        if (!Character.isUpperCase(className.codePointAt(0))) {
+            return;
+        }
+        Range end = compiled.getText().isEmpty() ? new Range(new Position(0, 0), new Position(0, 0))
+                : new Range(documentEnd(compiled, encoding), documentEnd(compiled, encoding));
+        addGenerate(actions, compiled, "Create class " + className, CodeActionKind.QuickFix,
+                List.of(new TextEdit(end, "\nclass " + className + " {\n}\n")), List.of(diagnostic));
+    }
+
+    static List<MethodNode> missingAbstracts(final ClassNode classNode) {
+        List<MethodNode> missing = new ArrayList<>();
+        for (MethodNode method : classNode.getAbstractMethods()) {
+            if (isUnimplementedAbstract(classNode, method)) {
+                missing.add(method);
+            }
+        }
+        return missing;
+    }
+
+    private static boolean isUnimplementedAbstract(final ClassNode classNode, final MethodNode method) {
+        if (method.isSynthetic()) {
+            return false;
+        }
+        ClassNode owner = method.getDeclaringClass();
+        if (owner != null && ("groovy.lang.GroovyObject".equals(owner.getName())
+                || "groovy.lang.GroovyObjectSupport".equals(owner.getName()))) {
+            return false;
+        }
+        MethodNode declared = classNode.getDeclaredMethod(method.getName(), method.getParameters());
+        if (declared != null && declared.getCode() != null && !declared.isAbstract()) {
+            return false;
+        }
+        return declared != method;
+    }
+
+    private static String stubs(final List<MethodNode> methods) {
+        StringBuilder builder = new StringBuilder();
+        for (MethodNode method : methods) {
+            builder.append("    ").append(overrideSnippet(method).replace("\n", "\n    ")).append('\n');
+        }
+        return builder.toString();
+    }
+
+    /**
+     * One {@code @Override} method stub for completions and generate.
+     *
+     * @param method abstract method
+     * @return snippet text
+     */
+    static String overrideSnippet(final MethodNode method) {
+        StringBuilder builder = new StringBuilder("@Override\n");
+        builder.append(returnName(method)).append(' ').append(method.getName()).append('(');
+        Parameter[] parameters = method.getParameters();
+        for (int i = 0; i < parameters.length; i++) {
+            if (i > 0) {
+                builder.append(", ");
+            }
+            builder.append(parameters[i].getType().getNameWithoutPackage()).append(' ')
+                    .append(parameters[i].getName());
+        }
+        builder.append(") {\n    throw new UnsupportedOperationException('TODO')\n}");
+        return builder.toString();
+    }
+
+    private static String returnName(final MethodNode method) {
+        ClassNode type = method.getReturnType();
+        if (type == null || ClassHelper.isObjectType(type) || ClassHelper.isDynamicTyped(type)) {
+            return "def";
+        }
+        if (ClassHelper.isPrimitiveVoid(type) || "void".equals(type.getName())) {
+            return "void";
+        }
+        return type.getNameWithoutPackage();
+    }
+
+    private static boolean overlapsName(final ClassNode classNode, final String text, final PositionEncoding encoding,
+                                        final Range range) {
+        Range name = Positions.toNameRange(classNode, text, encoding);
+        Range full = Positions.toRange(classNode, text, encoding);
+        return overlaps(name, range) || overlaps(full, range);
+    }
+
+    private static boolean overlaps(final Range left, final Range right) {
+        if (left == null || right == null) {
+            return true;
+        }
+        return left.getEnd().getLine() >= right.getStart().getLine()
+                && left.getStart().getLine() <= right.getEnd().getLine();
+    }
+
+    private static int wordIndex(final String line, final String word) {
+        int from = 0;
+        while (from < line.length()) {
+            int at = line.indexOf(word, from);
+            if (at < 0) {
+                return -1;
+            }
+            boolean startOk = at == 0 || !Character.isJavaIdentifierPart(line.codePointBefore(at));
+            int end = at + word.length();
+            boolean endOk = end >= line.length() || !Character.isJavaIdentifierPart(line.codePointAt(end));
+            if (startOk && endOk) {
+                return at;
+            }
+            from = end;
+        }
+        return -1;
+    }
+
+    private static Position documentEnd(final CompiledDocument compiled, final PositionEncoding encoding) {
+        String text = compiled.getText();
+        if (text.isEmpty()) {
+            return new Position(0, 0);
+        }
+        int line = 1;
+        for (int i = 0; i < text.length(); i++) {
+            char c = text.charAt(i);
+            if (c == '\n' || (c == '\r' && (i + 1 >= text.length() || text.charAt(i + 1) != '\n'))) {
+                line += 1;
+            }
+        }
+        if (text.charAt(text.length() - 1) == '\n' || text.charAt(text.length() - 1) == '\r') {
+            return new Position(line - 1, 0);
+        }
+        String last = Positions.lineText(text, line);
+        return Positions.toLsp(line, last.codePointCount(0, last.length()) + 1, last, encoding);
+    }
+}
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
new file mode 100644
index 0000000000..e0e3c560d4
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.java
@@ -0,0 +1,538 @@
+/*
+ *  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 groovy.lang.groovydoc.Groovydoc;
+import org.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.ImportSupport;
+import org.apache.groovy.lsp.internal.compile.TypeIndex;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.GroovyKeywords;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.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.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+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.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.runtime.DefaultGroovyMethods;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionList;
+import org.eclipse.lsp4j.InsertTextFormat;
+import org.eclipse.lsp4j.MarkupContent;
+import org.eclipse.lsp4j.MarkupKind;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Keyword, snippet, member, workspace-type and GDK completions.
+ * Unimported workspace types carry {@code additionalTextEdits}.
+ */
+public final class CompletionService {
+
+    static final int MAX_ITEMS = 80;
+    private static final List<String> GDK_NAMES = gdkMethodNames();
+
+    /**
+     * Completes at {@code position} in {@code document}.
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return a completion list (incomplete when truncated)
+     */
+    public CompletionList complete(final TextDocument document, final CompilationSnapshot snapshot,
+                                   final Position position, final PositionEncoding encoding) {
+        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 (!memberAccess) {
+            addMatching(items, GroovyKeywords.KEYWORDS, prefix, CompletionItemKind.Keyword);
+            addSnippets(items, prefix);
+        }
+
+        if (module != null) {
+            addFromAst(items, document, compiled, snapshot, position, encoding, module);
+        } else if (memberAccess && prefix.length() >= 2) {
+            addGdk(items, prefix);
+        }
+
+        boolean incomplete = items.size() > MAX_ITEMS;
+        if (incomplete) {
+            items = new ArrayList<>(items.subList(0, MAX_ITEMS));
+        }
+        return new CompletionList(incomplete, items);
+    }
+
+    /**
+     * Fills documentation from the compiled snapshot using {@code item.data}.
+     *
+     * @param item unresolved item
+     * @param snapshot latest compile
+     * @return the same item, possibly with documentation
+     */
+    public CompletionItem resolve(final CompletionItem item, final CompilationSnapshot snapshot) {
+        if (item == null || item.getData() == null || snapshot == null) {
+            return item;
+        }
+        Map<String, String> data = stringMap(item.getData());
+        if (data.isEmpty()) {
+            return item;
+        }
+        String kind = data.get("k");
+        String name = data.get("n");
+        if ("t".equals(kind) && name != null) {
+            TypeIndex.TypeHit hit = snapshot.types().byName(name);
+            if (hit != null && !hit.documentation().isEmpty()) {
+                item.setDocumentation(Either.forRight(new MarkupContent(MarkupKind.MARKDOWN, hit.documentation())));
+            }
+        }
+        if ("m".equals(kind) && name != null) {
+            String owner = data.get("o");
+            MethodNode method = NavigationService.findMethod(snapshot, owner, name, data.get("d"));
+            if (method != null) {
+                String docs = documentation(method);
+                if (!docs.isEmpty()) {
+                    item.setDocumentation(Either.forRight(new MarkupContent(MarkupKind.MARKDOWN, docs)));
+                }
+            }
+        }
+        return item;
+    }
+
+    static String prefixAt(final TextDocument document, final Position position,
+                           final PositionEncoding encoding) {
+        if (document == null || position == null) {
+            return "";
+        }
+        int[] groovy = document.toGroovy(position, encoding);
+        String line = Positions.lineText(document.getText(), groovy[0]);
+        int end = caretOffset(line, groovy[1]);
+        int start = skipIdentifierBack(line, end);
+        return line.substring(start, end);
+    }
+
+    static boolean isMemberAccess(final TextDocument document, final Position position,
+                                  final PositionEncoding encoding) {
+        if (document == null || position == null) {
+            return false;
+        }
+        int[] groovy = document.toGroovy(position, encoding);
+        String line = Positions.lineText(document.getText(), groovy[0]);
+        int idx = skipWhitespaceBack(line, caretOffset(line, groovy[1]));
+        idx = skipIdentifierBack(line, idx);
+        return idx > 0 && line.codePointBefore(idx) == '.';
+    }
+
+    private static int caretOffset(final String line, final int groovyColumn) {
+        int col = Math.max(groovyColumn - 1, 0);
+        int cpCount = line.codePointCount(0, line.length());
+        if (col > cpCount) {
+            col = cpCount;
+        }
+        return line.offsetByCodePoints(0, col);
+    }
+
+    private static int skipWhitespaceBack(final String line, int idx) {
+        while (idx > 0 && Character.isWhitespace(line.codePointBefore(idx))) {
+            idx -= Character.charCount(line.codePointBefore(idx));
+        }
+        return idx;
+    }
+
+    private static int skipIdentifierBack(final String line, int idx) {
+        while (idx > 0) {
+            int cp = line.codePointBefore(idx);
+            if (!Character.isJavaIdentifierPart(cp) && cp != '$') {
+                break;
+            }
+            idx -= Character.charCount(cp);
+        }
+        return idx;
+    }
+
+    private static void addOverrideCompletions(final List<CompletionItem> items, final ModuleNode module,
+                                               final TextDocument document, final Position position,
+                                               final PositionEncoding encoding, final String prefix) {
+        ClassNode enclosing = enclosingClass(module, document, position, encoding);
+        if (enclosing == null || enclosing.isInterface()) {
+            return;
+        }
+        for (MethodNode method : CodeActionService.missingAbstracts(enclosing)) {
+            if (!prefixMatches(method.getName(), prefix)) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(method.getName());
+            item.setKind(CompletionItemKind.Method);
+            item.setDetail("override " + signature(method));
+            item.setSortText("0" + method.getName());
+            item.setInsertText(CodeActionService.overrideSnippet(method));
+            item.setInsertTextFormat(InsertTextFormat.Snippet);
+            items.add(item);
+        }
+    }
+
+    private static ClassNode enclosingClass(final ModuleNode module, final TextDocument document,
+                                            final Position position, final PositionEncoding encoding) {
+        ClassNode enclosing = null;
+        for (ASTNode node : AstQuery.containing(module, document, position, encoding)) {
+            if (node instanceof ClassNode classNode) {
+                enclosing = classNode;
+            }
+        }
+        return enclosing;
+    }
+
+    private static void addNamedArgumentCompletions(final List<CompletionItem> items, final ModuleNode module,
+                                                    final int line, final int column,
+                                                    final CompilationSnapshot snapshot, final String prefix) {
+        ASTNode node = AstQuery.enclosingCall(module, line, column);
+        MethodNode target = null;
+        Expression arguments = null;
+        if (node instanceof MethodCallExpression call) {
+            target = NavigationService.resolveMethod(call, snapshot);
+            arguments = call.getArguments();
+        } else if (node instanceof StaticMethodCallExpression call) {
+            target = NavigationService.resolveStatic(call, snapshot);
+            arguments = call.getArguments();
+        } else if (node instanceof ConstructorCallExpression ctor) {
+            target = NavigationService.resolveConstructor(ctor);
+            arguments = ctor.getArguments();
+        }
+        if (target == null || target.getParameters() == null) {
+            return;
+        }
+        Set<String> used = usedNamedKeys(arguments);
+        for (Parameter parameter : target.getParameters()) {
+            if (parameter == null || parameter.getName() == null || parameter.getName().startsWith("$")
+                    || used.contains(parameter.getName()) || !prefixMatches(parameter.getName(), prefix)) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(parameter.getName());
+            item.setKind(CompletionItemKind.Field);
+            item.setDetail("named argument");
+            item.setSortText("0" + parameter.getName());
+            item.setInsertText(parameter.getName() + ": ");
+            items.add(item);
+        }
+    }
+
+    private static Set<String> usedNamedKeys(final Expression arguments) {
+        Set<String> used = new LinkedHashSet<>();
+        if (arguments instanceof MapExpression map) {
+            addMapKeys(used, map);
+        } else if (arguments instanceof TupleExpression tuple) {
+            for (Expression arg : tuple.getExpressions()) {
+                if (arg instanceof MapExpression map) {
+                    addMapKeys(used, map);
+                }
+            }
+        }
+        return used;
+    }
+
+    private static void addMapKeys(final Set<String> used, final MapExpression map) {
+        for (MapEntryExpression entry : map.getMapEntryExpressions()) {
+            if (entry.getKeyExpression() instanceof ConstantExpression constant
+                    && constant.getValue() != null) {
+                used.add(constant.getValue().toString());
+            }
+        }
+    }
+
+    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);
+        if (enclosing == null) {
+            return;
+        }
+        for (Parameter parameter : enclosing.getParameters()) {
+            if (!prefixMatches(parameter.getName(), prefix)) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(parameter.getName());
+            item.setKind(CompletionItemKind.Variable);
+            item.setDetail(parameter.getType().getNameWithoutPackage());
+            item.setSortText("0" + parameter.getName());
+            items.add(item);
+        }
+    }
+
+    private static void addWorkspaceTypes(final List<CompletionItem> items, final CompiledDocument compiled,
+                                          final CompilationSnapshot snapshot, final String prefix,
+                                          final PositionEncoding encoding) {
+        if (snapshot == null) {
+            return;
+        }
+        ModuleNode module = compiled.getModule();
+        for (TypeIndex.TypeHit hit : snapshot.types().matchingPrefix(prefix, MAX_ITEMS)) {
+            CompletionItem item = new CompletionItem(hit.simpleName());
+            item.setKind(typeKind(hit));
+            item.setDetail(hit.name());
+            item.setSortText("1" + hit.simpleName());
+            item.setData(Map.of("k", "t", "n", hit.name()));
+            if (ImportSupport.needsImport(module, hit.name())) {
+                item.setAdditionalTextEdits(ImportSupport.addImport(compiled, hit.name(), encoding));
+            }
+            items.add(item);
+        }
+    }
+
+    private static void addMembers(final List<CompletionItem> items, final ClassNode receiver, final String prefix) {
+        Set<String> seen = new LinkedHashSet<>();
+        for (MethodNode method : receiver.getMethods()) {
+            addMethod(items, seen, method, prefix);
+        }
+        for (FieldNode field : receiver.getFields()) {
+            if (field.isSynthetic() || !prefixMatches(field.getName(), prefix)) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(field.getName());
+            item.setKind(CompletionItemKind.Field);
+            item.setDetail(field.getType() == null ? null : field.getType().getNameWithoutPackage());
+            item.setSortText("2" + field.getName());
+            items.add(item);
+        }
+        for (PropertyNode property : receiver.getProperties()) {
+            if (property.isSynthetic() || !prefixMatches(property.getName(), prefix)) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(property.getName());
+            item.setKind(CompletionItemKind.Property);
+            item.setSortText("2" + property.getName());
+            items.add(item);
+        }
+    }
+
+    private static void addGdk(final List<CompletionItem> items, final String prefix) {
+        for (String name : GDK_NAMES) {
+            if (!prefixMatches(name, prefix)) {
+                continue;
+            }
+            CompletionItem item = new CompletionItem(name);
+            item.setKind(CompletionItemKind.Method);
+            item.setDetail("GDK");
+            item.setSortText("4" + name);
+            items.add(item);
+        }
+    }
+
+    private static List<String> gdkMethodNames() {
+        Set<String> names = new LinkedHashSet<>();
+        for (Method method : DefaultGroovyMethods.class.getMethods()) {
+            if (Modifier.isStatic(method.getModifiers()) && Modifier.isPublic(method.getModifiers())
+                    && method.getParameterCount() > 0) {
+                names.add(method.getName());
+            }
+        }
+        return List.copyOf(names);
+    }
+
+    private static void addSnippets(final List<CompletionItem> items, final String prefix) {
+        addSnippet(items, prefix, "class", "class ${1:Name} {\n    $0\n}\n", CompletionItemKind.Snippet);
+        addSnippet(items, prefix, "interface", "interface ${1:Name} {\n    $0\n}\n", CompletionItemKind.Snippet);
+        addSnippet(items, prefix, "trait", "trait ${1:Name} {\n    $0\n}\n", CompletionItemKind.Snippet);
+        addSnippet(items, prefix, "enum", "enum ${1:Name} {\n    $0\n}\n", CompletionItemKind.Snippet);
+        addSnippet(items, prefix, "for", "for (${1:item} in ${2:items}) {\n    $0\n}", CompletionItemKind.Snippet);
+        addSnippet(items, prefix, "if", "if (${1:condition}) {\n    $0\n}", CompletionItemKind.Snippet);
+        addSnippet(items, prefix, "try", "try {\n    $0\n} catch (${1:Exception} ${2:e}) {\n    \n}", CompletionItemKind.Snippet);
+    }
+
+    private static void addSnippet(final List<CompletionItem> items, final String prefix, final String label,
+                                   final String insert, final CompletionItemKind kind) {
+        if (!prefixMatches(label, prefix)) {
+            return;
+        }
+        CompletionItem item = new CompletionItem(label);
+        item.setKind(kind);
+        item.setInsertText(insert);
+        item.setInsertTextFormat(InsertTextFormat.Snippet);
+        item.setSortText("3" + label);
+        items.add(item);
+    }
+
+    private static void addMatching(final List<CompletionItem> items, final List<String> words, final String prefix,
+                                    final CompletionItemKind kind) {
+        for (String word : words) {
+            if (prefixMatches(word, prefix)) {
+                CompletionItem item = new CompletionItem(word);
+                item.setKind(kind);
+                item.setSortText("3" + word);
+                items.add(item);
+            }
+        }
+    }
+
+    static boolean prefixMatches(final String label, final String prefix) {
+        if (prefix == null || prefix.isEmpty()) {
+            return true;
+        }
+        return label.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT));
+    }
+
+    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('(');
+        Parameter[] parameters = method.getParameters();
+        for (int i = 0; i < parameters.length; i++) {
+            if (i > 0) {
+                builder.append(", ");
+            }
+            builder.append(parameters[i].getType().getNameWithoutPackage()).append(' ').append(parameters[i].getName());
+        }
+        builder.append(')');
+        return builder.toString();
+    }
+
+    private static String methodSnippet(final MethodNode method) {
+        Parameter[] parameters = method.getParameters();
+        if (parameters.length == 0) {
+            return method.getName() + "()";
+        }
+        StringBuilder builder = new StringBuilder(method.getName()).append('(');
+        for (int i = 0; i < parameters.length; i++) {
+            if (i > 0) {
+                builder.append(", ");
+            }
+            builder.append("${").append(i + 1).append(':').append(parameters[i].getName()).append('}');
+        }
+        return builder.append(')').toString();
+    }
+
+    private static void addFromAst(final List<CompletionItem> items, final TextDocument document,
+                                   final CompiledDocument compiled, final CompilationSnapshot snapshot,
+                                   final Position position, final PositionEncoding encoding,
+                                   final ModuleNode module) {
+        String prefix = prefixAt(document, position, encoding);
+        boolean memberAccess = isMemberAccess(document, position, encoding);
+        int[] groovy = document.toGroovy(position, encoding);
+        ASTNode node = AstQuery.nodeAt(module, document, position, encoding);
+        ClassNode receiver = receiverOf(node);
+        if (memberAccess) {
+            addMembers(items, receiver == null ? ClassHelper.OBJECT_TYPE : receiver, prefix);
+            if (prefix.length() >= 2) {
+                addGdk(items, prefix);
+            }
+            return;
+        }
+        addLocals(items, module, groovy[0], groovy[1], prefix);
+        addNamedArgumentCompletions(items, module, groovy[0], groovy[1], snapshot, prefix);
+        addOverrideCompletions(items, module, document, position, encoding, prefix);
+        for (ClassNode classNode : module.getClasses()) {
+            addMembers(items, classNode, prefix);
+        }
+        addWorkspaceTypes(items, compiled, snapshot, prefix, encoding);
+    }
+
+    private static CompletionItemKind typeKind(final TypeIndex.TypeHit hit) {
+        if (hit.iface()) {
+            return CompletionItemKind.Interface;
+        }
+        if (hit.enumeration()) {
+            return CompletionItemKind.Enum;
+        }
+        return CompletionItemKind.Class;
+    }
+
+    private static void addMethod(final List<CompletionItem> items, final Set<String> seen, final MethodNode method,
+                                  final String prefix) {
+        if (method.isSynthetic() || !seen.add(method.getName() + "/" + method.getParameters().length)
+                || !prefixMatches(method.getName(), prefix)) {
+            return;
+        }
+        CompletionItem item = new CompletionItem(method.getName());
+        item.setKind(method.isStatic() ? CompletionItemKind.Function : CompletionItemKind.Method);
+        item.setDetail(signature(method));
+        item.setSortText("2" + method.getName());
+        item.setInsertText(methodSnippet(method));
+        item.setInsertTextFormat(InsertTextFormat.Snippet);
+        String owner = method.getDeclaringClass() == null ? "" : method.getDeclaringClass().getName();
+        String descriptor = method.getTypeDescriptor() == null ? "" : method.getTypeDescriptor();
+        item.setData(Map.of("k", "m", "n", method.getName(), "o", owner, "d", descriptor));
+        items.add(item);
+    }
+
+    private static String documentation(final AnnotatedNode node) {
+        Groovydoc groovydoc = node.getGroovydoc();
+        if (groovydoc == null || !groovydoc.isPresent()) {
+            return "";
+        }
+        String content = groovydoc.getContent();
+        return content == null ? "" : content;
+    }
+
+    static Map<String, String> stringMap(final Object data) {
+        if (!(data instanceof Map<?, ?> map)) {
+            return Map.of();
+        }
+        Map<String, String> result = new LinkedHashMap<>();
+        for (Map.Entry<?, ?> entry : map.entrySet()) {
+            if (entry.getKey() != null && entry.getValue() != null) {
+                result.put(entry.getKey().toString(), entry.getValue().toString());
+            }
+        }
+        return result;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HierarchyService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HierarchyService.java
new file mode 100644
index 0000000000..a7ef4d3ff5
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HierarchyService.java
@@ -0,0 +1,365 @@
+/*
+ *  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.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.query.AstContext;
+import org.eclipse.lsp4j.CallHierarchyIncomingCall;
+import org.eclipse.lsp4j.CallHierarchyItem;
+import org.eclipse.lsp4j.CallHierarchyOutgoingCall;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SymbolKind;
+import org.eclipse.lsp4j.TypeHierarchyItem;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Call hierarchy and type hierarchy over the compiled snapshot.
+ */
+public final class HierarchyService {
+
+    public List<CallHierarchyItem> prepareCallHierarchy(final TextDocument document,
+                                                        final CompilationSnapshot snapshot,
+                                                        final Position position,
+                                                        final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return List.of();
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        MethodNode method = methodOf(node, snapshot);
+        if (method == null) {
+            return List.of();
+        }
+        CallHierarchyItem item = callItem(method, document.getUri(), compiled.getText(), encoding);
+        return item == null ? List.of() : List.of(item);
+    }
+
+    public List<CallHierarchyIncomingCall> incomingCalls(final CallHierarchyItem item,
+                                                         final CompilationSnapshot snapshot,
+                                                         final PositionEncoding encoding) {
+        MethodNode method = methodFromItem(item, snapshot);
+        if (method == null) {
+            return List.of();
+        }
+        Map<String, Incoming> grouped = new LinkedHashMap<>();
+        for (CompiledDocument document : snapshot.documents()) {
+            collectIncomingCalls(grouped, document, method, snapshot, encoding);
+        }
+        List<CallHierarchyIncomingCall> result = new ArrayList<>();
+        for (Incoming incoming : grouped.values()) {
+            if (incoming.item != null) {
+                result.add(new CallHierarchyIncomingCall(incoming.item, incoming.ranges));
+            }
+        }
+        return result;
+    }
+
+    public List<CallHierarchyOutgoingCall> outgoingCalls(final CallHierarchyItem item,
+                                                         final CompilationSnapshot snapshot,
+                                                         final PositionEncoding encoding) {
+        MethodNode method = methodFromItem(item, snapshot);
+        if (method == null || method.getCode() == null) {
+            return List.of();
+        }
+        Map<String, Outgoing> grouped = new LinkedHashMap<>();
+        String bodyText = textOf(method, snapshot);
+        AstQuery.walk(method.getCode(), (node, ctx) -> {
+                    MethodNode target = targetOf(node, snapshot);
+                    if (target == null || target.getLineNumber() <= 0) {
+                        return;
+                    }
+                    Range range = Positions.toIdentifierRange(node, bodyText, encoding);
+                    if (range == null) {
+                        return;
+                    }
+                    URI uri = NavigationService.uriOf(target, uriOf(item), snapshot, target.getDeclaringClass() == null
+                            ? null : target.getDeclaringClass().getModule());
+                    String key = SymbolIdentity.keyOf(target);
+                    Outgoing outgoing = grouped.computeIfAbsent(key, ignored -> {
+                        Outgoing created = new Outgoing();
+                        created.item = callItem(target, uri, textOf(target, snapshot), encoding);
+                        created.ranges = new ArrayList<>();
+                        return created;
+                    });
+                    outgoing.ranges.add(range);
+                });
+        List<CallHierarchyOutgoingCall> result = new ArrayList<>();
+        for (Outgoing outgoing : grouped.values()) {
+            if (outgoing.item != null) {
+                result.add(new CallHierarchyOutgoingCall(outgoing.item, outgoing.ranges));
+            }
+        }
+        return result;
+    }
+
+    public List<TypeHierarchyItem> prepareTypeHierarchy(final TextDocument document,
+                                                        final CompilationSnapshot snapshot,
+                                                        final Position position,
+                                                        final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return List.of();
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        ClassNode type = typeOf(node);
+        if (type == null || type.getLineNumber() <= 0) {
+            return List.of();
+        }
+        TypeHierarchyItem item = typeItem(type, document.getUri(), compiled.getText(), encoding);
+        return item == null ? List.of() : List.of(item);
+    }
+
+    public List<TypeHierarchyItem> supertypes(final TypeHierarchyItem item, final CompilationSnapshot snapshot,
+                                              final PositionEncoding encoding) {
+        ClassNode type = typeFromItem(item, snapshot);
+        if (type == null) {
+            return List.of();
+        }
+        List<TypeHierarchyItem> result = new ArrayList<>();
+        ClassNode superClass = type.getSuperClass();
+        if (superClass != null && !ClassHelper.isObjectType(superClass)) {
+            addType(result, superClass, snapshot, encoding);
+        }
+        if (type.getInterfaces() != null) {
+            for (ClassNode iface : type.getInterfaces()) {
+                addType(result, iface, snapshot, encoding);
+            }
+        }
+        return result;
+    }
+
+    public List<TypeHierarchyItem> subtypes(final TypeHierarchyItem item, final CompilationSnapshot snapshot,
+                                            final PositionEncoding encoding) {
+        ClassNode type = typeFromItem(item, snapshot);
+        if (type == null) {
+            return List.of();
+        }
+        List<TypeHierarchyItem> result = new ArrayList<>();
+        for (ClassNode impl : NavigationService.implementations(type, snapshot)) {
+            addType(result, impl, snapshot, encoding);
+        }
+        return result;
+    }
+
+    private static void addType(final List<TypeHierarchyItem> result, final ClassNode type,
+                                final CompilationSnapshot snapshot, final PositionEncoding encoding) {
+        if (type == null || type.getLineNumber() <= 0) {
+            return;
+        }
+        URI uri = NavigationService.uriOf(type, null, snapshot, type.getModule());
+        if (uri == null) {
+            return;
+        }
+        String text = textOf(type, snapshot);
+        TypeHierarchyItem item = typeItem(type, uri, text, encoding);
+        if (item != null) {
+            result.add(item);
+        }
+    }
+
+    private static void collectIncomingCalls(final Map<String, Incoming> grouped, final CompiledDocument document,
+                                             final MethodNode method, final CompilationSnapshot snapshot,
+                                             final PositionEncoding encoding) {
+        if (document.getModule() == null) {
+            return;
+        }
+        AstQuery.walk(document.getModule(), (node, ctx) ->
+                recordIncoming(grouped, document, method, encoding, node, ctx));
+    }
+
+    private static void recordIncoming(final Map<String, Incoming> grouped, final CompiledDocument document,
+                                       final MethodNode method, final PositionEncoding encoding, final ASTNode node,
+                                       final AstContext ctx) {
+        if (!isCallTo(node, method, ctx)) {
+            return;
+        }
+        MethodNode enclosing = ctx.enclosingMethod();
+        if (enclosing == null || enclosing.getLineNumber() <= 0) {
+            return;
+        }
+        Range range = Positions.toIdentifierRange(node, document.getText(), encoding);
+        if (range == null) {
+            return;
+        }
+        Incoming incoming = grouped.computeIfAbsent(SymbolIdentity.keyOf(enclosing), ignored -> {
+            Incoming created = new Incoming();
+            created.item = callItem(enclosing, document.getUri(), document.getText(), encoding);
+            created.ranges = new ArrayList<>();
+            return created;
+        });
+        incoming.ranges.add(range);
+    }
+
+    private static MethodNode methodOf(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof MethodNode method) {
+            return method;
+        }
+        return resolvedCall(node, snapshot);
+    }
+
+    private static ClassNode typeOf(final ASTNode node) {
+        if (node instanceof ClassNode classNode) {
+            return classNode;
+        }
+        return null;
+    }
+
+    private static MethodNode targetOf(final ASTNode node, final CompilationSnapshot snapshot) {
+        MethodNode resolved = resolvedCall(node, snapshot);
+        if (resolved != null) {
+            return resolved;
+        }
+        if (node instanceof ConstructorCallExpression ctor && ctor.getType() != null) {
+            List<MethodNode> ctors = List.copyOf(ctor.getType().getDeclaredConstructors());
+            return ctors.isEmpty() ? null : ctors.get(0);
+        }
+        return null;
+    }
+
+    private static MethodNode resolvedCall(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof MethodCallExpression call) {
+            return NavigationService.resolveMethod(call, snapshot);
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return NavigationService.resolveStatic(call, snapshot);
+        }
+        return null;
+    }
+
+    private static boolean isCallTo(final ASTNode node, final MethodNode method, final AstContext ctx) {
+        if (node instanceof MethodCallExpression call) {
+            return NavigationService.callMatches(call, method, ctx);
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return NavigationService.staticCallMatches(call, method);
+        }
+        return false;
+    }
+
+    private static CallHierarchyItem callItem(final MethodNode method, final URI uri, final String text,
+                                              final PositionEncoding encoding) {
+        if (method == null || uri == null) {
+            return null;
+        }
+        Range range = Positions.toRange(method, text, encoding);
+        Range selection = Positions.toIdentifierRange(method, text, encoding);
+        if (range == null || selection == null) {
+            return null;
+        }
+        CallHierarchyItem item = new CallHierarchyItem(method.getName(), SymbolService.kindOf(method),
+                uri.toString(), range, selection);
+        item.setData(payload(method));
+        return item;
+    }
+
+    private static TypeHierarchyItem typeItem(final ClassNode type, final URI uri, final String text,
+                                              final PositionEncoding encoding) {
+        Range range = Positions.toRange(type, text, encoding);
+        Range selection = Positions.toIdentifierRange(type, text, encoding);
+        if (range == null || selection == null) {
+            return null;
+        }
+        SymbolKind kind = SymbolService.kindOf(type);
+        TypeHierarchyItem item = new TypeHierarchyItem(type.getNameWithoutPackage(), kind, uri.toString(), range, selection);
+        item.setData(Map.of("kind", "type", "name", type.getName()));
+        return item;
+    }
+
+    private static Map<String, String> payload(final MethodNode method) {
+        String owner = method.getDeclaringClass() == null ? "" : method.getDeclaringClass().getName();
+        String descriptor = method.getTypeDescriptor() == null ? "" : method.getTypeDescriptor();
+        return Map.of("kind", "method", "name", method.getName(), "owner", owner, "desc", descriptor);
+    }
+
+    private static MethodNode methodFromItem(final CallHierarchyItem item, final CompilationSnapshot snapshot) {
+        if (item == null || snapshot == null) {
+            return null;
+        }
+        Map<String, String> data = CompletionService.stringMap(item.getData());
+        if (data.isEmpty()) {
+            return NavigationService.findMethod(snapshot, null, item.getName(), null);
+        }
+        return NavigationService.findMethod(snapshot, data.get("owner"), data.get("name"), data.get("desc"));
+    }
+
+    private static ClassNode typeFromItem(final TypeHierarchyItem item, final CompilationSnapshot snapshot) {
+        if (item == null || snapshot == null) {
+            return null;
+        }
+        Map<String, String> data = CompletionService.stringMap(item.getData());
+        String name = data.getOrDefault("name", item.getName());
+        for (CompiledDocument document : snapshot.documents()) {
+            if (document.getModule() == null) {
+                continue;
+            }
+            for (ClassNode classNode : document.getModule().getClasses()) {
+                if (name.equals(classNode.getName()) || name.equals(classNode.getNameWithoutPackage())) {
+                    return classNode;
+                }
+            }
+        }
+        return null;
+    }
+
+    private static String textOf(final ASTNode node, final CompilationSnapshot snapshot) {
+        URI uri = NavigationService.uriOf(node, null, snapshot, NavigationService.moduleOf(node));
+        if (uri == null) {
+            return "";
+        }
+        CompiledDocument document = snapshot.get(uri);
+        return document == null ? "" : document.getText();
+    }
+
+    private static URI uriOf(final CallHierarchyItem item) {
+        try {
+            return item.getUri() == null ? null : Uris.parse(item.getUri());
+        } catch (IllegalArgumentException ex) {
+            return null;
+        }
+    }
+
+    private static final class Incoming {
+        private CallHierarchyItem item;
+        private List<Range> ranges;
+    }
+
+    private static final class Outgoing {
+        private CallHierarchyItem item;
+        private List<Range> ranges;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HoverService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HoverService.java
new file mode 100644
index 0000000000..5b475d2726
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HoverService.java
@@ -0,0 +1,223 @@
+/*
+ *  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 groovy.lang.groovydoc.Groovydoc;
+import org.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.TypeIndex;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+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.ClassExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.eclipse.lsp4j.Hover;
+import org.eclipse.lsp4j.MarkupContent;
+import org.eclipse.lsp4j.MarkupKind;
+import org.eclipse.lsp4j.Position;
+
+/**
+ * Markdown hover for types, methods, fields and documentation comments.
+ */
+public final class HoverService {
+
+    /**
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return hover, or {@code null}
+     */
+    public Hover hover(final TextDocument document, final CompilationSnapshot snapshot,
+                       final Position position, final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        ModuleNode module = compiled == null ? null : compiled.getModule();
+        ASTNode node = module == null ? null : AstQuery.nodeAt(module, document, position, encoding);
+        ASTNode resolved = node == null ? null : resolveForHover(node, snapshot);
+        String markdown = resolved == null ? null : render(resolved, module);
+        if (markdown != null && (inferredCall(node, resolved)
+                || (resolved instanceof VariableExpression variable
+                && TypeInference.usedInitializer(variable, module)))) {
+            markdown += "\n\n_inferred_";
+        }
+        if (markdown == null || markdown.isBlank()) {
+            markdown = identifierHover(document, snapshot, position, encoding);
+        }
+        if (markdown == null || markdown.isBlank()) {
+            return null;
+        }
+        Hover hover = new Hover(new MarkupContent(MarkupKind.MARKDOWN, markdown));
+        if (node != null) {
+            hover.setRange(Positions.toIdentifierRange(node, document.getText(), encoding));
+        }
+        return hover;
+    }
+
+    private static ASTNode resolveForHover(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof MethodCallExpression call) {
+            MethodNode target = NavigationService.resolveMethod(call, snapshot);
+            if (target != null) {
+                return target;
+            }
+        }
+        return node;
+    }
+
+    private static String identifierHover(final TextDocument document, final CompilationSnapshot snapshot,
+                                          final Position position, final PositionEncoding encoding) {
+        String prefix = CompletionService.prefixAt(document, position, encoding);
+        if (prefix == null || prefix.isEmpty()) {
+            return null;
+        }
+        if (snapshot != null) {
+            TypeIndex.TypeHit hit = snapshot.types().uniqueBySimpleName(prefix);
+            if (hit != null) {
+                return codeFence(hit.name());
+            }
+        }
+        return codeFence(prefix);
+    }
+
+    static String render(final ASTNode node) {
+        return render(node, null);
+    }
+
+    static String render(final ASTNode node, final ModuleNode module) {
+        if (node instanceof MethodNode method) {
+            return codeFence(signature(method)) + docs(method);
+        }
+        if (node instanceof ClassNode classNode) {
+            return codeFence(kind(classNode) + " " + classNode.getName()) + docs(classNode);
+        }
+        if (node instanceof FieldNode field) {
+            return renderField(field);
+        }
+        if (node instanceof PropertyNode property) {
+            return codeFence(property.getType().getNameWithoutPackage() + " " + property.getName()) + docs(property);
+        }
+        if (node instanceof Parameter parameter) {
+            return codeFence(parameter.getType().getNameWithoutPackage() + " " + parameter.getName());
+        }
+        if (node instanceof VariableExpression variable) {
+            return renderVariable(variable, module);
+        }
+        if (node instanceof ClassExpression classExpression) {
+            return codeFence(classExpression.getType().getName());
+        }
+        if (node instanceof MethodCallExpression call) {
+            return renderCall(call);
+        }
+        if (node instanceof PropertyExpression property) {
+            String name = property.getPropertyAsString();
+            return codeFence(property.getType().getNameWithoutPackage() + " " + (name == null ? "?" : name));
+        }
+        if (node instanceof ImportNode imp) {
+            return codeFence("import " + imp.getText());
+        }
+        if (node instanceof ConstantExpression constant) {
+            return codeFence(String.valueOf(constant.getValue()));
+        }
+        return null;
+    }
+
+    private static String renderField(final FieldNode field) {
+        return typedFence(TypeInference.of(field), field.getType(), field.getName()) + docs(field);
+    }
+
+    private static String renderVariable(final VariableExpression variable, final ModuleNode module) {
+        return typedFence(TypeInference.of(variable, module), variable.getType(), variable.getName());
+    }
+
+    private static String typedFence(final ClassNode inferred, final ClassNode declared, final String name) {
+        ClassNode type = inferred == null ? declared : inferred;
+        String typeName = type == null ? "?" : type.getNameWithoutPackage();
+        return codeFence(typeName + " " + name);
+    }
+
+    private static String renderCall(final MethodCallExpression call) {
+        MethodNode target = call.getMethodTarget();
+        if (target != null) {
+            return codeFence(signature(target)) + docs(target);
+        }
+        return codeFence(call.getMethodAsString() + "(...)");
+    }
+
+    private static boolean inferredCall(final ASTNode node, final ASTNode resolved) {
+        return node instanceof MethodCallExpression call && call.getMethodTarget() == null
+                && resolved instanceof MethodNode;
+    }
+
+    private static String kind(final ClassNode classNode) {
+        if (classNode.isInterface()) {
+            return "interface";
+        }
+        if (classNode.isEnum()) {
+            return "enum";
+        }
+        if (classNode.isRecord()) {
+            return "record";
+        }
+        return "class";
+    }
+
+    private static String signature(final MethodNode method) {
+        StringBuilder builder = new StringBuilder();
+        builder.append(method.getReturnType().getNameWithoutPackage()).append(' ')
+                .append(method.getDeclaringClass() == null ? "" : method.getDeclaringClass().getNameWithoutPackage() + ".")
+                .append(method.getName()).append('(');
+        Parameter[] parameters = method.getParameters();
+        for (int i = 0; i < parameters.length; i++) {
+            if (i > 0) {
+                builder.append(", ");
+            }
+            builder.append(parameters[i].getType().getNameWithoutPackage()).append(' ').append(parameters[i].getName());
+        }
+        builder.append(')');
+        return builder.toString();
+    }
+
+    private static String docs(final AnnotatedNode node) {
+        Groovydoc groovydoc = node.getGroovydoc();
+        if (groovydoc == null || !groovydoc.isPresent()) {
+            return "";
+        }
+        String content = groovydoc.getContent();
+        if (content == null || content.isBlank()) {
+            return "";
+        }
+        return "\n\n" + content.replace("/**", "").replace("*/", "").strip();
+    }
+
+    private static String codeFence(final String groovy) {
+        return "```groovy\n" + groovy + "\n```";
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/NavigationService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/NavigationService.java
new file mode 100644
index 0000000000..6bb1f14d6a
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/NavigationService.java
@@ -0,0 +1,1005 @@
+/*
+ *  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.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.Identifiers;
+import org.apache.groovy.lsp.internal.compile.JavaSymbolIndex;
+import org.apache.groovy.lsp.internal.compile.TypeIndex;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.ConstructorNode;
+import org.codehaus.groovy.ast.FieldNode;
+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.Variable;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+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.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.query.AstContext;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.LocationLink;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Go-to definition, type definition, implementation, declaration and references.
+ */
+public final class NavigationService {
+
+    /**
+     * Resolves the definition of the symbol at {@code position}.
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return location links
+     */
+    public List<LocationLink> definition(final TextDocument document, final CompilationSnapshot snapshot,
+                                         final Position position, final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled != null && compiled.getModule() != null) {
+            ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+            if (node instanceof MethodCallExpression || node instanceof StaticMethodCallExpression) {
+                List<MethodNode> methods = new ArrayList<>(resolveMethodCandidates(node, snapshot));
+                List<LocationLink> links = linksFor(methods, node, document, snapshot, compiled, encoding);
+                if (!links.isEmpty()) {
+                    return links;
+                }
+            }
+        }
+        List<LocationLink> groovy = locate(document, snapshot, position, encoding, Target.DEFINITION);
+        if (!groovy.isEmpty()) {
+            return groovy;
+        }
+        return javaIdentifierLinks(document, snapshot, position, encoding);
+    }
+
+    /**
+     * Resolves the type definition of the symbol at {@code position}.
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return location links
+     */
+    public List<LocationLink> typeDefinition(final TextDocument document, final CompilationSnapshot snapshot,
+                                             final Position position, final PositionEncoding encoding) {
+        return locate(document, snapshot, position, encoding, Target.TYPE);
+    }
+
+    /**
+     * Resolves implementations of the type or method at {@code position}.
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return location links
+     */
+    public List<LocationLink> implementation(final TextDocument document, final CompilationSnapshot snapshot,
+                                             final Position position, final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return List.of();
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        ASTNode dest = resolve(node, Target.IMPLEMENTATION, snapshot);
+        if (dest instanceof ClassNode classNode) {
+            return linksFor(implementations(classNode, snapshot), node, document, snapshot, compiled, encoding);
+        }
+        if (dest instanceof MethodNode method) {
+            return linksFor(methodImplementations(method, snapshot), node, document, snapshot, compiled, encoding);
+        }
+        return locate(document, snapshot, position, encoding, Target.IMPLEMENTATION);
+    }
+
+    /**
+     * Nearest overridden method at {@code position}. Skips
+     * {@code Object} / {@code GroovyObject}. Empty when the caret is not
+     * on an override or the super has no source.
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return locations, never {@code null}
+     */
+    public List<Location> superMethod(final TextDocument document, final CompilationSnapshot snapshot,
+                                      final Position position, final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return List.of();
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        MethodNode method = methodAt(node, snapshot);
+        MethodNode parent = superOf(method);
+        if (parent == null) {
+            return List.of();
+        }
+        LocationLink link = toLink(node, parent, document, snapshot, compiled, encoding);
+        if (link == null) {
+            return List.of();
+        }
+        return List.of(new Location(link.getTargetUri(), link.getTargetSelectionRange()));
+    }
+
+    /**
+     * Resolves the declaration (same as definition for Groovy).
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return location links
+     */
+    public List<LocationLink> declaration(final TextDocument document, final CompilationSnapshot snapshot,
+                                          final Position position, final PositionEncoding encoding) {
+        return locate(document, snapshot, position, encoding, Target.DEFINITION);
+    }
+
+    /**
+     * Finds references to the symbol at {@code position}.
+     *
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @param includeDeclaration whether to include the declaration
+     * @return locations
+     */
+    public List<Location> references(final TextDocument document, final CompilationSnapshot snapshot,
+                                     final Position position, final PositionEncoding encoding,
+                                     final boolean includeDeclaration) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return List.of();
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        SymbolIdentity identity = SymbolIdentity.of(node, snapshot);
+        if (identity == null || identity.name() == null) {
+            return List.of();
+        }
+        List<Location> locations = new ArrayList<>();
+        Set<String> seen = new LinkedHashSet<>();
+        if (includeDeclaration) {
+            addLocation(locations, seen, document.getUri(), identity.declaration(), compiled.getText(), encoding);
+        }
+        ReferenceWalk walk = new ReferenceWalk(locations, seen, identity, snapshot, encoding, includeDeclaration);
+        for (CompiledDocument other : snapshot.documents()) {
+            addReferencesFrom(walk, other);
+        }
+        return locations;
+    }
+
+    private static void addReferencesFrom(final ReferenceWalk walk, final CompiledDocument other) {
+        if (other.getModule() == null || !Identifiers.containsWord(other.getText(), walk.identity().name())) {
+            return;
+        }
+        AstQuery.walk(other.getModule(), (hit, ctx) -> {
+            if (!walk.includeDeclaration() && walk.identity().isDeclaration(hit)) {
+                return;
+            }
+            if (walk.identity().refersTo(hit, ctx)) {
+                addLocation(walk.locations(), walk.seen(), other.getUri(), hit, other.getText(), walk.encoding());
+            }
+        });
+    }
+
+    private List<LocationLink> locate(final TextDocument document, final CompilationSnapshot snapshot,
+                                      final Position position, final PositionEncoding encoding, final Target target) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return List.of();
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        ASTNode dest = resolve(node, target, snapshot);
+        if (dest == null) {
+            dest = node;
+        }
+        LocationLink link = toLink(node, dest, document, snapshot, compiled, encoding);
+        return link == null ? List.of() : List.of(link);
+    }
+
+    private static ASTNode resolve(final ASTNode node, final Target target, final CompilationSnapshot snapshot) {
+        if (node instanceof VariableExpression variable) {
+            return resolveVariable(variable, target);
+        }
+        if (node instanceof ClassExpression classExpression) {
+            return classExpression.getType();
+        }
+        if (node instanceof MethodCallExpression call) {
+            return resolveCall(call, target, snapshot);
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return resolveStatic(call, snapshot);
+        }
+        if (node instanceof ConstructorCallExpression ctor) {
+            return ctor.getType();
+        }
+        if (node instanceof PropertyExpression property) {
+            return resolvePropertyTarget(property, target);
+        }
+        if (node instanceof DeclarationExpression declaration && declaration.isMultipleAssignmentDeclaration()) {
+            return declaration;
+        }
+        if (node instanceof ClassNode classNode && target == Target.IMPLEMENTATION) {
+            return classNode;
+        }
+        if (node instanceof MethodNode method && target == Target.IMPLEMENTATION) {
+            return method;
+        }
+        return resolveDeclaration(node, target);
+    }
+
+    private static ASTNode resolveVariable(final VariableExpression variable, final Target target) {
+        if (target == Target.TYPE) {
+            return variable.getType();
+        }
+        Variable accessed = variable.getAccessedVariable();
+        return accessed instanceof ASTNode ast ? ast : null;
+    }
+
+    private static ASTNode resolveCall(final MethodCallExpression call, final Target target,
+                                       final CompilationSnapshot snapshot) {
+        if (target == Target.TYPE) {
+            return call.getType();
+        }
+        return resolveMethod(call, snapshot);
+    }
+
+    private static ASTNode resolvePropertyTarget(final PropertyExpression property, final Target target) {
+        if (target == Target.TYPE) {
+            return property.getType();
+        }
+        return resolveProperty(property);
+    }
+
+    private static ASTNode resolveDeclaration(final ASTNode node, final Target target) {
+        if (!(node instanceof MethodNode || node instanceof FieldNode || node instanceof PropertyNode
+                || node instanceof ClassNode || node instanceof Parameter)) {
+            return null;
+        }
+        if (target == Target.TYPE && node instanceof FieldNode field) {
+            return field.getType();
+        }
+        if (target == Target.TYPE && node instanceof Parameter parameter) {
+            return parameter.getType();
+        }
+        return node;
+    }
+
+    /**
+     * Best-effort single target for hover and similar read-only features.
+     * Prefers {@link MethodCallExpression#getMethodTarget()}; otherwise the
+     * unique method on the receiver (or enclosing class for implicit
+     * {@code this}) whose arity matches. Returns {@code null} when several
+     * methods match — callers must not treat that as a rename target.
+     *
+     * @param call a method call
+     * @param snapshot latest compile
+     * @return one method, or {@code null}
+     */
+    static MethodNode resolveMethod(final MethodCallExpression call, final CompilationSnapshot snapshot) {
+        List<MethodNode> methods = resolveMethodCandidates(call, snapshot);
+        return methods.size() == 1 ? methods.get(0) : null;
+    }
+
+    static MethodNode resolveStatic(final StaticMethodCallExpression call, final CompilationSnapshot snapshot) {
+        List<MethodNode> methods = resolveMethodCandidates(call, snapshot);
+        return methods.size() == 1 ? methods.get(0) : null;
+    }
+
+    /**
+     * All arity-matching methods for definition. When {@code methodTarget}
+     * is set, that is the only candidate.
+     *
+     * @param node a method or static call
+     * @param snapshot latest compile
+     * @return candidates, never {@code null}
+     */
+    static List<MethodNode> resolveMethodCandidates(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof MethodCallExpression call) {
+            if (call.getMethodTarget() != null) {
+                return List.of(call.getMethodTarget());
+            }
+            String name = call.getMethodAsString();
+            int argc = argumentCount(call.getArguments());
+            List<MethodNode> found = findMethods(typeOf(call.getObjectExpression()), name, argc);
+            if (found.isEmpty() && call.isImplicitThis()) {
+                found = findMethods(enclosingClass(call, snapshot), name, argc);
+            }
+            return found;
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return findMethods(call.getOwnerType(), call.getMethodAsString(), argumentCount(call.getArguments()));
+        }
+        return List.of();
+    }
+
+    /**
+     * Unique-arity constructors for {@code new Type(...)}. Empty when
+     * anonymous or the type is unresolved. Exact arity only.
+     *
+     * @param ctor a constructor call
+     * @return candidates, never {@code null}
+     */
+    static List<ConstructorNode> resolveConstructorCandidates(final ConstructorCallExpression ctor) {
+        if (ctor == null || ctor.isUsingAnonymousInnerClass()) {
+            return List.of();
+        }
+        ClassNode type = ctor.getType();
+        if (!SymbolIdentity.isResolvedType(type)) {
+            return List.of();
+        }
+        return matchingArity(type.getDeclaredConstructors(), argumentCount(ctor.getArguments()));
+    }
+
+    static ConstructorNode resolveConstructor(final ConstructorCallExpression ctor) {
+        List<ConstructorNode> found = resolveConstructorCandidates(ctor);
+        return found.size() == 1 ? found.get(0) : null;
+    }
+
+    /**
+     * Whether rename/prepareRename may edit this symbol. Declarations are
+     * safe. Calls and properties are safe only when the compiler bound a
+     * unique target ({@code methodTarget} or a uniquely resolved field).
+     * A name-and-arity heuristic is not enough: it can rewrite the wrong
+     * overload in dynamically typed Groovy.
+     *
+     * @param node node under the caret
+     * @param snapshot latest compile
+     * @return {@code true} when a workspace edit is justified
+     */
+    static boolean isRenameSafe(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof MethodCallExpression call) {
+            return call.getMethodTarget() != null;
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return resolveStatic(call, snapshot) != null;
+        }
+        if (node instanceof PropertyExpression property) {
+            return resolveProperty(property) != null;
+        }
+        if (node instanceof VariableExpression variable) {
+            Variable accessed = variable.getAccessedVariable();
+            return accessed instanceof ASTNode ast && ast.getLineNumber() > 0;
+        }
+        return node instanceof ClassNode || node instanceof MethodNode || node instanceof FieldNode
+                || node instanceof PropertyNode || node instanceof Parameter;
+    }
+
+    static ASTNode resolveProperty(final PropertyExpression property) {
+        if (property == null) {
+            return null;
+        }
+        String name = property.getPropertyAsString();
+        ClassNode type = typeOf(property.getObjectExpression());
+        if (name == null || !SymbolIdentity.isResolvedType(type)) {
+            return null;
+        }
+        FieldNode field = type.getField(name);
+        if (field != null && !field.isSynthetic()) {
+            return field;
+        }
+        PropertyNode prop = type.getProperty(name);
+        if (prop != null && !prop.isSynthetic()) {
+            return prop;
+        }
+        return null;
+    }
+
+    static boolean callMatches(final MethodCallExpression call, final MethodNode method,
+                               final AstContext context) {
+        if (call == null || method == null || !method.getName().equals(call.getMethodAsString())) {
+            return false;
+        }
+        MethodNode target = call.getMethodTarget();
+        if (target != null) {
+            return SymbolIdentity.keyOf(target).equals(SymbolIdentity.keyOf(method));
+        }
+        ClassNode receiver = typeOf(call.getObjectExpression());
+        ClassNode owner = method.getDeclaringClass();
+        if (SymbolIdentity.isResolvedType(receiver) && owner != null) {
+            return sameOrDerived(receiver, owner);
+        }
+        if (call.isImplicitThis() && context != null && context.enclosingClass() != null && owner != null) {
+            return sameOrDerived(context.enclosingClass(), owner);
+        }
+        return false;
+    }
+
+    static boolean staticCallMatches(final StaticMethodCallExpression call, final MethodNode method) {
+        if (call == null || method == null || !method.getName().equals(call.getMethodAsString())) {
+            return false;
+        }
+        ClassNode owner = method.getDeclaringClass();
+        return owner != null && sameType(owner, call.getOwnerType());
+    }
+
+    static boolean receiverMatchesOwner(final Expression receiver, final String ownerName) {
+        if (ownerName == null || ownerName.isEmpty()) {
+            return false;
+        }
+        ClassNode type = typeOf(receiver);
+        return SymbolIdentity.isResolvedType(type) && ownerName.equals(type.getName());
+    }
+
+    static List<ClassNode> implementations(final ClassNode type, final CompilationSnapshot snapshot) {
+        List<ClassNode> result = new ArrayList<>();
+        if (snapshot == null || type == null) {
+            return result;
+        }
+        for (CompiledDocument document : snapshot.documents()) {
+            if (document.getModule() == null) {
+                continue;
+            }
+            for (ClassNode classNode : document.getModule().getClasses()) {
+                if (classNode == type || sameType(classNode, type) || classNode.getLineNumber() <= 0) {
+                    continue;
+                }
+                if (implementsOrExtends(classNode, type)) {
+                    result.add(classNode);
+                }
+            }
+        }
+        return result;
+    }
+
+    static List<MethodNode> methodImplementations(final MethodNode method, final CompilationSnapshot snapshot) {
+        List<MethodNode> result = new ArrayList<>();
+        if (method == null || method.getDeclaringClass() == null) {
+            return result;
+        }
+        for (ClassNode classNode : implementations(method.getDeclaringClass(), snapshot)) {
+            MethodNode override = classNode.getDeclaredMethod(method.getName(), method.getParameters());
+            if (override == null) {
+                override = classNode.getMethod(method.getName(), method.getParameters());
+            }
+            if (override != null && override.getLineNumber() > 0 && override != method) {
+                result.add(override);
+            }
+        }
+        return result;
+    }
+
+    static String nameOf(final ASTNode node) {
+        return Identifiers.nameOf(node);
+    }
+
+    static URI uriOf(final ASTNode dest, final URI fallback, final CompilationSnapshot snapshot,
+                     final ModuleNode current) {
+        ModuleNode module = moduleOf(dest);
+        if (module != null && module.getContext() != null) {
+            try {
+                return Uris.parse(module.getContext().getName());
+            } catch (IllegalArgumentException ignored) {
+                // fall through
+            }
+        }
+        if (current != null && current.getContext() != null) {
+            try {
+                return Uris.parse(current.getContext().getName());
+            } catch (IllegalArgumentException ignored) {
+                // fall through
+            }
+        }
+        URI fromSnapshot = uriOfClass(dest, snapshot);
+        return fromSnapshot == null ? fallback : fromSnapshot;
+    }
+
+    private static URI uriOfClass(final ASTNode dest, final CompilationSnapshot snapshot) {
+        if (snapshot == null || !(dest instanceof ClassNode classNode)) {
+            return null;
+        }
+        for (CompiledDocument document : snapshot.documents()) {
+            URI uri = uriIfDeclares(document, classNode);
+            if (uri != null) {
+                return uri;
+            }
+        }
+        return null;
+    }
+
+    private static URI uriIfDeclares(final CompiledDocument document, final ClassNode classNode) {
+        if (document.getModule() == null) {
+            return null;
+        }
+        for (ClassNode other : document.getModule().getClasses()) {
+            if (sameType(other, classNode)) {
+                return document.getUri();
+            }
+        }
+        return null;
+    }
+
+    static ModuleNode moduleOf(final ASTNode dest) {
+        if (dest instanceof ClassNode classNode) {
+            return classNode.getModule();
+        }
+        if (dest instanceof AnnotatedNode annotated && annotated.getDeclaringClass() != null) {
+            return annotated.getDeclaringClass().getModule();
+        }
+        return null;
+    }
+
+    private static String textOf(final URI uri, final CompilationSnapshot snapshot, final String fallback) {
+        CompiledDocument document = snapshot.get(uri);
+        return document == null ? fallback : document.getText();
+    }
+
+    private static void addLocation(final List<Location> locations, final Set<String> seen, final URI uri,
+                                    final ASTNode node, final String text, final PositionEncoding encoding) {
+        Range range = node instanceof AnnotatedNode annotated
+                ? Positions.toNameRange(annotated, text, encoding)
+                : Positions.toIdentifierRange(node, text, encoding);
+        if (range == null) {
+            return;
+        }
+        String key = uri + "|" + range.getStart().getLine() + "|" + range.getStart().getCharacter()
+                + "|" + range.getEnd().getLine() + "|" + range.getEnd().getCharacter();
+        if (!seen.add(key)) {
+            return;
+        }
+        locations.add(new Location(uri.toString(), range));
+    }
+
+    private List<LocationLink> linksFor(final List<? extends ASTNode> nodes, final ASTNode origin,
+                                        final TextDocument document, final CompilationSnapshot snapshot,
+                                        final CompiledDocument compiled, final PositionEncoding encoding) {
+        List<LocationLink> links = new ArrayList<>();
+        Set<String> seen = new LinkedHashSet<>();
+        for (ASTNode dest : nodes) {
+            LocationLink link = toLink(origin, dest, document, snapshot, compiled, encoding);
+            if (link == null) {
+                continue;
+            }
+            String key = link.getTargetUri() + "|" + link.getTargetSelectionRange();
+            if (seen.add(key)) {
+                links.add(link);
+            }
+        }
+        return links;
+    }
+
+    private static LocationLink toLink(final ASTNode origin, final ASTNode dest, final TextDocument document,
+                                       final CompilationSnapshot snapshot, final CompiledDocument compiled,
+                                       final PositionEncoding encoding) {
+        if (dest == null) {
+            return null;
+        }
+        if (dest.getLineNumber() <= 0 || moduleOf(dest) == null) {
+            LocationLink java = javaLink(origin, dest, document, snapshot, encoding);
+            if (java != null) {
+                return java;
+            }
+        }
+        if (dest.getLineNumber() <= 0) {
+            return null;
+        }
+        URI uri = uriOf(dest, document.getUri(), snapshot, compiled.getModule());
+        Range originRange = Positions.toIdentifierRange(origin, document.getText(), encoding);
+        String destText = textOf(uri, snapshot, compiled.getText());
+        Range targetRange = Positions.toRange(dest, destText, encoding);
+        Range selection = Positions.toIdentifierRange(dest, destText, encoding);
+        if (targetRange == null || selection == null) {
+            return null;
+        }
+        LocationLink link = new LocationLink();
+        link.setOriginSelectionRange(originRange);
+        link.setTargetUri(uri.toString());
+        link.setTargetRange(targetRange);
+        link.setTargetSelectionRange(selection);
+        return link;
+    }
+
+    private static List<LocationLink> javaIdentifierLinks(final TextDocument document,
+                                                          final CompilationSnapshot snapshot,
+                                                          final Position position, final PositionEncoding encoding) {
+        if (document == null || snapshot == null || position == null) {
+            return List.of();
+        }
+        String name = identifierAt(document, position, encoding);
+        if (name.isEmpty()) {
+            return List.of();
+        }
+        List<LocationLink> links = new ArrayList<>();
+        addGroovyTypeLinks(links, document, snapshot, position, encoding, name);
+        addJavaTypeLinks(links, document, snapshot, position, encoding, name);
+        return links;
+    }
+
+    private static String identifierAt(final TextDocument document, final Position position,
+                                       final PositionEncoding encoding) {
+        int[] groovy = document.toGroovy(position, encoding);
+        String line = Positions.lineText(document.getText(), groovy[0]);
+        int offset = Positions.groovyColumnToCharacter(line, groovy[1], encoding);
+        String name = Identifiers.wordAround(line, offset);
+        if (name == null || name.isEmpty()) {
+            name = CompletionService.prefixAt(document, position, encoding);
+        }
+        return name == null ? "" : name;
+    }
+
+    private static void addGroovyTypeLinks(final List<LocationLink> links, final TextDocument document,
+                                           final CompilationSnapshot snapshot, final Position position,
+                                           final PositionEncoding encoding, final String name) {
+        TypeIndex.TypeHit groovyType = snapshot.types().uniqueBySimpleName(name);
+        if (groovyType == null) {
+            return;
+        }
+        CompiledDocument dest = snapshot.get(groovyType.uri());
+        if (dest == null || dest.getModule() == null) {
+            return;
+        }
+        for (ClassNode classNode : dest.getModule().getClasses()) {
+            if (name.equals(classNode.getNameWithoutPackage()) && classNode.getLineNumber() > 0) {
+                addLink(links, javaSourceLink(document, position, encoding, new JavaTarget(groovyType.uri(),
+                        dest.getText(), classNode.getLineNumber(), classNode.getColumnNumber(),
+                        classNode.getLastLineNumber(), classNode.getLastColumnNumber())));
+            }
+        }
+    }
+
+    private static void addJavaTypeLinks(final List<LocationLink> links, final TextDocument document,
+                                         final CompilationSnapshot snapshot, final Position position,
+                                         final PositionEncoding encoding, final String name) {
+        for (JavaSymbolIndex.JavaSymbol symbol : snapshot.javaSymbols().typesNamed(name)) {
+            addLink(links, javaSourceLink(document, position, encoding, new JavaTarget(symbol.uri(),
+                    textOf(symbol.uri(), snapshot, document.getText()),
+                    symbol.line(), symbol.column(), symbol.endLine(), symbol.endColumn())));
+        }
+    }
+
+    private static void addLink(final List<LocationLink> links, final LocationLink link) {
+        if (link != null) {
+            links.add(link);
+        }
+    }
+
+    private static LocationLink javaLink(final ASTNode origin, final ASTNode dest, final TextDocument document,
+                                         final CompilationSnapshot snapshot, final PositionEncoding encoding) {
+        JavaSymbolIndex.JavaSymbol symbol = javaSymbolOf(dest, snapshot);
+        if (symbol == null) {
+            return null;
+        }
+        Range originRange = Positions.toIdentifierRange(origin, document.getText(), encoding);
+        Position caret = originRange == null ? new Position(0, 0) : originRange.getStart();
+        return javaSourceLink(document, caret, encoding, new JavaTarget(symbol.uri(),
+                textOf(symbol.uri(), snapshot, document.getText()),
+                symbol.line(), symbol.column(), symbol.endLine(), symbol.endColumn()));
+    }
+
+    private static JavaSymbolIndex.JavaSymbol javaSymbolOf(final ASTNode dest, final CompilationSnapshot snapshot) {
+        if (snapshot == null || dest == null) {
+            return null;
+        }
+        JavaSymbolIndex index = snapshot.javaSymbols();
+        if (dest instanceof ClassNode classNode) {
+            return index.type(classNode.getName());
+        }
+        if (dest instanceof MethodNode method && method.getDeclaringClass() != null) {
+            List<JavaSymbolIndex.JavaSymbol> hits = index.members(method.getDeclaringClass().getName(), method.getName());
+            return hits.isEmpty() ? null : hits.get(0);
+        }
+        if (dest instanceof FieldNode field && field.getDeclaringClass() != null) {
+            List<JavaSymbolIndex.JavaSymbol> hits = index.members(field.getDeclaringClass().getName(), field.getName());
+            return hits.isEmpty() ? null : hits.get(0);
+        }
+        return null;
+    }
+
+    private static LocationLink javaSourceLink(final TextDocument origin, final Position position,
+                                               final PositionEncoding encoding, final JavaTarget dest) {
+        if (origin == null || dest == null || dest.uri() == null || dest.text() == null) {
+            return null;
+        }
+        int endLine = dest.endLine() <= 0 ? dest.line() : dest.endLine();
+        int endColumn = dest.endColumn() <= 0 ? dest.column() + 1 : dest.endColumn();
+        Range target = Positions.toRange(dest.line(), dest.column(), endLine, endColumn, dest.text(), encoding);
+        if (target == null) {
+            return null;
+        }
+        LocationLink link = new LocationLink();
+        link.setOriginSelectionRange(new Range(position, position));
+        link.setTargetUri(dest.uri().toString());
+        link.setTargetRange(target);
+        link.setTargetSelectionRange(target);
+        return link;
+    }
+
+    private record ReferenceWalk(List<Location> locations, Set<String> seen, SymbolIdentity identity,
+                                 CompilationSnapshot snapshot, PositionEncoding encoding, boolean includeDeclaration) {
+    }
+
+    private record JavaTarget(URI uri, String text, int line, int column, int endLine, int endColumn) {
+    }
+
+    private static MethodNode methodAt(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof MethodNode method) {
+            return method;
+        }
+        if (node instanceof MethodCallExpression call) {
+            return resolveMethod(call, snapshot);
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return resolveStatic(call, snapshot);
+        }
+        return null;
+    }
+
+    static MethodNode superOf(final MethodNode method) {
+        if (method == null || method.getDeclaringClass() == null) {
+            return null;
+        }
+        ClassNode type = method.getDeclaringClass();
+        MethodNode found = matchSuper(type.getSuperClass(), method);
+        if (found != null) {
+            return found;
+        }
+        if (type.getInterfaces() != null) {
+            for (ClassNode iface : type.getInterfaces()) {
+                found = matchSuper(iface, method);
+                if (found != null) {
+                    return found;
+                }
+            }
+        }
+        return null;
+    }
+
+    private static MethodNode matchSuper(final ClassNode type, final MethodNode method) {
+        if (type == null || skipSuperOwner(type)) {
+            return null;
+        }
+        MethodNode declared = type.getDeclaredMethod(method.getName(), method.getParameters());
+        if (declared == null) {
+            declared = type.getMethod(method.getName(), method.getParameters());
+        }
+        if (declared != null && declared != method) {
+            return declared;
+        }
+        MethodNode further = matchSuper(type.getSuperClass(), method);
+        if (further != null) {
+            return further;
+        }
+        if (type.getInterfaces() != null) {
+            for (ClassNode iface : type.getInterfaces()) {
+                further = matchSuper(iface, method);
+                if (further != null) {
+                    return further;
+                }
+            }
+        }
+        return null;
+    }
+
+    private static boolean skipSuperOwner(final ClassNode type) {
+        String name = type.getName();
+        return "java.lang.Object".equals(name) || "groovy.lang.GroovyObject".equals(name)
+                || "groovy.lang.GroovyObjectSupport".equals(name);
+    }
+
+    static MethodNode findMethod(final CompilationSnapshot snapshot, final String owner, final String name,
+                                 final String descriptor) {
+        if (snapshot == null || name == null) {
+            return null;
+        }
+        MethodNode fallback = null;
+        for (CompiledDocument document : snapshot.documents()) {
+            MethodNode match = findMethodIn(document, owner, name, descriptor);
+            if (match == null) {
+                continue;
+            }
+            if (descriptor != null && !descriptor.isEmpty() && descriptor.equals(match.getTypeDescriptor())) {
+                return match;
+            }
+            if (fallback == null) {
+                fallback = match;
+            }
+        }
+        return fallback;
+    }
+
+    private static MethodNode findMethodIn(final CompiledDocument document, final String owner, final String name,
+                                           final String descriptor) {
+        if (document.getModule() == null) {
+            return null;
+        }
+        MethodNode fallback = null;
+        for (ClassNode classNode : document.getModule().getClasses()) {
+            if (ownerMatches(owner, classNode)) {
+                MethodNode hit = methodOn(classNode, name, descriptor);
+                if (hit != null && descriptor != null && !descriptor.isEmpty()
+                        && descriptor.equals(hit.getTypeDescriptor())) {
+                    return hit;
+                }
+                if (fallback == null) {
+                    fallback = hit;
+                }
+            }
+        }
+        return fallback;
+    }
+
+    private static boolean ownerMatches(final String owner, final ClassNode classNode) {
+        return owner == null || owner.isEmpty() || owner.equals(classNode.getName());
+    }
+
+    private static MethodNode methodOn(final ClassNode classNode, final String name, final String descriptor) {
+        MethodNode fallback = null;
+        for (MethodNode method : classNode.getMethods()) {
+            if (name.equals(method.getName()) && method.getLineNumber() > 0) {
+                if (descriptor != null && !descriptor.isEmpty()) {
+                    if (descriptor.equals(method.getTypeDescriptor())) {
+                        return method;
+                    }
+                } else if (fallback == null) {
+                    fallback = method;
+                }
+            }
+        }
+        return fallback;
+    }
+
+    private static List<MethodNode> findMethods(final ClassNode type, final String name, final int argc) {
+        if (!SymbolIdentity.isResolvedType(type) || name == null) {
+            return List.of();
+        }
+        return matchingArity(type.getMethods(name), argc);
+    }
+
+    private static <T extends MethodNode> List<T> matchingArity(final Iterable<T> methods, final int argc) {
+        List<T> matches = new ArrayList<>();
+        if (methods == null) {
+            return matches;
+        }
+        for (T method : methods) {
+            if (method == null || method.isSynthetic()) {
+                continue;
+            }
+            int count = method.getParameters() == null ? 0 : method.getParameters().length;
+            if (count == argc) {
+                matches.add(method);
+            }
+        }
+        return matches;
+    }
+
+    private static ClassNode enclosingClass(final MethodCallExpression call, final CompilationSnapshot snapshot) {
+        if (snapshot == null) {
+            return null;
+        }
+        ClassNode[] found = new ClassNode[1];
+        for (CompiledDocument document : snapshot.documents()) {
+            if (document.getModule() == null) {
+                continue;
+            }
+            AstQuery.walk(document.getModule(), (node, ctx) -> {
+                if (node == call && ctx.enclosingClass() != null) {
+                    found[0] = ctx.enclosingClass();
+                }
+            });
+            if (found[0] != null) {
+                return found[0];
+            }
+        }
+        return null;
+    }
+
+    static List<MethodNode> overloads(final ClassNode type, final String name) {
+        List<MethodNode> result = new ArrayList<>();
+        if (type == null || name == null) {
+            return result;
+        }
+        for (MethodNode method : type.getMethods(name)) {
+            if (!method.isSynthetic()) {
+                result.add(method);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * All visible overloads of a call, including implicit {@code this}.
+     * Signature help lists every overload; it must not require a unique match.
+     *
+     * @param call a method call
+     * @param snapshot latest compile
+     * @return overloads, possibly empty
+     */
+    static List<MethodNode> overloads(final MethodCallExpression call, final CompilationSnapshot snapshot) {
+        if (call == null) {
+            return List.of();
+        }
+        List<MethodNode> found = overloads(typeOf(call.getObjectExpression()), call.getMethodAsString());
+        if (found.isEmpty() && call.isImplicitThis()) {
+            found = overloads(enclosingClass(call, snapshot), call.getMethodAsString());
+        }
+        return found;
+    }
+
+    static int argumentCount(final Expression arguments) {
+        if (arguments instanceof TupleExpression tuple) {
+            return tuple.getExpressions().size();
+        }
+        return arguments == null ? 0 : 1;
+    }
+
+    private static ClassNode typeOf(final Expression expression) {
+        return expression == null ? null : expression.getType();
+    }
+
+    static boolean sameType(final ClassNode left, final ClassNode right) {
+        if (left == null || right == null) {
+            return false;
+        }
+        if (left.equals(right)) {
+            return true;
+        }
+        return left.getName() != null && left.getName().equals(right.getName());
+    }
+
+    static boolean sameOrDerived(final ClassNode receiver, final ClassNode owner) {
+        if (sameType(receiver, owner)) {
+            return true;
+        }
+        return implementsOrExtends(receiver, owner);
+    }
+
+    private static boolean implementsOrExtends(final ClassNode candidate, final ClassNode type) {
+        if (candidate == null || type == null || sameType(candidate, type)) {
+            return false;
+        }
+        if (sameType(candidate.getSuperClass(), type)) {
+            return true;
+        }
+        if (candidate.isDerivedFrom(type)) {
+            return true;
+        }
+        return candidate.implementsInterface(type);
+    }
+
+    private enum Target {
+        DEFINITION, TYPE, IMPLEMENTATION
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/RenameService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/RenameService.java
new file mode 100644
index 0000000000..7e4c70019e
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/RenameService.java
@@ -0,0 +1,297 @@
+/*
+ *  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.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.InnerClassNode;
+import org.codehaus.groovy.ast.ModuleNode;
+import org.eclipse.lsp4j.FileRename;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.PrepareRenameResult;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.RenameFile;
+import org.eclipse.lsp4j.ResourceOperation;
+import org.eclipse.lsp4j.SnippetTextEdit;
+import org.eclipse.lsp4j.TextDocumentEdit;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.VersionedTextDocumentIdentifier;
+import org.eclipse.lsp4j.WorkspaceEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Prepare-rename and rename using reference locations. Calls whose
+ * {@code methodTarget} is empty are refused: a name-and-arity guess is
+ * good enough for hover, not for rewriting source.
+ */
+public final class RenameService {
+
+    private final NavigationService navigation = new NavigationService();
+
+    /**
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param encoding negotiated encoding
+     * @return the range of the identifier, or {@code null} when it cannot be renamed
+     */
+    public Either<Range, PrepareRenameResult> prepareRename(final TextDocument document,
+                                                            final CompilationSnapshot snapshot,
+                                                            final Position position,
+                                                            final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return null;
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        if (!NavigationService.isRenameSafe(node, snapshot)) {
+            return null;
+        }
+        String name = NavigationService.nameOf(node);
+        if (name == null) {
+            return null;
+        }
+        Range range = Positions.toIdentifierRange(node, document.getText(), encoding);
+        if (range == null) {
+            return null;
+        }
+        return Either.forRight(new PrepareRenameResult(range, name));
+    }
+
+    /**
+     * @param document open document
+     * @param snapshot latest compile
+     * @param position LSP position
+     * @param newName replacement identifier
+     * @param encoding negotiated encoding
+     * @return a workspace edit
+     */
+    public WorkspaceEdit rename(final TextDocument document, final CompilationSnapshot snapshot,
+                                final Position position, final String newName, final PositionEncoding encoding) {
+        ASTNode node = renameTarget(document, snapshot, position, newName, encoding);
+        if (node == null) {
+            return emptyEdit();
+        }
+        Map<String, List<TextEdit>> changes = referenceEdits(document, snapshot, position, encoding, newName);
+        WorkspaceEdit edit = new WorkspaceEdit(changes);
+        addFileRename(edit, document, node, newName, changes);
+        return edit;
+    }
+
+    /**
+     * Inverse of class→file rename: rewrite the type whose name equals the
+     * old stem. The client already moves the file; this returns text edits
+     * only.
+     *
+     * @param files rename pairs
+     * @param snapshot latest compile
+     * @param encoding negotiated encoding
+     * @return a workspace edit, never {@code null}
+     */
+    public WorkspaceEdit willRenameFiles(final List<FileRename> files, final CompilationSnapshot snapshot,
+                                         final PositionEncoding encoding) {
+        WorkspaceEdit combined = new WorkspaceEdit(new HashMap<>());
+        if (files == null || snapshot == null) {
+            return combined;
+        }
+        for (FileRename file : files) {
+            if (file == null) {
+                continue;
+            }
+            WorkspaceEdit one = willRenameFile(file.getOldUri(), file.getNewUri(), snapshot, encoding);
+            if (one.getChanges() != null) {
+                one.getChanges().forEach((uri, edits) ->
+                        combined.getChanges().computeIfAbsent(uri, key -> new ArrayList<>()).addAll(edits));
+            }
+        }
+        return combined;
+    }
+
+    private WorkspaceEdit willRenameFile(final String oldUriString, final String newUriString,
+                                         final CompilationSnapshot snapshot, final PositionEncoding encoding) {
+        if (oldUriString == null || newUriString == null) {
+            return emptyEdit();
+        }
+        URI oldUri = Uris.parse(oldUriString);
+        URI newUri = Uris.parse(newUriString);
+        String oldName = Uris.fileName(oldUri);
+        String newName = Uris.fileName(newUri);
+        String oldExt = Uris.groovyExtension(oldName);
+        String newExt = Uris.groovyExtension(newName);
+        if (oldExt == null || newExt == null || !oldExt.equals(newExt)) {
+            return emptyEdit();
+        }
+        String oldStem = oldName.substring(0, oldName.length() - oldExt.length());
+        String newStem = newName.substring(0, newName.length() - newExt.length());
+        if (!isIdentifier(newStem) || !sameDirectory(oldUri, newUri)) {
+            return emptyEdit();
+        }
+        CompiledDocument compiled = snapshot.get(oldUri);
+        if (compiled == null || compiled.getModule() == null) {
+            return emptyEdit();
+        }
+        ClassNode type = uniqueStemClass(compiled.getModule(), oldStem);
+        if (type == null) {
+            return emptyEdit();
+        }
+        Range nameRange = Positions.toNameRange(type, compiled.getText(), encoding);
+        if (nameRange == null) {
+            return emptyEdit();
+        }
+        TextDocument document = compiled.toTextDocument();
+        return renameText(document, snapshot, nameRange.getStart(), newStem, encoding);
+    }
+
+    private WorkspaceEdit renameText(final TextDocument document, final CompilationSnapshot snapshot,
+                                     final Position position, final String newName,
+                                     final PositionEncoding encoding) {
+        if (renameTarget(document, snapshot, position, newName, encoding) == null) {
+            return emptyEdit();
+        }
+        return new WorkspaceEdit(referenceEdits(document, snapshot, position, encoding, newName));
+    }
+
+    private ASTNode renameTarget(final TextDocument document, final CompilationSnapshot snapshot,
+                                 final Position position, final String newName,
+                                 final PositionEncoding encoding) {
+        if (document == null || newName == null || newName.isBlank() || !isIdentifier(newName)) {
+            return null;
+        }
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return null;
+        }
+        ASTNode node = AstQuery.nodeAt(compiled.getModule(), document, position, encoding);
+        return NavigationService.isRenameSafe(node, snapshot) ? node : null;
+    }
+
+    private Map<String, List<TextEdit>> referenceEdits(final TextDocument document,
+                                                       final CompilationSnapshot snapshot,
+                                                       final Position position,
+                                                       final PositionEncoding encoding,
+                                                       final String newName) {
+        List<Location> locations = navigation.references(document, snapshot, position, encoding, true);
+        Map<String, List<TextEdit>> changes = new HashMap<>();
+        for (Location location : locations) {
+            changes.computeIfAbsent(location.getUri(), key -> new ArrayList<>())
+                    .add(new TextEdit(location.getRange(), newName));
+        }
+        return changes;
+    }
+
+    private static WorkspaceEdit emptyEdit() {
+        return new WorkspaceEdit(Map.of());
+    }
+
+    private static ClassNode uniqueStemClass(final ModuleNode module, final String stem) {
+        ClassNode match = null;
+        for (ClassNode classNode : module.getClasses()) {
+            if (classNode instanceof InnerClassNode || classNode.isScript() || classNode.getLineNumber() <= 0) {
+                continue;
+            }
+            if (stem.equals(classNode.getNameWithoutPackage())) {
+                if (match != null) {
+                    return null;
+                }
+                match = classNode;
+            }
+        }
+        return match;
+    }
+
+    private static boolean sameDirectory(final URI oldUri, final URI newUri) {
+        String oldPath = oldUri.getPath();
+        String newPath = newUri.getPath();
+        if (oldPath == null || newPath == null) {
+            return false;
+        }
+        int oldSlash = Uris.lastSeparator(oldPath);
+        int newSlash = Uris.lastSeparator(newPath);
+        if (oldSlash < 0 || newSlash < 0) {
+            return false;
+        }
+        return oldPath.substring(0, oldSlash).equals(newPath.substring(0, newSlash));
+    }
+
+    private static void addFileRename(final WorkspaceEdit edit, final TextDocument document, final ASTNode node,
+                                      final String newName, final Map<String, List<TextEdit>> changes) {
+        if (!(node instanceof ClassNode classNode)) {
+            return;
+        }
+        String fileName = Uris.fileName(document.getUri());
+        String extension = Uris.groovyExtension(fileName);
+        if (extension == null) {
+            return;
+        }
+        String stem = fileName.substring(0, fileName.length() - extension.length());
+        if (!stem.equals(classNode.getNameWithoutPackage())) {
+            return;
+        }
+        String oldUri = document.getUri().toString();
+        int slash = Uris.lastSeparator(oldUri);
+        if (slash < 0) {
+            return;
+        }
+        String newUri = oldUri.substring(0, slash + 1) + newName + extension;
+        List<Either<TextDocumentEdit, ResourceOperation>> documentChanges = new ArrayList<>();
+        for (Map.Entry<String, List<TextEdit>> entry : changes.entrySet()) {
+            List<Either<TextEdit, SnippetTextEdit>> eitherEdits = new ArrayList<>();
+            for (TextEdit textEdit : entry.getValue()) {
+                eitherEdits.add(Either.forLeft(textEdit));
+            }
+            documentChanges.add(Either.forLeft(new TextDocumentEdit(
+                    new VersionedTextDocumentIdentifier(entry.getKey(), null), eitherEdits)));
+        }
+        documentChanges.add(Either.forRight(new RenameFile(oldUri, newUri)));
+        edit.setDocumentChanges(documentChanges);
+    }
+
+    static boolean isIdentifier(final String name) {
+        if (name == null || name.isEmpty()) {
+            return false;
+        }
+        int first = name.codePointAt(0);
+        if (!Character.isJavaIdentifierStart(first)) {
+            return false;
+        }
+        int i = Character.charCount(first);
+        while (i < name.length()) {
+            int cp = name.codePointAt(i);
+            if (!Character.isJavaIdentifierPart(cp)) {
+                return false;
+            }
+            i += Character.charCount(cp);
+        }
+        return true;
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SourceGeneration.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SourceGeneration.java
new file mode 100644
index 0000000000..9944bd3613
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SourceGeneration.java
@@ -0,0 +1,310 @@
+/*
+ *  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.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.ConstructorNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Source-generation edits (getters, constructors, toString, equals/hashCode,
+ * {@code @Override}), designed after JDT LS source actions but written for
+ * the Groovy AST.
+ */
+public final class SourceGeneration {
+
+    static final String GENERATE_ACCESSORS = "source.generate.accessors";
+    static final String GENERATE_TO_STRING = "source.generate.toString";
+    static final String GENERATE_EQUALS = "source.generate.hashCodeEquals";
+    static final String GENERATE_CONSTRUCTORS = "source.generate.constructors";
+    private static final String METHOD_CLOSE = "\n    }\n";
+
+    private SourceGeneration() {
+    }
+
+    static List<FieldNode> instanceFields(final ClassNode classNode) {
+        List<FieldNode> fields = new ArrayList<>();
+        if (classNode == null) {
+            return fields;
+        }
+        for (FieldNode field : classNode.getFields()) {
+            if (field.isSynthetic() || Modifier.isStatic(field.getModifiers())
+                    || field.getLineNumber() <= 0 || "metaClass".equals(field.getName())) {
+                continue;
+            }
+            fields.add(field);
+        }
+        for (PropertyNode property : classNode.getProperties()) {
+            if (property.isSynthetic() || Modifier.isStatic(property.getModifiers())
+                    || property.getField() == null || "metaClass".equals(property.getName())) {
+                continue;
+            }
+            FieldNode field = property.getField();
+            boolean known = fields.stream().anyMatch(existing -> existing.getName().equals(field.getName()));
+            if (!known) {
+                fields.add(field);
+            }
+        }
+        return fields;
+    }
+
+    static List<FieldNode> privateFieldsWithoutAccessors(final ClassNode classNode) {
+        List<FieldNode> fields = new ArrayList<>();
+        for (FieldNode field : instanceFields(classNode)) {
+            if (needsGeneratedAccessor(classNode, field)) {
+                fields.add(field);
+            }
+        }
+        return fields;
+    }
+
+    private static boolean needsGeneratedAccessor(final ClassNode classNode, final FieldNode field) {
+        return Modifier.isPrivate(field.getModifiers())
+                && classNode.getProperty(field.getName()) == null
+                && !hasAccessor(classNode, field);
+    }
+
+    static boolean hasAccessor(final ClassNode classNode, final FieldNode field) {
+        String cap = capitalize(field.getName());
+        return classNode.getDeclaredMethod("get" + cap, Parameter.EMPTY_ARRAY) != null
+                || classNode.getDeclaredMethod("set" + cap, new Parameter[]{new Parameter(field.getType(), field.getName())}) != null
+                || classNode.getGetterMethod("get" + cap) != null;
+    }
+
+    static boolean hasDeclared(final ClassNode classNode, final String name, final Parameter[] parameters) {
+        MethodNode method = classNode.getDeclaredMethod(name, parameters);
+        return method != null && !method.isSynthetic();
+    }
+
+    static List<TextEdit> accessors(final ClassNode classNode, final CompiledDocument compiled,
+                                    final PositionEncoding encoding) {
+        List<FieldNode> fields = privateFieldsWithoutAccessors(classNode);
+        if (fields.isEmpty()) {
+            return List.of();
+        }
+        StringBuilder stub = new StringBuilder();
+        for (FieldNode field : fields) {
+            String cap = capitalize(field.getName());
+            String type = typeName(field);
+            stub.append("    ").append(type).append(" get").append(cap).append("() {\n        ")
+                    .append(field.getName()).append(METHOD_CLOSE)
+                    .append("    void set").append(cap).append('(').append(type).append(' ').append(field.getName())
+                    .append(") {\n        this.").append(field.getName()).append(" = ").append(field.getName())
+                    .append(METHOD_CLOSE);
+        }
+        return insert(classNode, compiled, encoding, stub.toString());
+    }
+
+    static List<TextEdit> toStringMethod(final ClassNode classNode, final CompiledDocument compiled,
+                                         final PositionEncoding encoding) {
+        if (hasDeclared(classNode, "toString", Parameter.EMPTY_ARRAY)) {
+            return List.of();
+        }
+        StringBuilder body = new StringBuilder("    @Override\n    String toString() {\n        '")
+                .append(classNode.getNameWithoutPackage()).append("(");
+        List<FieldNode> fields = instanceFields(classNode);
+        for (int i = 0; i < fields.size(); i++) {
+            if (i > 0) {
+                body.append(", ");
+            }
+            body.append(fields.get(i).getName()).append(": ${").append(fields.get(i).getName()).append('}');
+        }
+        body.append(")'\n    }\n");
+        return insert(classNode, compiled, encoding, body.toString());
+    }
+
+    static List<TextEdit> equalsAndHashCode(final ClassNode classNode, final CompiledDocument compiled,
+                                            final PositionEncoding encoding) {
+        Parameter[] equalsParams = {new Parameter(ClassHelper.OBJECT_TYPE, "o")};
+        if (hasDeclared(classNode, "equals", equalsParams) || hasDeclared(classNode, "hashCode", Parameter.EMPTY_ARRAY)) {
+            return List.of();
+        }
+        String simple = classNode.getNameWithoutPackage();
+        StringBuilder body = new StringBuilder();
+        body.append("    @Override\n    boolean equals(Object o) {\n        if (this.is(o)) {\n            return true\n        }\n")
+                .append("        if (!(o instanceof ").append(simple).append(")) {\n            return false\n        }\n")
+                .append("        ").append(simple).append(" other = (").append(simple).append(") o\n        ");
+        List<FieldNode> fields = instanceFields(classNode);
+        if (fields.isEmpty()) {
+            body.append("true").append(METHOD_CLOSE);
+        } else {
+            for (int i = 0; i < fields.size(); i++) {
+                if (i > 0) {
+                    body.append(" && ");
+                }
+                String name = fields.get(i).getName();
+                body.append(name).append(" == other.").append(name);
+            }
+            body.append(METHOD_CLOSE);
+        }
+        body.append("    @Override\n    int hashCode() {\n        Objects.hash(");
+        for (int i = 0; i < fields.size(); i++) {
+            if (i > 0) {
+                body.append(", ");
+            }
+            body.append(fields.get(i).getName());
+        }
+        body.append(")\n    }\n");
+        return insert(classNode, compiled, encoding, body.toString());
+    }
+
+    static List<TextEdit> constructor(final ClassNode classNode, final CompiledDocument compiled,
+                                      final PositionEncoding encoding) {
+        for (ConstructorNode constructor : classNode.getDeclaredConstructors()) {
+            if (!constructor.isSynthetic()) {
+                return List.of();
+            }
+        }
+        List<FieldNode> fields = instanceFields(classNode);
+        if (fields.isEmpty()) {
+            return List.of();
+        }
+        StringBuilder body = new StringBuilder("    ").append(classNode.getNameWithoutPackage()).append('(');
+        for (int i = 0; i < fields.size(); i++) {
+            if (i > 0) {
+                body.append(", ");
+            }
+            body.append(typeName(fields.get(i))).append(' ').append(fields.get(i).getName());
+        }
+        body.append(") {\n");
+        for (FieldNode field : fields) {
+            body.append("        this.").append(field.getName()).append(" = ").append(field.getName()).append('\n');
+        }
+        body.append("    }\n");
+        return insert(classNode, compiled, encoding, body.toString());
+    }
+
+    static List<TextEdit> overrideAnnotations(final ClassNode classNode, final CompiledDocument compiled,
+                                              final PositionEncoding encoding) {
+        List<TextEdit> edits = new ArrayList<>();
+        if (classNode == null) {
+            return edits;
+        }
+        ClassNode superClass = classNode.getSuperClass();
+        for (MethodNode method : classNode.getMethods()) {
+            TextEdit edit = overrideEdit(method, superClass, classNode, compiled, encoding);
+            if (edit != null) {
+                edits.add(edit);
+            }
+        }
+        return edits;
+    }
+
+    static Position beforeClose(final ClassNode classNode, final CompiledDocument compiled,
+                                final PositionEncoding encoding) {
+        Range classRange = Positions.toRange(classNode, compiled.getText(), encoding);
+        if (classRange == null) {
+            return null;
+        }
+        Position insert = classRange.getEnd();
+        String line = Positions.lineText(compiled.getText(), insert.getLine() + 1);
+        int brace = line.lastIndexOf('}');
+        if (brace >= 0) {
+            return Positions.toLsp(insert.getLine() + 1, brace + 1, line, encoding);
+        }
+        return insert;
+    }
+
+    static String capitalize(final String name) {
+        if (name == null || name.isEmpty()) {
+            return "";
+        }
+        int first = name.codePointAt(0);
+        return new String(Character.toChars(Character.toUpperCase(first))) + name.substring(Character.charCount(first));
+    }
+
+    private static TextEdit overrideEdit(final MethodNode method, final ClassNode superClass,
+                                         final ClassNode classNode, final CompiledDocument compiled,
+                                         final PositionEncoding encoding) {
+        if (method.isSynthetic() || method.getLineNumber() <= 0 || hasOverride(method)) {
+            return null;
+        }
+        if (!overrides(method, superClass) && !overridesInterface(method, classNode)) {
+            return null;
+        }
+        Range name = Positions.toNameRange(method, compiled.getText(), encoding);
+        if (name == null) {
+            return null;
+        }
+        Position start = new Position(name.getStart().getLine(), 0);
+        String line = Positions.lineText(compiled.getText(), name.getStart().getLine() + 1);
+        int indent = TextDocument.leadingWhitespace(line);
+        return new TextEdit(new Range(start, start), line.substring(0, indent) + "@Override\n");
+    }
+
+    private static List<TextEdit> insert(final ClassNode classNode, final CompiledDocument compiled,
+                                         final PositionEncoding encoding, final String stub) {
+        Position insert = beforeClose(classNode, compiled, encoding);
+        if (insert == null || stub.isEmpty()) {
+            return List.of();
+        }
+        return List.of(new TextEdit(new Range(insert, insert), stub));
+    }
+
+    private static String typeName(final FieldNode field) {
+        return field.getType() == null ? "def" : field.getType().getNameWithoutPackage();
+    }
+
+    private static boolean hasOverride(final MethodNode method) {
+        for (AnnotationNode annotation : method.getAnnotations()) {
+            if (annotation.getClassNode() != null
+                    && "Override".equals(annotation.getClassNode().getNameWithoutPackage())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean overrides(final MethodNode method, final ClassNode superClass) {
+        if (superClass == null || "java.lang.Object".equals(superClass.getName())) {
+            return false;
+        }
+        MethodNode inherited = superClass.getMethod(method.getName(), method.getParameters());
+        return inherited != null && !inherited.isPrivate();
+    }
+
+    private static boolean overridesInterface(final MethodNode method, final ClassNode classNode) {
+        if (classNode.getInterfaces() == null) {
+            return false;
+        }
+        for (ClassNode iface : classNode.getInterfaces()) {
+            if (iface.getMethod(method.getName(), method.getParameters()) != null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SupportServices.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SupportServices.java
new file mode 100644
index 0000000000..9f7a7ad2e6
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SupportServices.java
@@ -0,0 +1,1285 @@
+/*
+ *  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.Token;
+import org.apache.groovy.lsp.internal.compile.AstQuery;
+import org.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.ImportSupport;
+import org.apache.groovy.lsp.internal.compile.TypeIndex;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.apache.groovy.lsp.internal.util.GroovyKeywords;
+import org.apache.groovy.lsp.internal.util.GroovySourceTokens;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.apache.groovy.parser.antlr4.GroovyLexer;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.ConstructorNode;
+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.PackageNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+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.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.eclipse.lsp4j.CodeAction;
+import org.eclipse.lsp4j.CodeActionKind;
+import org.eclipse.lsp4j.CodeLens;
+import org.eclipse.lsp4j.Command;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DocumentHighlight;
+import org.eclipse.lsp4j.DocumentHighlightKind;
+import org.eclipse.lsp4j.DocumentLink;
+import org.eclipse.lsp4j.FoldingRange;
+import org.eclipse.lsp4j.FoldingRangeKind;
+import org.eclipse.lsp4j.InlayHint;
+import org.eclipse.lsp4j.InlayHintKind;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.ParameterInformation;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SelectionRange;
+import org.eclipse.lsp4j.SemanticTokens;
+import org.eclipse.lsp4j.SemanticTokensLegend;
+import org.eclipse.lsp4j.SignatureHelp;
+import org.eclipse.lsp4j.SignatureInformation;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.WorkspaceEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Remaining LSP language features that share the compiled snapshot:
+ * signature help, highlight, folding, formatting, semantic tokens,
+ * inlay hints, code actions, code lenses, hierarchies, selection
+ * ranges and document links.
+ */
+public final class SupportServices {
+
+    private static final Pattern UNRESOLVED_CLASS = Pattern.compile("unable to resolve class ([\\w.]+)", Pattern.CASE_INSENSITIVE);
+    private final CodeActionService codeActions = new CodeActionService();
+
+    public static final List<String> TOKEN_TYPES = List.of(
+            "namespace", "class", "enum", "interface", "method", "property",
+            "variable", "parameter", "keyword", "string", "number", "comment"
+    );
+    public static final List<String> TOKEN_MODIFIERS = List.of("declaration", "static", "deprecated");
+
+    public static SemanticTokensLegend legend() {
+        return new SemanticTokensLegend(TOKEN_TYPES, TOKEN_MODIFIERS);
+    }
+
+    public SignatureHelp signatureHelp(final TextDocument document, final CompilationSnapshot snapshot,
+                                       final Position position, final PositionEncoding encoding) {
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        if (compiled == null || compiled.getModule() == null) {
+            return new SignatureHelp(List.of(), 0, 0);
+        }
+        int[] groovy = document.toGroovy(position, encoding);
+        ASTNode node = AstQuery.enclosingCall(compiled.getModule(), groovy[0], groovy[1]);
+        List<MethodNode> overloads = new ArrayList<>();
+        int active = 0;
+        int argc = 0;
+        Expression arguments = argumentsOf(node);
+        if (arguments != null) {
+            argc = NavigationService.argumentCount(arguments);
+            active = activeParameter(arguments, groovy[0], groovy[1]);
+        }
+        if (node instanceof MethodCallExpression call) {
+            overloads.addAll(NavigationService.overloads(call, snapshot));
+        } else if (node instanceof StaticMethodCallExpression call) {
+            overloads.addAll(NavigationService.overloads(call.getOwnerType(), call.getMethodAsString()));
+        } else if (node instanceof ConstructorCallExpression ctor && ctor.getType() != null) {
+            overloads.addAll(List.copyOf(ctor.getType().getDeclaredConstructors()));
+        }
+        if (overloads.isEmpty()) {
+            return new SignatureHelp(List.of(), 0, 0);
+        }
+        List<SignatureInformation> informations = new ArrayList<>();
+        int activeSignature = 0;
+        int best = Integer.MAX_VALUE;
+        for (int i = 0; i < overloads.size(); i++) {
+            MethodNode method = overloads.get(i);
+            informations.add(signatureInformation(method));
+            int distance = Math.abs(method.getParameters().length - argc);
+            if (distance < best) {
+                best = distance;
+                activeSignature = i;
+            }
+        }
+        int paramCount = overloads.get(activeSignature).getParameters().length;
+        if (active >= paramCount) {
+            active = Math.max(paramCount - 1, 0);
+        }
+        return new SignatureHelp(informations, activeSignature, active);
+    }
+
+    public List<DocumentHighlight> highlights(final TextDocument document, final CompilationSnapshot snapshot,
+                                              final Position position, final PositionEncoding encoding) {
+        if (document == null || document.getUri() == null) {
+            return List.of();
+        }
+        List<Location> refs = new NavigationService()
+                .references(document, snapshot, position, encoding, true);
+        List<DocumentHighlight> highlights = new ArrayList<>();
+        String uri = document.getUri().toString();
+        for (Location location : refs) {
+            if (location != null && uri.equals(location.getUri())) {
+                highlights.add(new DocumentHighlight(location.getRange(), DocumentHighlightKind.Text));
+            }
+        }
+        return highlights;
+    }
+
+    public List<FoldingRange> folding(final CompiledDocument document) {
+        List<FoldingRange> ranges = new ArrayList<>();
+        if (document == null || document.getModule() == null) {
+            return ranges;
+        }
+        for (ASTNode node : AstQuery.declarations(document.getModule())) {
+            if (node.getLineNumber() > 0 && node.getLastLineNumber() > node.getLineNumber()) {
+                FoldingRange range = new FoldingRange(node.getLineNumber() - 1, node.getLastLineNumber() - 1);
+                ranges.add(range);
+            }
+        }
+        addImportFolds(document.getModule(), ranges);
+        addCommentFolds(document.getText(), ranges);
+        addRegionFolds(document.getText(), ranges);
+        return ranges;
+    }
+
+    public List<TextEdit> format(final TextDocument document, final Range range, final int tabSize,
+                                 final boolean insertSpaces, final PositionEncoding encoding) {
+        String text = document.getText();
+        PositionEncoding enc = encoding == null ? PositionEncoding.UTF16 : encoding;
+        boolean full = range == null;
+        Range target = full ? document.fullRange(enc) : range;
+        String[] lines = text.split("\n", -1);
+        int startLine = Math.max(target.getStart().getLine(), 0);
+        int endLine = Math.min(target.getEnd().getLine(), lines.length - 1);
+        if (endLine < startLine) {
+            return List.of();
+        }
+        String indentUnit = indentUnit(tabSize, insertSpaces);
+        List<String> formatted = formatAllLines(lines, insertSpaces, indentUnit, frozenIndentLines(text));
+        if (full) {
+            String result = String.join("\n", formatted);
+            if (!result.endsWith("\n") && !result.isEmpty()) {
+                result += "\n";
+            }
+            if (result.equals(text)) {
+                return List.of();
+            }
+            return List.of(new TextEdit(document.fullRange(enc), result));
+        }
+        StringBuilder original = new StringBuilder();
+        StringBuilder rebuilt = new StringBuilder();
+        for (int i = startLine; i <= endLine; i++) {
+            if (i > startLine) {
+                original.append('\n');
+                rebuilt.append('\n');
+            }
+            original.append(lines[i]);
+            rebuilt.append(i < formatted.size() ? formatted.get(i) : lines[i]);
+        }
+        if (original.toString().equals(rebuilt.toString())) {
+            return List.of();
+        }
+        String last = lines[endLine];
+        Position end = Positions.toLsp(endLine + 1, last.codePointCount(0, last.length()) + 1, last, enc);
+        return List.of(new TextEdit(new Range(new Position(startLine, 0), end), rebuilt.toString()));
+    }
+
+    private static List<String> formatAllLines(final String[] lines, final boolean insertSpaces,
+                                               final String indentUnit, final Set<Integer> frozen) {
+        int[] indent = {0};
+        List<String> formatted = new ArrayList<>(lines.length);
+        for (int i = 0; i < lines.length; i++) {
+            formatted.add(frozen.contains(i) ? lines[i] : formatLine(lines[i], insertSpaces, indentUnit, indent));
+        }
+        return formatted;
+    }
+
+    private static String formatLine(final String raw, final boolean insertSpaces, final String indentUnit,
+                                     final int[] indent) {
+        String line = rtrim(raw).replace("\t", insertSpaces ? indentUnit : "\t");
+        String stripped = line.stripLeading();
+        applyCloser(stripped, indent);
+        String rebuilt = stripped.isEmpty() ? "" : indentUnit.repeat(indent[0]) + stripped;
+        applyOpener(stripped, indent);
+        return rebuilt;
+    }
+
+    private static void updateIndent(final String raw, final int[] indent) {
+        String stripped = rtrim(raw).stripLeading();
+        applyCloser(stripped, indent);
+        applyOpener(stripped, indent);
+    }
+
+    private static void applyCloser(final String stripped, final int[] indent) {
+        if (indentCloser(stripped)) {
+            indent[0] = Math.max(indent[0] - 1, 0);
+        }
+    }
+
+    private static void applyOpener(final String stripped, final int[] indent) {
+        if (indentOpener(stripped)) {
+            indent[0] += 1;
+        }
+    }
+
+    private static boolean indentCloser(final String stripped) {
+        return stripped.startsWith("}") || stripped.startsWith(")") || stripped.startsWith("]");
+    }
+
+    private static boolean indentOpener(final String stripped) {
+        return stripped.endsWith("{") || stripped.endsWith("(") && !stripped.contains(")");
+    }
+
+    private static String indentUnit(final int tabSize, final boolean insertSpaces) {
+        return insertSpaces ? " ".repeat(Math.max(tabSize, 1)) : "\t";
+    }
+
+    private static List<TextEdit> replaceLeadingWhitespace(final String line, final int lineNumber, final String pad) {
+        int existing = TextDocument.leadingWhitespace(line);
+        if (pad.equals(line.substring(0, existing))) {
+            return List.of();
+        }
+        return List.of(new TextEdit(new Range(new Position(lineNumber, 0), new Position(lineNumber, existing)), pad));
+    }
+
+    private static List<TextEdit> outdentCloser(final TextDocument document, final Position position,
+                                                final int tabSize, final boolean insertSpaces,
+                                                final PositionEncoding encoding) {
+        String text = document.getText();
+        int after = TextDocument.offsetOf(text, position, encoding == null ? PositionEncoding.UTF16 : encoding);
+        if (after <= 0 || !GroovySourceTokens.isStructuralRbrace(text, after - 1)) {
+            return List.of();
+        }
+        String[] lines = text.split("\n", -1);
+        int line = position.getLine();
+        if (line < 0 || line >= lines.length) {
+            return List.of();
+        }
+        String current = lines[line];
+        String stripped = current.stripLeading();
+        if (!stripped.startsWith("}")) {
+            return List.of();
+        }
+        int[] indent = {0};
+        for (int i = 0; i < line; i++) {
+            updateIndent(lines[i], indent);
+        }
+        int level = Math.max(indent[0] - 1, 0);
+        return replaceLeadingWhitespace(current, line, indentUnit(tabSize, insertSpaces).repeat(level));
+    }
+
+    /**
+     * Indents the new line after a newline, matching Metals' on-type formatter.
+     *
+     * @param document open document
+     * @param position caret after the typed character
+     * @param typed typed character
+     * @param tabSize tab size
+     * @param insertSpaces whether to insert spaces
+     * @return an indent edit, or empty
+     */
+    public List<TextEdit> onTypeFormat(final TextDocument document, final Position position, final String typed,
+                                       final int tabSize, final boolean insertSpaces) {
+        return onTypeFormat(document, position, typed, tabSize, insertSpaces, PositionEncoding.UTF16);
+    }
+
+    /**
+     * Indents after newline or outdents a structural {@code '}'}'.
+     *
+     * @param encoding negotiated encoding
+     * @return a tiny indent edit, or empty
+     */
+    public List<TextEdit> onTypeFormat(final TextDocument document, final Position position, final String typed,
+                                       final int tabSize, final boolean insertSpaces,
+                                       final PositionEncoding encoding) {
+        if (document == null || typed == null || position.getLine() < 0) {
+            return List.of();
+        }
+        if ("}".equals(typed)) {
+            return outdentCloser(document, position, tabSize, insertSpaces, encoding);
+        }
+        if (!"\n".equals(typed) || position.getLine() <= 0) {
+            return List.of();
+        }
+        String[] lines = document.getText().split("\n", -1);
+        int prev = position.getLine() - 1;
+        if (prev >= lines.length || position.getLine() >= lines.length) {
+            return List.of();
+        }
+        String previous = lines[prev];
+        int indent = TextDocument.leadingWhitespace(previous);
+        if (rtrim(previous).endsWith("{")) {
+            indent += Math.max(tabSize, 1);
+        }
+        String pad;
+        if (insertSpaces) {
+            pad = " ".repeat(indent);
+        } else {
+            pad = "\t".repeat(Math.max(indent / Math.max(tabSize, 1), 0));
+        }
+        return replaceLeadingWhitespace(lines[position.getLine()], position.getLine(), pad);
+    }
+
+    public SemanticTokens semanticTokens(final CompiledDocument document, final PositionEncoding encoding) {
+        return semanticTokens(document, encoding, null);
+    }
+
+    public SemanticTokens semanticTokens(final CompiledDocument document, final PositionEncoding encoding,
+                                         final Range range) {
+        List<Integer> data = new ArrayList<>();
+        if (document == null) {
+            return new SemanticTokens(data);
+        }
+        List<int[]> tokens = new ArrayList<>();
+        ModuleNode module = document.getModule();
+        if (module != null) {
+            for (ASTNode node : AstQuery.declarations(module)) {
+                addDeclarationToken(tokens, document, encoding, range, node);
+            }
+            addParameterTokens(tokens, document, encoding, range, module);
+            addUseSiteTokens(tokens, document, encoding, range, module);
+        }
+        addLexicalTokens(document.getText(), tokens, encoding, range);
+        tokens.sort((left, right) -> {
+            if (left[0] != right[0]) {
+                return Integer.compare(left[0], right[0]);
+            }
+            if (left[1] != right[1]) {
+                return Integer.compare(left[1], right[1]);
+            }
+            return Integer.compare(right[2], left[2]);
+        });
+        dedupeSameSpan(tokens);
+        int prevLine = 0;
+        int prevChar = 0;
+        for (int[] token : tokens) {
+            int dLine = token[0] - prevLine;
+            int dChar = dLine == 0 ? token[1] - prevChar : token[1];
+            if (dLine < 0 || dChar < 0) {
+                continue;
+            }
+            data.add(dLine);
+            data.add(dChar);
+            data.add(token[2]);
+            data.add(token[3]);
+            data.add(token[4]);
+            prevLine = token[0];
+            prevChar = token[1];
+        }
+        return new SemanticTokens(data);
+    }
+
+    public List<InlayHint> inlayHints(final CompiledDocument document, final Range range,
+                                      final PositionEncoding encoding) {
+        return inlayHints(document, range, encoding, null);
+    }
+
+    public List<InlayHint> inlayHints(final CompiledDocument document, final Range range,
+                                      final PositionEncoding encoding, final CompilationSnapshot snapshot) {
+        List<InlayHint> hints = new ArrayList<>();
+        if (document == null || document.getModule() == null) {
+            return hints;
+        }
+        for (ClassNode classNode : document.getModule().getClasses()) {
+            addClassHints(hints, document, range, encoding, classNode);
+        }
+        addCallSiteHints(hints, document, range, encoding, snapshot);
+        return hints;
+    }
+
+    private static void addDeclarationToken(final List<int[]> tokens, final CompiledDocument document,
+                                            final PositionEncoding encoding, final Range range, final ASTNode node) {
+        Range nameRange = node instanceof AnnotatedNode annotated
+                ? Positions.toNameRange(annotated, document.getText(), encoding)
+                : Positions.toRange(node, document.getText(), encoding);
+        if (nameRange == null || !overlaps(nameRange, range)) {
+            return;
+        }
+        int type = tokenType(node);
+        int length = Math.max(nameRange.getEnd().getCharacter() - nameRange.getStart().getCharacter(), 1);
+        tokens.add(new int[]{nameRange.getStart().getLine(), nameRange.getStart().getCharacter(), length, type,
+                tokenModifiers(node)});
+    }
+
+    private static void addParameterTokens(final List<int[]> tokens, final CompiledDocument document,
+                                           final PositionEncoding encoding, final Range range, final ModuleNode module) {
+        for (ClassNode classNode : module.getClasses()) {
+            for (MethodNode method : classNode.getMethods()) {
+                if (method.isSynthetic() || method.getParameters() == null) {
+                    continue;
+                }
+                for (Parameter parameter : method.getParameters()) {
+                    addDeclarationToken(tokens, document, encoding, range, parameter);
+                }
+            }
+        }
+    }
+
+    private static void addUseSiteTokens(final List<int[]> tokens, final CompiledDocument document,
+                                         final PositionEncoding encoding, final Range range, final ModuleNode module) {
+        String text = document.getText();
+        AstQuery.walk(module, (node, ctx) -> {
+            if (node instanceof MethodCallExpression call) {
+                addSpanToken(tokens, Positions.toIdentifierRange(call, text, encoding), range, TOKEN_METHOD, 0);
+            } else if (node instanceof StaticMethodCallExpression call) {
+                addSpanToken(tokens, Positions.toIdentifierRange(call, text, encoding), range, TOKEN_METHOD, 0);
+            } else if (node instanceof PropertyExpression property) {
+                addSpanToken(tokens, Positions.toIdentifierRange(property, text, encoding), range, TOKEN_PROPERTY, 0);
+            }
+        });
+    }
+
+    private static void addSpanToken(final List<int[]> tokens, final Range nameRange, final Range range,
+                                     final int type, final int modifiers) {
+        if (nameRange == null || !overlaps(nameRange, range)) {
+            return;
+        }
+        int length = Math.max(nameRange.getEnd().getCharacter() - nameRange.getStart().getCharacter(), 1);
+        tokens.add(new int[]{nameRange.getStart().getLine(), nameRange.getStart().getCharacter(), length, type, modifiers});
+    }
+
+    private static void addClassHints(final List<InlayHint> hints, final CompiledDocument document, final Range range,
+                                      final PositionEncoding encoding, final ClassNode classNode) {
+        for (MethodNode method : classNode.getMethods()) {
+            addMethodHints(hints, document, range, encoding, method);
+        }
+        for (FieldNode field : classNode.getFields()) {
+            if (!field.isSynthetic() && field.isDynamicTyped()) {
+                addTypeHint(hints, field, TypeInference.of(field), document.getText(), encoding, range,
+                        TypeInference.usedInitializer(field));
+            }
+        }
+    }
+
+    private static void addMethodHints(final List<InlayHint> hints, final CompiledDocument document, final Range range,
+                                       final PositionEncoding encoding, final MethodNode method) {
+        if (method.isSynthetic() || method.getCode() == null) {
+            return;
+        }
+        for (Parameter parameter : method.getParameters()) {
+            if (parameter.isDynamicTyped()) {
+                addTypeHint(hints, parameter, parameter.getType(), document.getText(), encoding, range, false);
+            }
+        }
+        AstQuery.walk(method.getCode(), (node, ctx) -> {
+            if (node instanceof DeclarationExpression decl && !decl.isMultipleAssignmentDeclaration()) {
+                VariableExpression variable = decl.getVariableExpression();
+                if (variable != null && variable.isDynamicTyped()) {
+                    addTypeHint(hints, variable, TypeInference.of(variable, document.getModule()),
+                            document.getText(), encoding, range,
+                            TypeInference.usedInitializer(variable, document.getModule()));
+                }
+            }
+        });
+    }
+
+    private static void addCodeLens(final List<CodeLens> lenses, final TextDocument open,
+                                    final CompiledDocument document, final CompilationSnapshot snapshot,
+                                    final PositionEncoding encoding, final NavigationService navigation,
+                                    final ASTNode node) {
+        if (!(node instanceof ClassNode) && !(node instanceof MethodNode)) {
+            return;
+        }
+        if (node instanceof MethodNode method && method.isSynthetic()) {
+            return;
+        }
+        Range range = Positions.toNameRange((AnnotatedNode) node, document.getText(), encoding);
+        if (range == null) {
+            return;
+        }
+        int count = navigation.references(open, snapshot, range.getStart(), encoding, false).size();
+        String label = count == 1 ? "1 reference" : count + " references";
+        CodeLens lens = new CodeLens(range);
+        lens.setCommand(new Command(label, "groovy.lsp.showReferences",
+                List.of(document.getUri().toString(), range.getStart().getLine(), range.getStart().getCharacter())));
+        lenses.add(lens);
+    }
+
+    private static void addLexicalTokens(final String text, final List<int[]> tokens,
+                                         final PositionEncoding encoding, final Range range) {
+        if (text == null || text.isEmpty()) {
+            return;
+        }
+        for (Token token : GroovySourceTokens.tokenize(text)) {
+            if (token != null) {
+                int type = lexicalType(token);
+                if (type >= 0) {
+                    addLexerSpans(text, tokens, encoding, range, token, type);
+                }
+            }
+        }
+    }
+
+    private static int lexicalType(final Token token) {
+        int type = token.getType();
+        if (type == GroovyLexer.StringLiteral || type == GroovyLexer.GStringBegin
+                || type == GroovyLexer.GStringPart || type == GroovyLexer.GStringEnd) {
+            return TOKEN_STRING;
+        }
+        if (type == GroovyLexer.IntegerLiteral || type == GroovyLexer.FloatingPointLiteral) {
+            return TOKEN_NUMBER;
+        }
+        if (type == GroovyLexer.NL) {
+            String body = token.getText();
+            if (body != null && (body.startsWith("//") || body.startsWith("/*"))) {
+                return TOKEN_COMMENT;
+            }
+            return -1;
+        }
+        if (type == GroovyLexer.NOT_IN || type == GroovyLexer.NOT_INSTANCEOF
+                || GroovyKeywords.isKeyword(token.getText())) {
+            return TOKEN_KEYWORD;
+        }
+        return -1;
+    }
+
+    private static void addLexerSpans(final String text, final List<int[]> tokens, final PositionEncoding encoding,
+                                      final Range range, final Token token, final int type) {
+        String tokenText = token.getText();
+        if (tokenText == null || tokenText.isEmpty()) {
+            return;
+        }
+        int line = token.getLine() - 1;
+        int utf16Col = token.getCharPositionInLine();
+        String[] parts = tokenText.split("\n", -1);
+        for (int i = 0; i < parts.length; i++) {
+            String part = parts[i];
+            boolean trailingEmpty = part.isEmpty() && i == parts.length - 1 && tokenText.endsWith("\n");
+            int lineI = line + i;
+            if (!trailingEmpty && lineInRange(range, lineI)) {
+                String row = Positions.lineText(text, lineI + 1);
+                int startUtf16 = i == 0 ? utf16Col : 0;
+                int endUtf16 = startUtf16 + part.length();
+                int startChar = Positions.groovyColumnToCharacter(row,
+                        Positions.characterToGroovyColumn(row, startUtf16, PositionEncoding.UTF16), encoding);
+                int endChar = Positions.groovyColumnToCharacter(row,
+                        Positions.characterToGroovyColumn(row, endUtf16, PositionEncoding.UTF16), encoding);
+                tokens.add(new int[]{lineI, startChar, Math.max(endChar - startChar, 1), type, 0});
+            }
+        }
+    }
+
+    private static void addTypeHint(final List<InlayHint> hints, final AnnotatedNode node, final ClassNode type,
+                                    final String text, final PositionEncoding encoding, final Range range,
+                                    final boolean inferred) {
+        if (!SymbolIdentity.isResolvedType(type)) {
+            return;
+        }
+        Range nameRange = Positions.toNameRange(node, text, encoding);
+        if (nameRange == null || (range != null && nameRange.getStart().getLine() < range.getStart().getLine())) {
+            return;
+        }
+        InlayHint hint = new InlayHint(nameRange.getStart(),
+                Either.forLeft(type.getNameWithoutPackage() + " "));
+        hint.setKind(InlayHintKind.Type);
+        hint.setPaddingRight(true);
+        if (inferred) {
+            hint.setTooltip("inferred");
+        }
+        hints.add(hint);
+    }
+
+    private static void addCallSiteHints(final List<InlayHint> hints, final CompiledDocument document,
+                                         final Range range, final PositionEncoding encoding,
+                                         final CompilationSnapshot snapshot) {
+        if (document.getModule() == null) {
+            return;
+        }
+        AstQuery.walk(document.getModule(), (node, ctx) -> {
+            if (node instanceof MethodCallExpression call) {
+                MethodNode method = NavigationService.resolveMethod(call, snapshot);
+                addParameterNameHints(hints, document, range, encoding, call.getArguments(),
+                        method == null ? null : method.getParameters(), call.getMethodTarget() != null);
+            } else if (node instanceof StaticMethodCallExpression call) {
+                MethodNode method = NavigationService.resolveStatic(call, snapshot);
+                addParameterNameHints(hints, document, range, encoding, call.getArguments(),
+                        method == null ? null : method.getParameters(), false);
+            } else if (node instanceof ConstructorCallExpression ctor) {
+                ConstructorNode constructor = NavigationService.resolveConstructor(ctor);
+                addParameterNameHints(hints, document, range, encoding, ctor.getArguments(),
+                        constructor == null ? null : constructor.getParameters(), false);
+            }
+        });
+    }
+
+    private static void addParameterNameHints(final List<InlayHint> hints, final CompiledDocument document,
+                                              final Range range, final PositionEncoding encoding,
+                                              final Expression arguments, final Parameter[] parameters,
+                                              final boolean bound) {
+        if (parameters == null || hasNamedArgs(arguments)) {
+            return;
+        }
+        List<Expression> args = argumentExpressions(arguments);
+        int count = Math.min(parameters.length, args.size());
+        for (int i = 0; i < count; i++) {
+            Parameter parameter = parameters[i];
+            Expression arg = args.get(i);
+            Range argRange = Positions.toRange(arg, document.getText(), encoding);
+            if (skipParameterHint(parameter, arg, argRange, range)) {
+                continue;
+            }
+            InlayHint hint = new InlayHint(argRange.getStart(), Either.forLeft(parameter.getName() + ":"));
+            hint.setKind(InlayHintKind.Parameter);
+            hint.setPaddingRight(true);
+            if (!bound) {
+                hint.setTooltip("inferred");
+            }
+            hints.add(hint);
+        }
+    }
+
+    private static boolean skipParameterHint(final Parameter parameter, final Expression arg, final Range argRange,
+                                             final Range range) {
+        if (parameter == null || parameter.isSynthetic() || parameter.getName() == null
+                || parameter.getName().startsWith("$")) {
+            return true;
+        }
+        if (arg instanceof VariableExpression variable && parameter.getName().equals(variable.getName())) {
+            return true;
+        }
+        return argRange == null || (range != null && !overlaps(argRange, range));
+    }
+
+    private static boolean hasNamedArgs(final Expression arguments) {
+        for (Expression arg : argumentExpressions(arguments)) {
+            if (arg instanceof MapExpression) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static List<Expression> argumentExpressions(final Expression arguments) {
+        if (arguments instanceof TupleExpression tuple) {
+            return tuple.getExpressions();
+        }
+        if (arguments == null) {
+            return List.of();
+        }
+        return List.of(arguments);
+    }
+
+    public List<Either<Command, CodeAction>> codeActions(final TextDocument document,
+                                                         final CompilationSnapshot snapshot,
+                                                         final List<Diagnostic> diagnostics,
+                                                         final Range range, final PositionEncoding encoding,
+                                                         final Collection<URI> folders,
+                                                         final List<String> sourcePaths) {
+        List<Either<Command, CodeAction>> actions = new ArrayList<>();
+        if (document == null) {
+            return actions;
+        }
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        CodeAction organize = new CodeAction("Organize imports");
+        organize.setKind(CodeActionKind.SourceOrganizeImports);
+        List<TextEdit> organized = organizeImports(compiled, encoding);
+        if (!organized.isEmpty()) {
+            organize.setEdit(new WorkspaceEdit(Map.of(document.getUri().toString(), organized)));
+        }
+        organize.setCommand(new Command("Organize imports", "groovy.lsp.organizeImports",
+                List.of(document.getUri().toString())));
+        actions.add(Either.forRight(organize));
+        if (diagnostics != null) {
+            for (Diagnostic diagnostic : diagnostics) {
+                String message = diagnosticMessage(diagnostic);
+                String className = unresolvedClassName(message);
+                if (className == null) {
+                    continue;
+                }
+                for (TypeIndex.TypeHit hit : importCandidates(className, snapshot)) {
+                    List<TextEdit> edits = ImportSupport.addImport(compiled, hit.name(), encoding);
+                    if (edits.isEmpty()) {
+                        continue;
+                    }
+                    CodeAction action = new CodeAction("Add import for " + hit.name());
+                    action.setKind(CodeActionKind.QuickFix);
+                    action.setDiagnostics(List.of(diagnostic));
+                    action.setEdit(new WorkspaceEdit(Map.of(document.getUri().toString(), edits)));
+                    actions.add(Either.forRight(action));
+                }
+            }
+        }
+        addUnambiguousImports(actions, compiled, snapshot, diagnostics, encoding);
+        actions.addAll(codeActions.contribute(document, snapshot, diagnostics, range, encoding, folders, sourcePaths));
+        return actions;
+    }
+
+    public List<CodeLens> codeLenses(final CompiledDocument document, final CompilationSnapshot snapshot,
+                                     final PositionEncoding encoding) {
+        List<CodeLens> lenses = new ArrayList<>();
+        if (document == null || document.getModule() == null || snapshot == null) {
+            return lenses;
+        }
+        TextDocument open = document.toTextDocument();
+        NavigationService navigation = new NavigationService();
+        for (ASTNode node : AstQuery.declarations(document.getModule())) {
+            addCodeLens(lenses, open, document, snapshot, encoding, navigation, node);
+        }
+        return lenses;
+    }
+
+    public List<SelectionRange> selectionRanges(final TextDocument document, final CompilationSnapshot snapshot,
+                                                final List<Position> positions, final PositionEncoding encoding) {
+        List<SelectionRange> ranges = new ArrayList<>();
+        CompiledDocument compiled = snapshot == null ? null : snapshot.get(document);
+        ModuleNode module = compiled == null ? null : compiled.getModule();
+        for (Position position : positions) {
+            SelectionRange current = null;
+            if (module != null) {
+                List<ASTNode> path = AstQuery.containing(module, document, position, encoding);
+                for (int i = path.size() - 1; i >= 0; i--) {
+                    Range range = Positions.toRange(path.get(i), document.getText(), encoding);
+                    if (range != null) {
+                        current = new SelectionRange(range, current);
+                    }
+                }
+            }
+            if (current == null) {
+                current = new SelectionRange(new Range(position, position), null);
+            }
+            ranges.add(current);
+        }
+        return ranges;
+    }
+
+    public List<DocumentLink> documentLinks(final CompiledDocument document, final CompilationSnapshot snapshot,
+                                            final PositionEncoding encoding) {
+        List<DocumentLink> links = new ArrayList<>();
+        if (document == null || document.getModule() == null) {
+            return links;
+        }
+        for (ImportNode imp : ImportSupport.allImports(document.getModule())) {
+            addDocumentLink(links, document, snapshot, encoding, imp);
+        }
+        return links;
+    }
+
+    private static void addDocumentLink(final List<DocumentLink> links, final CompiledDocument document,
+                                        final CompilationSnapshot snapshot, final PositionEncoding encoding,
+                                        final ImportNode imp) {
+        Range range = Positions.toRange(imp, document.getText(), encoding);
+        if (range == null) {
+            return;
+        }
+        String target = importTarget(imp, document, snapshot);
+        if (target != null) {
+            links.add(new DocumentLink(range, target));
+        }
+    }
+
+    public List<TextEdit> organizeImports(final CompiledDocument document, final PositionEncoding encoding) {
+        if (document == null || document.getModule() == null) {
+            return List.of();
+        }
+        List<ImportNode> imports = ImportSupport.allImports(document.getModule());
+        if (imports.isEmpty()) {
+            return List.of();
+        }
+        List<String> names = new ArrayList<>();
+        Range first = null;
+        Range last = null;
+        for (ImportNode imp : imports) {
+            names.add(imp.getText());
+            Range span = Positions.toRange(imp, document.getText(), encoding);
+            if (span != null) {
+                first = earlier(first, span);
+                last = later(last, span);
+            }
+        }
+        names.sort(importOrder());
+        if (first == null) {
+            return List.of();
+        }
+        String replacement = String.join("\n", names);
+        String current = slice(document.getText(), first, last);
+        if (replacement.equals(current)) {
+            return List.of();
+        }
+        return List.of(new TextEdit(new Range(first.getStart(), last.getEnd()), replacement));
+    }
+
+    private static Range earlier(final Range current, final Range candidate) {
+        if (current == null || comparePosition(candidate.getStart(), current.getStart()) < 0) {
+            return candidate;
+        }
+        return current;
+    }
+
+    private static Range later(final Range current, final Range candidate) {
+        if (current == null || comparePosition(candidate.getEnd(), current.getEnd()) > 0) {
+            return candidate;
+        }
+        return current;
+    }
+
+    private static void addCommentFolds(final String text, final List<FoldingRange> ranges) {
+        int blockStart = -1;
+        String[] lines = text.split("\n", -1);
+        for (int i = 0; i < lines.length; i++) {
+            String stripped = lines[i].strip();
+            if (stripped.startsWith("/*") && blockStart < 0) {
+                blockStart = i;
+            }
+            if (stripped.contains("*/") && blockStart >= 0) {
+                if (i > blockStart) {
+                    FoldingRange range = new FoldingRange(blockStart, i);
+                    range.setKind(FoldingRangeKind.Comment);
+                    ranges.add(range);
+                }
+                blockStart = -1;
+            }
+        }
+    }
+
+    private static void addRegionFolds(final String text, final List<FoldingRange> ranges) {
+        if (text == null || text.isEmpty()) {
+            return;
+        }
+        List<Integer> open = new ArrayList<>();
+        for (Token token : GroovySourceTokens.tokenize(text)) {
+            String directive = regionDirective(token);
+            if (directive != null) {
+                int line = token.getLine() - 1;
+                if (REGION.equals(directive)) {
+                    open.add(line);
+                } else if (ENDREGION.equals(directive) && !open.isEmpty()) {
+                    int start = open.remove(open.size() - 1);
+                    if (line > start) {
+                        FoldingRange range = new FoldingRange(start, line);
+                        range.setKind(FoldingRangeKind.Region);
+                        ranges.add(range);
+                    }
+                }
+            }
+        }
+    }
+
+    private static String regionDirective(final Token token) {
+        if (token == null || token.getType() != GroovyLexer.NL || token.getText() == null
+                || !token.getText().startsWith("//")) {
+            return null;
+        }
+        String body = token.getText().substring(2).strip();
+        if (body.startsWith("#")) {
+            body = body.substring(1).strip();
+        }
+        if (body.startsWith(REGION)) {
+            return REGION;
+        }
+        if (body.startsWith(ENDREGION)) {
+            return ENDREGION;
+        }
+        return null;
+    }
+
+    private static Set<Integer> frozenIndentLines(final String text) {
+        Set<Integer> frozen = new HashSet<>();
+        for (Token token : GroovySourceTokens.tokenize(text)) {
+            String body = token.getText();
+            if (!multilineStringOrComment(token, body)) {
+                continue;
+            }
+            int startLine = token.getLine() - 1;
+            int extra = body.split("\n", -1).length - 1;
+            for (int i = 1; i <= extra; i++) {
+                frozen.add(startLine + i);
+            }
+        }
+        return frozen;
+    }
+
+    private static boolean multilineStringOrComment(final Token token, final String body) {
+        int type = lexicalType(token);
+        return (type == TOKEN_STRING || type == TOKEN_COMMENT) && body != null && body.contains("\n");
+    }
+
+    private static void dedupeSameSpan(final List<int[]> tokens) {
+        if (tokens.size() < 2) {
+            return;
+        }
+        List<int[]> kept = new ArrayList<>(tokens.size());
+        for (int[] token : tokens) {
+            if (!kept.isEmpty()) {
+                int[] last = kept.get(kept.size() - 1);
+                if (last[0] == token[0] && last[1] == token[1] && last[2] == token[2]) {
+                    if ((token[4] & MOD_DECLARATION) != 0 || last[4] == 0 && token[4] != 0) {
+                        kept.set(kept.size() - 1, token);
+                    }
+                    continue;
+                }
+            }
+            kept.add(token);
+        }
+        tokens.clear();
+        tokens.addAll(kept);
+    }
+
+    private static int activeParameter(final Expression arguments, final int line, final int column) {
+        if (!(arguments instanceof TupleExpression tuple) || tuple.getExpressions().isEmpty()) {
+            return 0;
+        }
+        List<Expression> exprs = tuple.getExpressions();
+        int active = 0;
+        for (int i = 0; i < exprs.size(); i++) {
+            if (caretAfter(exprs.get(i), line, column)) {
+                active = i + 1;
+            }
+        }
+        if (active >= exprs.size()) {
+            active = exprs.size() - 1;
+        }
+        return Math.max(active, 0);
+    }
+
+    private static boolean caretAfter(final Expression arg, final int line, final int column) {
+        if (arg == null || arg.getLineNumber() <= 0) {
+            return false;
+        }
+        int lastLine = arg.getLastLineNumber() > 0 ? arg.getLastLineNumber() : arg.getLineNumber();
+        int lastColumn = arg.getLastColumnNumber() > 0 ? arg.getLastColumnNumber() : arg.getColumnNumber();
+        return line > lastLine || (line == lastLine && column > lastColumn);
+    }
+
+    private static boolean lineInRange(final Range range, final int line) {
+        if (range == null || range.getStart() == null || range.getEnd() == null) {
+            return true;
+        }
+        return line >= range.getStart().getLine() && line <= range.getEnd().getLine();
+    }
+
+    private static boolean overlaps(final Range token, final Range range) {
+        if (range == null || token == null) {
+            return true;
+        }
+        return token.getEnd().getLine() >= range.getStart().getLine()
+                && token.getStart().getLine() <= range.getEnd().getLine();
+    }
+
+    private static SignatureInformation signatureInformation(final MethodNode method) {
+        StringBuilder label = new StringBuilder(method.getName()).append('(');
+        List<ParameterInformation> infos = new ArrayList<>();
+        Parameter[] parameters = method.getParameters();
+        for (int i = 0; i < parameters.length; i++) {
+            if (i > 0) {
+                label.append(", ");
+            }
+            String part = parameters[i].getType().getNameWithoutPackage() + " " + parameters[i].getName();
+            label.append(part);
+            infos.add(new ParameterInformation(part));
+        }
+        label.append(')');
+        SignatureInformation information = new SignatureInformation(label.toString());
+        information.setParameters(infos);
+        return information;
+    }
+
+    private static void addUnambiguousImports(final List<Either<Command, CodeAction>> actions,
+                                              final CompiledDocument compiled, final CompilationSnapshot snapshot,
+                                              final List<Diagnostic> diagnostics, final PositionEncoding encoding) {
+        if (compiled == null || diagnostics == null || diagnostics.isEmpty()) {
+            return;
+        }
+        List<TextEdit> edits = new ArrayList<>();
+        LinkedHashSet<String> seen = new LinkedHashSet<>();
+        for (Diagnostic diagnostic : diagnostics) {
+            TypeIndex.TypeHit hit = uniqueUnresolved(diagnostic, snapshot, seen);
+            if (hit == null) {
+                continue;
+            }
+            edits.addAll(ImportSupport.addImport(compiled, hit.name(), encoding));
+        }
+        if (edits.size() < 2) {
+            return;
+        }
+        CodeAction action = new CodeAction("Add all unambiguous imports");
+        action.setKind(CodeActionKind.Source);
+        action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(), edits)));
+        actions.add(Either.forRight(action));
+    }
+
+    private static TypeIndex.TypeHit uniqueUnresolved(final Diagnostic diagnostic, final CompilationSnapshot snapshot,
+                                                      final Set<String> seen) {
+        String className = unresolvedClassName(diagnosticMessage(diagnostic));
+        if (className == null || className.contains(".")) {
+            return null;
+        }
+        List<TypeIndex.TypeHit> hits = snapshot == null ? List.of() : snapshot.types().bySimpleName(className);
+        if (hits.size() != 1 || !seen.add(hits.get(0).name())) {
+            return null;
+        }
+        return hits.get(0);
+    }
+
+    private static void addImportFolds(final ModuleNode module, final List<FoldingRange> ranges) {
+        List<ImportNode> imports = ImportSupport.allImports(module);
+        int start = Integer.MAX_VALUE;
+        int end = -1;
+        for (ImportNode imp : imports) {
+            if (imp == null || imp.getLineNumber() <= 0) {
+                continue;
+            }
+            start = Math.min(start, imp.getLineNumber() - 1);
+            end = Math.max(end, Math.max(imp.getLastLineNumber(), imp.getLineNumber()) - 1);
+        }
+        if (end > start) {
+            FoldingRange range = new FoldingRange(start, end);
+            range.setKind(FoldingRangeKind.Imports);
+            ranges.add(range);
+        }
+    }
+
+    private static List<TypeIndex.TypeHit> importCandidates(final String className, final CompilationSnapshot snapshot) {
+        if (snapshot == null || className == null) {
+            return List.of();
+        }
+        if (className.contains(".")) {
+            TypeIndex.TypeHit hit = snapshot.types().byName(className);
+            return List.of(hit == null ? new TypeIndex.TypeHit(className, className, null, false, false, "") : hit);
+        }
+        List<TypeIndex.TypeHit> hits = snapshot.types().bySimpleName(className);
+        return hits.size() > 5 ? hits.subList(0, 5) : hits;
+    }
+
+    private static Comparator<String> importOrder() {
+        return (left, right) -> {
+            boolean leftStatic = left.startsWith("import static");
+            boolean rightStatic = right.startsWith("import static");
+            if (leftStatic != rightStatic) {
+                return leftStatic ? 1 : -1;
+            }
+            return String.CASE_INSENSITIVE_ORDER.compare(left, right);
+        };
+    }
+
+    private static int comparePosition(final Position left, final Position right) {
+        if (left.getLine() != right.getLine()) {
+            return Integer.compare(left.getLine(), right.getLine());
+        }
+        return Integer.compare(left.getCharacter(), right.getCharacter());
+    }
+
+    private static String slice(final String text, final Range first, final Range last) {
+        String[] lines = text.split("\n", -1);
+        int startLine = first.getStart().getLine();
+        int endLine = last.getEnd().getLine();
+        if (startLine < 0 || endLine >= lines.length || startLine > endLine) {
+            return "";
+        }
+        StringBuilder slice = new StringBuilder();
+        for (int line = startLine; line <= endLine; line++) {
+            String row = lines[line];
+            int from = line == startLine ? Math.min(Math.max(first.getStart().getCharacter(), 0), row.length()) : 0;
+            int to = line == endLine ? Math.min(Math.max(last.getEnd().getCharacter(), 0), row.length()) : row.length();
+            if (from > to) {
+                from = to;
+            }
+            slice.append(line > startLine ? "\n" : "").append(row, from, to);
+        }
+        return slice.toString();
+    }
+
+    private static Expression argumentsOf(final ASTNode node) {
+        if (node instanceof MethodCallExpression call) {
+            return call.getArguments();
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return call.getArguments();
+        }
+        if (node instanceof ConstructorCallExpression ctor) {
+            return ctor.getArguments();
+        }
+        return null;
+    }
+
+    static String unresolvedClassName(final String message) {
+        if (message == null) {
+            return null;
+        }
+        Matcher matcher = UNRESOLVED_CLASS.matcher(message);
+        return matcher.find() ? matcher.group(1) : null;
+    }
+
+    private static String importTarget(final ImportNode imp, final CompiledDocument document,
+                                       final CompilationSnapshot snapshot) {
+        String className = imp.getClassName();
+        if (className == null) {
+            return null;
+        }
+        String workspace = workspaceTypeUri(className, snapshot);
+        if (workspace != null) {
+            return workspace;
+        }
+        if (document.getUri() == null || !"file".equalsIgnoreCase(document.getUri().getScheme())) {
+            return null;
+        }
+        Path parent = Uris.toPath(document.getUri()).getParent();
+        if (parent == null) {
+            return null;
+        }
+        return Uris.normalize(parent.resolve(className.replace('.', '/') + ".groovy").toUri()).toString();
+    }
+
+    private static String workspaceTypeUri(final String className, final CompilationSnapshot snapshot) {
+        if (snapshot == null) {
+            return null;
+        }
+        for (CompiledDocument other : snapshot.documents()) {
+            if (other.getModule() == null) {
+                continue;
+            }
+            for (ClassNode classNode : other.getModule().getClasses()) {
+                if (className.equals(classNode.getName())) {
+                    return other.getUri().toString();
+                }
+            }
+        }
+        return null;
+    }
+
+    private static final int TOKEN_NAMESPACE = 0;
+    private static final int TOKEN_CLASS = 1;
+    private static final int TOKEN_ENUM = 2;
+    private static final int TOKEN_INTERFACE = 3;
+    private static final int TOKEN_METHOD = 4;
+    private static final int TOKEN_PROPERTY = 5;
+    private static final int TOKEN_VARIABLE = 6;
+    private static final int TOKEN_PARAMETER = 7;
+    private static final int TOKEN_KEYWORD = 8;
+    private static final int TOKEN_STRING = 9;
+    private static final int TOKEN_NUMBER = 10;
+    private static final int TOKEN_COMMENT = 11;
+    private static final String REGION = "region";
+    private static final String ENDREGION = "endregion";
+    private static final int MOD_DECLARATION = 1;
+    private static final int MOD_STATIC = 2;
+    private static final int MOD_DEPRECATED = 4;
+
+    private static int tokenType(final ASTNode node) {
+        if (node instanceof PackageNode || node instanceof ImportNode) {
+            return TOKEN_NAMESPACE;
+        }
+        if (node instanceof ClassNode classNode) {
+            if (classNode.isInterface()) {
+                return TOKEN_INTERFACE;
+            }
+            if (classNode.isEnum()) {
+                return TOKEN_ENUM;
+            }
+            return TOKEN_CLASS;
+        }
+        if (node instanceof MethodNode) {
+            return TOKEN_METHOD;
+        }
+        if (node instanceof PropertyNode || node instanceof FieldNode) {
+            return TOKEN_PROPERTY;
+        }
+        if (node instanceof Parameter) {
+            return TOKEN_PARAMETER;
+        }
+        return TOKEN_VARIABLE;
+    }
+
+    private static int tokenModifiers(final ASTNode node) {
+        int bits = MOD_DECLARATION;
+        if (isStaticMember(node)) {
+            bits |= MOD_STATIC;
+        }
+        if (node instanceof AnnotatedNode annotated && isDeprecated(annotated)) {
+            bits |= MOD_DEPRECATED;
+        }
+        return bits;
+    }
+
+    private static boolean isStaticMember(final ASTNode node) {
+        if (node instanceof MethodNode method) {
+            return method.isStatic();
+        }
+        if (node instanceof FieldNode field) {
+            return field.isStatic();
+        }
+        return node instanceof PropertyNode property && property.isStatic();
+    }
+
+    private static boolean isDeprecated(final AnnotatedNode node) {
+        if (node.getAnnotations() == null) {
+            return false;
+        }
+        for (AnnotationNode annotation : node.getAnnotations()) {
+            ClassNode type = annotation.getClassNode();
+            if (type != null && "java.lang.Deprecated".equals(type.getName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    static String diagnosticMessage(final Diagnostic diagnostic) {
+        if (diagnostic == null || diagnostic.getMessage() == null) {
+            return null;
+        }
+        var message = diagnostic.getMessage();
+        if (message.isLeft()) {
+            return message.getLeft();
+        }
+        if (message.isRight()) {
+            return message.getRight().getValue();
+        }
+        return message.toString();
+    }
+
+    private static String rtrim(final String line) {
+        int end = line.length();
+        while (end > 0 && Character.isWhitespace(line.charAt(end - 1))) {
+            end -= 1;
+        }
+        return line.substring(0, end);
+    }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolIdentity.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolIdentity.java
new file mode 100644
index 0000000000..7ddde5af96
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolIdentity.java
@@ -0,0 +1,277 @@
+/*
+ *  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.apache.groovy.lsp.internal.compile.CompilationSnapshot;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.Variable;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.query.AstContext;
+
+/**
+ * Declaration identity for rename, references and highlight. Matching uses
+ * the resolved binding (or receiver type + name), not the unqualified name
+ * alone, so two methods both called {@code foo} stay distinct.
+ */
+final class SymbolIdentity {
+
+    enum Kind {
+        TYPE, METHOD, FIELD, VARIABLE
+    }
+
+    private final Kind kind;
+    private final String key;
+    private final ASTNode declaration;
+    private final String name;
+
+    private SymbolIdentity(final Kind kind, final String key, final ASTNode declaration, final String name) {
+        this.kind = kind;
+        this.key = key;
+        this.declaration = declaration;
+        this.name = name;
+    }
+
+    static SymbolIdentity of(final ASTNode node, final CompilationSnapshot snapshot) {
+        ASTNode declaration = declarationOf(node, snapshot);
+        if (declaration == null) {
+            return null;
+        }
+        String key = keyOf(declaration);
+        if (key == null) {
+            return null;
+        }
+        return new SymbolIdentity(kindOf(declaration), key, declaration, NavigationService.nameOf(declaration));
+    }
+
+    ASTNode declaration() {
+        return declaration;
+    }
+
+    String name() {
+        return name;
+    }
+
+    Kind kind() {
+        return kind;
+    }
+
+    boolean isDeclaration(final ASTNode node) {
+        return node == declaration || (node != null && key.equals(keyOf(node)));
+    }
+
+    boolean refersTo(final ASTNode node, final AstContext context) {
+        if (node == null) {
+            return false;
+        }
+        if (node == declaration) {
+            return true;
+        }
+        return switch (kind) {
+            case TYPE -> refersToType(node);
+            case METHOD -> refersToMethod(node, context);
+            case FIELD -> refersToField(node);
+            case VARIABLE -> refersToVariable(node);
+        };
+    }
+
+    static ASTNode declarationOf(final ASTNode node, final CompilationSnapshot snapshot) {
+        if (node instanceof VariableExpression variable) {
+            return declarationOfVariable(variable);
+        }
+        if (node instanceof MethodCallExpression call) {
+            return NavigationService.resolveMethod(call, snapshot);
+        }
+        if (node instanceof StaticMethodCallExpression call) {
+            return NavigationService.resolveStatic(call, snapshot);
+        }
+        if (node instanceof PropertyExpression property) {
+            return NavigationService.resolveProperty(property);
+        }
+        if (node instanceof ClassExpression classExpression && classExpression.getType() != null) {
+            ClassNode type = classExpression.getType();
+            return type.getLineNumber() > 0 ? type : classExpression;
+        }
+        if (node instanceof ConstructorCallExpression ctor && ctor.getType() != null) {
+            return ctor.getType();
+        }
+        return node;
+    }
+
+    private static ASTNode declarationOfVariable(final VariableExpression variable) {
+        Variable accessed = variable.getAccessedVariable();
+        if (accessed instanceof ASTNode ast && ast.getLineNumber() > 0) {
+            return ast;
+        }
+        return variable;
+    }
+
+    static String keyOf(final ASTNode node) {
+        if (node instanceof MethodNode method) {
+            String owner = ownerName(method.getDeclaringClass());
+            String descriptor = method.getTypeDescriptor() == null ? "" : method.getTypeDescriptor();
+            return "M:" + owner + "#" + method.getName() + descriptor;
+        }
+        if (node instanceof ClassNode classNode) {
+            return "C:" + classNode.getName();
+        }
... 7975 lines suppressed ...


(groovy) 02/02: GROOVY-12411: Fold STC into groovy-lsp core and drop the public SPI

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-12411
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 85293321e62c3d5cc9465bd700fac23ee8032ac5
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Sep 26 20:56:43 2026 +0900

    GROOVY-12411: Fold STC into groovy-lsp core and drop the public SPI
    
    The only in-tree consumer of GroovyLspExtension was the static type
    checking pass. Run that after SEMANTIC_ANALYSIS as core behaviour.
    Client settings go through workspace/didChangeConfiguration and an
    asynchronous workspace/configuration pull.
---
 subprojects/groovy-lsp/AGENTS.md                   |  15 +-
 subprojects/groovy-lsp/ARCHITECTURE.md             |  72 +++---
 .../apache/groovy/lsp/GroovyLanguageServer.java    |   4 +-
 .../apache/groovy/lsp/internal/ExtensionHost.java  | 219 ------------------
 .../groovy/lsp/internal/LanguageServerContext.java |  52 ++---
 .../lsp/internal/compile/CompilerSettings.java     |  73 +++++-
 .../lsp/internal/compile/GroovyCompiler.java       |  44 +---
 .../groovy/lsp/internal/feature/TypeInference.java |   2 +-
 ...gExtension.java => StaticTypeCheckingPass.java} |  38 ++--
 .../lsp/internal/protocol/ClientFeatures.java      |   5 +
 .../protocol/GroovyTextDocumentService.java        |   3 +-
 .../internal/protocol/GroovyWorkspaceService.java  |  97 ++++----
 .../internal/protocol/ServerCapabilityFactory.java |  29 +--
 .../apache/groovy/lsp/spi/GroovyLspExtension.java  | 184 ---------------
 .../apache/groovy/lsp/spi/GroovyLspSession.java    | 112 ---------
 .../org/apache/groovy/lsp/spi/package-info.java    |  33 ---
 .../org.apache.groovy.lsp.spi.GroovyLspExtension   |  19 --
 .../groovy-lsp/src/spec/doc/groovy-lsp.adoc        |  50 ++---
 .../groovy/lsp/internal/HelperCoverageTest.groovy  | 147 ++++++------
 .../apache/groovy/lsp/internal/LspFixture.groovy   |  27 +++
 .../intelligence/StaticTypeCheckingPassTest.groovy | 232 +++++++++++++++++++
 .../internal/protocol/ProtocolCoverageTest.groovy  |  15 +-
 .../lsp/internal/spi/GroovyLspExtensionTest.groovy | 250 ---------------------
 23 files changed, 588 insertions(+), 1134 deletions(-)

diff --git a/subprojects/groovy-lsp/AGENTS.md b/subprojects/groovy-lsp/AGENTS.md
index 30b51d7317..a9316b13df 100644
--- a/subprojects/groovy-lsp/AGENTS.md
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -30,16 +30,11 @@ Rename of a method *call* is allowed only when `methodTarget` is
 set. Do not add an identifier-text fallback (that is how
 `groovy-language-server-edt`'s AST path rewrites the wrong `foo`).
 
-New Groovy-specific behaviour that is not core language support
-belongs in a `GroovyLspExtension`, not a fork of
-`LanguageServerContext`. Do not add OSGi, extra JSON-RPC methods, or
-a second hover/completion SPI — `configure` / `initialized` /
-`afterCompile` / extra diagnostics (including `ModuleNode`) / extra
-code actions / commands are the extension valves. Inject members in
-`afterCompile` so core hover and completion see them. The SPI must
-not take `internal` types; plugins see `GroovyLspSession` (document
-text, folders, classpath, extra settings, apply-edit) and groovy-core
-AST (`CompilationUnit`, `ModuleNode`).
+New Groovy-specific behaviour that is not protocol adaptation belongs
+in `feature/*` or `compile/*`, not a public SPI. Configuration is
+`workspace/didChangeConfiguration` and `workspace/configuration`
+under the `groovy` section (see `groovy-lsp.adoc`). Do not add OSGi,
+extra JSON-RPC methods, or a second hover/completion SPI.
 
 Do not add language-server fields or visitor special cases to
 groovy-core (`AnnotatedNode` name spans, `AstQuery` parameter walks).
diff --git a/subprojects/groovy-lsp/ARCHITECTURE.md b/subprojects/groovy-lsp/ARCHITECTURE.md
index 2e18402ea4..bbdcedc7b8 100644
--- a/subprojects/groovy-lsp/ARCHITECTURE.md
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -44,22 +44,21 @@ A **Groovy language core** that editors and AI tools can share:
   conservative rename on `.groovy` sources.
 - AI agents get the same snapshot: structure, types where the compiler
   knows them, and edits that refuse to guess. A later `groovy-mcp`
-  module is expected to wrap this server so a model can ask “what is
-  this identifier?” without re-implementing the compiler.
+  module is expected to wrap the same in-process engine so a model can
+  ask “what is this identifier?” without re-implementing the compiler.
 
 It is intended for **direct use by an IDE or editor as an LSP
-server**, as the **semantic backend** for Groovy-aware AI, and as the
-**Groovy-AST compile + protocol loop** other Apache-2.0 Groovy LSPs
-can sit on. Plugins implement incubating
-[`GroovyLspExtension`](src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java)
-(`ServiceLoader` or tests register an `ExtensionHost`). That is how
-Jenkins GDSL, a forced STC pass, or `groovy-mcp` add behaviour
-without forking `LanguageServerContext`.
-
-It is not a complete IDE. Eclipse JDT / groovy-eclipse servers
-cannot replace this compiler through the SPI — they remain a
-parallel stack. GDSL and Maven downloads stay in the *plugin*, not
-in this module.
+server**, as the **semantic backend** for Groovy-aware AI, and as an
+**in-process language-services library**. `feature/*` and `compile/*`
+answer questions about sources; `protocol/*` is the LSP adapter.
+Console, groovysh, a notebook kernel, or an MCP process can pull
+answers from that library. There is no public extension SPI in this
+first cut.
+
+It is not a complete IDE. Eclipse JDT / groovy-eclipse remain a
+parallel stack. GDSL, Maven downloads, and a forced-STC toggle are
+out of scope here; the last of those would be a configuration key,
+not an extension.
 
 ### What it is not
 
@@ -186,30 +185,27 @@ call. Wrong edits are worse for an agent than a refused rename.
   line. Compile reports `window/workDoneProgress` when the client
   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 extension ids skips a full recompile when nothing
-  changed. 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` (and friends) plus `lib/*.jar` — it never invokes
-  Gradle or Maven.
-- **Extensions.** `GroovyLspExtension` may `configure` the
-  `CompilerConfiguration` (with a `GroovyLspSession` so extra
-  `groovy.*` settings and folders are visible), run `initialized` /
-  `shutdown`, run `afterCompile` on the live `CompilationUnit`
-  (before the snapshot freezes), add diagnostics (URI + text +
-  optional `ModuleNode`) and code actions (with source text), and
-  handle extra commands through `GroovyLspSession`. The session is
-  apply-edit plus document text, folders, classpath, source paths,
-  extra settings and position encoding — not a compile handle. A
-  throwing plugin is isolated (`id()` / `commands()` included); it
-  must not compile or publish on its own. Hover and completion stay
-  in core: inject AST in `afterCompile`. The server ships a default
-  `groovy.lsp.stc` extension that runs `StaticTypeCheckingVisitor` on
-  `@TypeChecked` / `@CompileStatic` members only and copies
-  `DIRECT_METHOD_CALL_TARGET` onto `methodTarget`. `TypeInference`
-  also reads `StaticTypesMarker.INFERRED_TYPE` when a plugin wrote
-  it.
+- **Compile cache.** A fingerprint of open buffers, extra-file mtimes
+  and settings skips a full recompile when nothing changed. 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`
+  (and friends) plus `lib/*.jar` — it never invokes Gradle or Maven.
+- **Static type checking.** After `SEMANTIC_ANALYSIS`, a core
+  `StaticTypeCheckingPass` runs `StaticTypeCheckingVisitor` on
+  `@TypeChecked` / `@CompileStatic` members only (not
+  `TypeCheckingMode.SKIP`) and copies `DIRECT_METHOD_CALL_TARGET`
+  onto `methodTarget`. Dynamic Groovy is left alone. `TypeInference`
+  also reads `StaticTypesMarker.INFERRED_TYPE` when that pass wrote
+  it. A throwing class is isolated so the snapshot still publishes.
+- **Configuration.** `workspace/didChangeConfiguration` applies the
+  `groovy` map (or the root map). After `initialized`, if the client
+  advertised `workspace.configuration`, the server also pulls section
+  `groovy` via `workspace/configuration` without blocking the JSON-RPC
+  reader. Keys: `classpath`,
+  `sourcePaths`, `grapeEnabled`, `astTestEnabled`, plus opaque extra
+  `groovy.*` strings that force a recompile and rebuild the class
+  loader. There is no ServiceLoader order.
 - **Semantic tokens.** The advertised legend is append-only. Lexer
   tokens (`GroovySourceTokens`) fill `keyword` / `string` / `number` /
   `comment` even when the AST is missing. Declarations and use-site
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
index 7af63e49db..2f20a32289 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
@@ -90,8 +90,7 @@ public final class GroovyLanguageServer implements LanguageServer, LanguageClien
         } else if (params.getRootUri() != null) {
             context.addWorkspaceFolder(Uris.parse(params.getRootUri()));
         }
-        InitializeResult result = new InitializeResult(capabilities.create(
-                context.getPositionEncoding(), context.getExtensions().commands()));
+        InitializeResult result = new InitializeResult(capabilities.create(context.getPositionEncoding()));
         result.setServerInfo(new ServerInfo("groovy-lsp", GroovyLanguageServer.class.getPackage() == null
                 ? "dev" : String.valueOf(GroovyLanguageServer.class.getPackage().getImplementationVersion())));
         return CompletableFuture.completedFuture(result);
@@ -100,6 +99,7 @@ public final class GroovyLanguageServer implements LanguageServer, LanguageClien
     @Override
     public void initialized(final InitializedParams params) {
         context.setInitialized(true);
+        workspace.pullConfiguration();
         context.scheduleRecompile(0);
     }
 
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
deleted file mode 100644
index cf829331c3..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- *  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;
-
-import org.apache.groovy.lsp.internal.compile.CompiledDocument;
-import org.apache.groovy.lsp.internal.workspace.TextDocument;
-import org.apache.groovy.lsp.spi.GroovyLspExtension;
-import org.apache.groovy.lsp.spi.GroovyLspSession;
-import org.codehaus.groovy.control.CompilationUnit;
-import org.codehaus.groovy.control.CompilerConfiguration;
-import org.eclipse.lsp4j.CodeAction;
-import org.eclipse.lsp4j.Command;
-import org.eclipse.lsp4j.Diagnostic;
-import org.eclipse.lsp4j.Range;
-import org.eclipse.lsp4j.jsonrpc.messages.Either;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-import java.util.ServiceLoader;
-import java.util.function.Consumer;
-
-/**
- * Discovers and fans out {@link GroovyLspExtension} instances. One bad
- * plugin must not abort compile or a request.
- */
-public final class ExtensionHost {
-
-    private static final ExtensionHost NONE = new ExtensionHost(List.of());
-
-    private final List<GroovyLspExtension> extensions;
-
-    private ExtensionHost(final List<GroovyLspExtension> extensions) {
-        this.extensions = List.copyOf(extensions);
-    }
-
-    /**
-     * @return a host with no plugins
-     */
-    public static ExtensionHost none() {
-        return NONE;
-    }
-
-    /**
-     * @param extensions explicit plugins, typically from tests
-     * @return a host
-     */
-    public static ExtensionHost of(final GroovyLspExtension... extensions) {
-        if (extensions == null || extensions.length == 0) {
-            return NONE;
-        }
-        List<GroovyLspExtension> list = new ArrayList<>();
-        for (GroovyLspExtension extension : extensions) {
-            if (extension != null) {
-                list.add(extension);
-            }
-        }
-        return list.isEmpty() ? NONE : new ExtensionHost(list);
-    }
-
-    /**
-     * Loads {@link ServiceLoader} registrations from {@code loader}.
-     *
-     * @param loader class loader, or {@code null} for the SPI type's loader
-     * @return a host
-     */
-    public static ExtensionHost discover(final ClassLoader loader) {
-        ClassLoader use = loader == null ? GroovyLspExtension.class.getClassLoader() : loader;
-        List<GroovyLspExtension> found = new ArrayList<>();
-        for (GroovyLspExtension extension : ServiceLoader.load(GroovyLspExtension.class, use)) {
-            found.add(extension);
-        }
-        return found.isEmpty() ? NONE : new ExtensionHost(found);
-    }
-
-    /**
-     * @return plugin ids in registration order
-     */
-    public List<String> ids() {
-        List<String> ids = new ArrayList<>(extensions.size());
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                String id = extension.id();
-                ids.add(id == null || id.isBlank() ? extension.getClass().getName() : id);
-            } catch (RuntimeException ignored) {
-                ids.add(extension.getClass().getName());
-            }
-        }
-        return ids;
-    }
-
-    /**
-     * @return advertised extra execute-command names
-     */
-    public List<String> commands() {
-        List<String> commands = new ArrayList<>();
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                List<String> extra = extension.commands();
-                if (extra != null) {
-                    commands.addAll(extra);
-                }
-            } catch (RuntimeException ignored) {
-                // one plugin must not abort initialize
-            }
-        }
-        return commands;
-    }
-
-    /**
-     * @return the plugins
-     */
-    public List<GroovyLspExtension> all() {
-        return extensions;
-    }
-
-    public void configure(final CompilerConfiguration configuration) {
-        configure(configuration, (GroovyLspSession) null);
-    }
-
-    public void configure(final CompilerConfiguration configuration, final GroovyLspSession session) {
-        each(extension -> extension.configure(configuration, session));
-    }
-
-    public void initialized(final GroovyLspSession session) {
-        each(extension -> extension.initialized(session));
-    }
-
-    public void shutdown() {
-        each(GroovyLspExtension::shutdown);
-    }
-
-    public void afterCompile(final CompilationUnit unit) {
-        each(extension -> extension.afterCompile(unit));
-    }
-
-    public List<Diagnostic> extraDiagnostics(final CompiledDocument document) {
-        List<Diagnostic> extras = new ArrayList<>();
-        each(extension -> {
-            List<Diagnostic> extra = document == null
-                    ? null
-                    : extension.extraDiagnostics(document.getUri(), document.getText(), document.getModule());
-            if (extra != null) {
-                extras.addAll(extra);
-            }
-        });
-        return extras;
-    }
-
-    public List<Either<Command, CodeAction>> extraCodeActions(final TextDocument document,
-                                                              final List<Diagnostic> diagnostics,
-                                                              final Range range) {
-        List<Either<Command, CodeAction>> extras = new ArrayList<>();
-        each(extension -> {
-            List<Either<Command, CodeAction>> extra = document == null
-                    ? null
-                    : extension.extraCodeActions(document.getUri(), document.getText(), diagnostics, range);
-            if (extra != null) {
-                extras.addAll(extra);
-            }
-        });
-        return extras;
-    }
-
-    private void each(final Consumer<GroovyLspExtension> action) {
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                action.accept(extension);
-            } catch (RuntimeException ignored) {
-                // one plugin must not abort the host
-            }
-        }
-    }
-
-    public Object executeCommand(final String command, final List<Object> arguments,
-                                 final GroovyLspSession session) {
-        if (command == null) {
-            return null;
-        }
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                List<String> commands = extension.commands();
-                if (commands == null || !commands.contains(command)) {
-                    continue;
-                }
-                return extension.executeCommand(command, arguments, session);
-            } catch (RuntimeException ignored) {
-                return null;
-            }
-        }
-        return null;
-    }
-
-    @Override
-    public boolean equals(final Object other) {
-        return other instanceof ExtensionHost host && ids().equals(host.ids());
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(ids());
-    }
-}
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 a20961f509..21c3ad5553 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
@@ -38,7 +38,7 @@ 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.apache.groovy.lsp.spi.GroovyLspSession;
+
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
 import org.eclipse.lsp4j.WorkspaceEdit;
 import org.eclipse.lsp4j.ClientCapabilities;
@@ -70,9 +70,8 @@ import java.util.concurrent.atomic.AtomicReference;
 /**
  * Mutable session state for one language-server process.
  */
-public final class LanguageServerContext implements GroovyLspSession {
+public final class LanguageServerContext {
 
-    private final ExtensionHost extensions;
     private final DocumentStore documents = new DocumentStore();
     private final GroovyCompiler compiler = new GroovyCompiler();
     private final WorkspaceScanner scanner = new WorkspaceScanner();
@@ -106,21 +105,9 @@ public final class LanguageServerContext implements GroovyLspSession {
     private String compileFingerprint;
 
     /**
-     * Creates a session and loads {@code GroovyLspExtension} registrations
-     * from this class's loader.
+     * Creates a session.
      */
     public LanguageServerContext() {
-        this(ExtensionHost.discover(LanguageServerContext.class.getClassLoader()));
-    }
-
-    /**
-     * Creates a session with an explicit plugin host. Tests use this to
-     * register a fake extension without {@code META-INF/services}.
-     *
-     * @param extensions plugin host, or {@code null} for none
-     */
-    public LanguageServerContext(final ExtensionHost extensions) {
-        this.extensions = extensions == null ? ExtensionHost.none() : extensions;
     }
 
     /**
@@ -150,7 +137,7 @@ public final class LanguageServerContext implements GroovyLspSession {
                 effective = WorkspaceLayout.withInferred(settings.get(), folders);
                 extra = scanner.scan(folders, effective.getSourcePaths());
                 open = documents.snapshot();
-                fingerprint = fingerprint(open, extra, effective) + '|' + extensions.ids();
+                fingerprint = fingerprint(open, extra, effective);
                 if (fingerprint.equals(compileFingerprint) && snapshot.get() != CompilationSnapshot.EMPTY) {
                     // Another compile already committed this snapshot but may
                     // not have published yet. Fall through to publishDiagnostics.
@@ -166,7 +153,7 @@ public final class LanguageServerContext implements GroovyLspSession {
                 beginProgress(token, "Compiling Groovy");
                 CompilationSnapshot compiled;
                 try {
-                    compiled = compiler.compile(open, extra, effective, getClass().getClassLoader(), extensions, this);
+                    compiled = compiler.compile(open, extra, effective, getClass().getClassLoader());
                 } finally {
                     endProgress(token);
                 }
@@ -212,7 +199,7 @@ public final class LanguageServerContext implements GroovyLspSession {
     }
 
     /**
-     * Compiler, unused-import and plugin diagnostics for one document.
+     * Compiler and unused-import diagnostics for one document.
      * Used by both push and pull diagnostic paths.
      *
      * @param document compiled document
@@ -225,11 +212,9 @@ public final class LanguageServerContext implements GroovyLspSession {
         PositionEncoding encoding = positionEncoding.get();
         List<Diagnostic> converted = new ArrayList<>(diagnostics.convert(document, encoding));
         converted.addAll(ImportSupport.unusedDiagnostics(document, encoding));
-        converted.addAll(extensions.extraDiagnostics(document));
         return converted;
     }
 
-    @Override
     public void applyEdit(final WorkspaceEdit edit) {
         LanguageClient languageClient = client.get();
         if (languageClient == null || edit == null
@@ -239,7 +224,6 @@ public final class LanguageServerContext implements GroovyLspSession {
         languageClient.applyEdit(new ApplyWorkspaceEditParams(edit));
     }
 
-    @Override
     public String documentText(final URI uri) {
         if (uri == null) {
             return null;
@@ -252,29 +236,34 @@ public final class LanguageServerContext implements GroovyLspSession {
         return compiled == null ? null : compiled.getText();
     }
 
-    @Override
     public List<URI> workspaceFolders() {
         synchronized (workspaceFolders) {
             return List.copyOf(workspaceFolders);
         }
     }
 
-    @Override
     public List<String> classpath() {
         return settings.get().getClasspath();
     }
 
-    @Override
     public List<String> sourcePaths() {
         return settings.get().getSourcePaths();
     }
 
-    @Override
     public Map<String, String> extraSettings() {
         return settings.get().getExtra();
     }
 
-    @Override
+    /**
+     * One extra {@code groovy.*} setting from workspace configuration.
+     *
+     * @param key setting name without the {@code groovy.} prefix
+     * @return the value, or {@code null}
+     */
+    public String extraSetting(final String key) {
+        return key == null ? null : extraSettings().get(key);
+    }
+
     public String positionEncoding() {
         return positionEncoding.get().protocolName();
     }
@@ -320,7 +309,6 @@ public final class LanguageServerContext implements GroovyLspSession {
         debounce.shutdownNow();
         compiler.close();
         documents.clear();
-        extensions.shutdown();
     }
 
     public DocumentStore getDocuments() {
@@ -383,10 +371,6 @@ public final class LanguageServerContext implements GroovyLspSession {
         return hierarchy;
     }
 
-    public ExtensionHost getExtensions() {
-        return extensions;
-    }
-
     public LanguageClient getClient() {
         return client.get();
     }
@@ -433,11 +417,7 @@ public final class LanguageServerContext implements GroovyLspSession {
     }
 
     public void setInitialized(final boolean initialized) {
-        boolean was = this.initialized;
         this.initialized = initialized;
-        if (initialized && !was) {
-            extensions.initialized(this);
-        }
     }
 
     public boolean isShutdown() {
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 252d1b2c60..fbac3f2faa 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
@@ -27,6 +27,7 @@ import java.net.URL;
 import java.net.URLClassLoader;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -41,6 +42,13 @@ import java.util.Set;
 public record CompilerSettings(List<String> classpath, List<String> sourcePaths, int throughPhase,
                                boolean grapeEnabled, boolean astTestEnabled, Map<String, String> extra) {
 
+    private static final String CLASSPATH = "classpath";
+    private static final String SOURCE_PATHS = "sourcePaths";
+    private static final String GRAPE_ENABLED = "grapeEnabled";
+    private static final String AST_TEST_ENABLED = "astTestEnabled";
+    private static final Set<String> CLIENT_KEYS = Set.of(
+            CLASSPATH, SOURCE_PATHS, GRAPE_ENABLED, AST_TEST_ENABLED);
+
     /**
      * Creates settings with the supplied classpath and source paths.
      *
@@ -58,7 +66,7 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
     }
 
     /**
-     * Settings without extra plugin keys.
+     * Settings without extra {@code groovy.*} keys.
      *
      * @param classpath jar and directory entries
      * @param sourcePaths extra Groovy source roots
@@ -105,6 +113,69 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
         return extra;
     }
 
+    /**
+     * Merges a client {@code groovy} settings map into {@code current}.
+     * Missing keys keep the current value. An empty {@code classpath} or
+     * {@code sourcePaths} list also keeps the current list, matching
+     * {@code workspace/didChangeConfiguration}.
+     *
+     * @param map client map, possibly {@code null}
+     * @param current settings to merge into, possibly {@code null}
+     * @return merged settings, never {@code null}
+     */
+    public static CompilerSettings fromClientMap(final Map<?, ?> map, final CompilerSettings current) {
+        CompilerSettings base = current == null ? defaults() : current;
+        if (map == null || map.isEmpty()) {
+            return base;
+        }
+        List<String> classpath = stringList(map.get(CLASSPATH));
+        List<String> sourcePaths = stringList(map.get(SOURCE_PATHS));
+        boolean grape = map.containsKey(GRAPE_ENABLED)
+                ? Boolean.TRUE.equals(map.get(GRAPE_ENABLED)) : base.isGrapeEnabled();
+        boolean astTest = map.containsKey(AST_TEST_ENABLED)
+                ? Boolean.TRUE.equals(map.get(AST_TEST_ENABLED)) : base.isAstTestEnabled();
+        return new CompilerSettings(
+                classpath.isEmpty() ? base.getClasspath() : classpath,
+                sourcePaths.isEmpty() ? base.getSourcePaths() : sourcePaths,
+                base.getThroughPhase(), grape, astTest, extraFrom(map, base));
+    }
+
+    private static Map<String, String> extraFrom(final Map<?, ?> map, final CompilerSettings current) {
+        Map<String, String> extra = new LinkedHashMap<>(current.getExtra());
+        for (Map.Entry<?, ?> entry : map.entrySet()) {
+            if (entry.getKey() != null) {
+                String key = entry.getKey().toString();
+                if (!CLIENT_KEYS.contains(key)) {
+                    extra.put(key, extraValue(entry.getValue()));
+                }
+            }
+        }
+        return extra;
+    }
+
+    private static String extraValue(final Object value) {
+        if (value == null) {
+            return "";
+        }
+        if (value instanceof List<?> list) {
+            return stringList(list).toString();
+        }
+        return value.toString();
+    }
+
+    private static List<String> stringList(final Object value) {
+        if (!(value instanceof List<?> list)) {
+            return List.of();
+        }
+        List<String> result = new ArrayList<>();
+        for (Object item : list) {
+            if (item != null) {
+                result.add(item.toString());
+            }
+        }
+        return result;
+    }
+
     /**
      * Builds a {@link CompilerConfiguration} for a tooling compile.
      *
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 5a2bc5686b..3fe5c9e662 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
@@ -19,10 +19,9 @@
 package org.apache.groovy.lsp.internal.compile;
 
 import groovy.lang.GroovyClassLoader;
-import org.apache.groovy.lsp.internal.ExtensionHost;
+import org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingPass;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
-import org.apache.groovy.lsp.spi.GroovyLspSession;
 import org.codehaus.groovy.control.CompilationFailedException;
 import org.codehaus.groovy.control.CompilationUnit;
 import org.codehaus.groovy.control.CompilerConfiguration;
@@ -73,47 +72,12 @@ 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, ExtensionHost.none());
-    }
-
-    /**
-     * Compiles like {@link #compile(Collection, Collection, CompilerSettings, ClassLoader)}
-     * and then runs {@code extensions} so plugins can inject AST or run an
-     * intelligence pass before the snapshot is frozen.
-     *
-     * @param openDocuments open editor buffers
-     * @param extraFiles additional Groovy files
-     * @param settings compiler settings
-     * @param parentLoader parent class loader
-     * @param extensions plugins, never {@code null}
-     * @return an immutable snapshot
-     */
-    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
-                                       final CompilerSettings settings, final ClassLoader parentLoader,
-                                       final ExtensionHost extensions) {
-        return compile(openDocuments, extraFiles, settings, parentLoader, extensions, null);
-    }
-
-    /**
-     * Compiles like
-     * {@link #compile(Collection, Collection, CompilerSettings, ClassLoader, ExtensionHost)}
-     * and passes {@code session} to {@code configure} so plugins can read
-     * folders and extra settings.
-     *
-     * @param session session, possibly {@code null}
-     * @return an immutable snapshot
-     */
-    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
-                                       final CompilerSettings settings, final ClassLoader parentLoader,
-                                       final ExtensionHost extensions, final GroovyLspSession session) {
         CompilerConfiguration configuration = settings.toConfiguration();
-        ExtensionHost host = extensions == null ? ExtensionHost.none() : extensions;
-        host.configure(configuration, session);
         String previousAstTest = System.getProperty(ASTTEST_ENABLE);
         String previousGrape = System.getProperty(GRAPE_ENABLE);
         applyProcessFlags(settings);
         try {
-            return compileUnit(openDocuments, extraFiles, settings, parentLoader, host, configuration);
+            return compileUnit(openDocuments, extraFiles, settings, parentLoader, configuration);
         } finally {
             restoreProperty(ASTTEST_ENABLE, previousAstTest);
             restoreProperty(GRAPE_ENABLE, previousGrape);
@@ -122,7 +86,7 @@ public final class GroovyCompiler implements AutoCloseable {
 
     private CompilationSnapshot compileUnit(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
                                             final CompilerSettings settings, final ClassLoader parentLoader,
-                                            final ExtensionHost host, final CompilerConfiguration configuration) {
+                                            final CompilerConfiguration configuration) {
         Map<URI, Integer> versions = new LinkedHashMap<>();
         Map<URI, String> texts = new LinkedHashMap<>();
         Map<URI, String> javaOverlays = new LinkedHashMap<>();
@@ -140,7 +104,7 @@ public final class GroovyCompiler implements AutoCloseable {
         } catch (CompilationFailedException ignored) {
             // keep the partial AST and collected messages
         }
-        host.afterCompile(unit);
+        StaticTypeCheckingPass.apply(unit);
 
         Map<URI, CompiledDocument> compiled = CompilationSnapshot.newMap();
         var sourceIterator = unit.iterator();
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java
index a6b0f3bcbe..35c4e91227 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java
@@ -33,7 +33,7 @@ import org.codehaus.groovy.transform.stc.StaticTypesMarker;
 /**
  * Best-effort types for hover and inlay when the AST still says
  * {@code Object} / dynamic. Prefers {@link StaticTypesMarker} metadata
- * written by an {@code afterCompile} STC pass, then an initializer
+ * written by the core static type-checking pass, then an initializer
  * expression. Does not run the static type checker itself.
  */
 final class TypeInference {
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingExtension.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPass.java
similarity index 80%
rename from subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingExtension.java
rename to subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPass.java
index c5e4be183a..5bc4f0ce7d 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingExtension.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPass.java
@@ -21,7 +21,6 @@ package org.apache.groovy.lsp.internal.intelligence;
 import groovy.transform.CompileStatic;
 import groovy.transform.TypeChecked;
 import org.apache.groovy.lsp.internal.compile.AstQuery;
-import org.apache.groovy.lsp.spi.GroovyLspExtension;
 import org.codehaus.groovy.ast.AnnotatedNode;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
@@ -38,24 +37,28 @@ import java.util.LinkedHashSet;
 import java.util.Set;
 
 /**
- * Default intelligence pass for {@code @TypeChecked} / {@code @CompileStatic}
- * members. Those transforms run at {@code INSTRUCTION_SELECTION}, which
- * groovy-lsp never reaches; this copies {@code DIRECT_METHOD_CALL_TARGET}
- * onto {@code methodTarget} so hover, rename and parameter-name inlays
- * see a compiler bind. Dynamic Groovy is left alone.
+ * Core post-pass after {@code SEMANTIC_ANALYSIS}. {@code @TypeChecked} /
+ * {@code @CompileStatic} transforms run at {@code INSTRUCTION_SELECTION},
+ * which groovy-lsp never reaches; this copies
+ * {@code DIRECT_METHOD_CALL_TARGET} onto {@code methodTarget} so hover,
+ * rename and parameter-name inlays see a compile-time bind. Dynamic
+ * Groovy is left alone.
  */
-public final class StaticTypeCheckingExtension implements GroovyLspExtension {
+public final class StaticTypeCheckingPass {
 
     private static final ClassNode TYPE_CHECKED = ClassHelper.make(TypeChecked.class);
     private static final ClassNode COMPILE_STATIC = ClassHelper.make(CompileStatic.class);
 
-    @Override
-    public String id() {
-        return "groovy.lsp.stc";
+    private StaticTypeCheckingPass() {
     }
 
-    @Override
-    public void afterCompile(final CompilationUnit unit) {
+    /**
+     * Runs the pass on every module in {@code unit}. Failures are isolated
+     * so a bad class does not drop the snapshot.
+     *
+     * @param unit the compiled unit, possibly {@code null}
+     */
+    public static void apply(final CompilationUnit unit) {
         if (unit == null) {
             return;
         }
@@ -67,7 +70,11 @@ public final class StaticTypeCheckingExtension implements GroovyLspExtension {
                 continue;
             }
             for (ClassNode classNode : module.getClasses()) {
-                checkClass(source, unit, classNode);
+                try {
+                    checkClass(source, unit, classNode);
+                } catch (RuntimeException ignored) {
+                    // keep the rest of the snapshot
+                }
             }
         }
     }
@@ -98,7 +105,10 @@ public final class StaticTypeCheckingExtension implements GroovyLspExtension {
         bindTargets(classNode);
     }
 
-    private static void bindTargets(final ClassNode classNode) {
+    static void bindTargets(final ClassNode classNode) {
+        if (classNode == null) {
+            return;
+        }
         AstQuery.walk(classNode, (node, ctx) -> {
             if (node instanceof MethodCallExpression call && call.getMethodTarget() == null) {
                 Object target = call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET);
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
index f342911b05..8a83f350dc 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
@@ -66,6 +66,11 @@ public final class ClientFeatures {
         return workspace != null && Boolean.TRUE.equals(workspace.getApplyEdit());
     }
 
+    public static boolean workspaceConfiguration(final ClientCapabilities capabilities) {
+        WorkspaceClientCapabilities workspace = capabilities == null ? null : capabilities.getWorkspace();
+        return workspace != null && Boolean.TRUE.equals(workspace.getConfiguration());
+    }
+
     public static boolean workDoneProgress(final ClientCapabilities capabilities) {
         return capabilities != null && capabilities.getWindow() != null
                 && Boolean.TRUE.equals(capabilities.getWindow().getWorkDoneProgress());
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 2c35463625..0240c979e8 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
@@ -239,8 +239,7 @@ public final class GroovyTextDocumentService implements TextDocumentService {
             List<Either<Command, CodeAction>> actions = new ArrayList<>(
                     context.getSupport().codeActions(document, context.getSnapshot(),
                             diagnostics, params.getRange(), context.getPositionEncoding(),
-                            context.getWorkspaceFolders(), context.getSettings().getSourcePaths()));
-            actions.addAll(context.getExtensions().extraCodeActions(document, diagnostics, params.getRange()));
+                            context.workspaceFolders(), context.getSettings().getSourcePaths()));
             return filterByOnly(actions, params.getContext() == null ? null : params.getContext().getOnly());
         });
     }
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 d4245de9d6..441799ea3c 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
@@ -24,6 +24,8 @@ import org.apache.groovy.lsp.internal.compile.CompilerSettings;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
+import org.eclipse.lsp4j.ConfigurationItem;
+import org.eclipse.lsp4j.ConfigurationParams;
 import org.eclipse.lsp4j.Position;
 import org.eclipse.lsp4j.DidChangeConfigurationParams;
 import org.eclipse.lsp4j.DidChangeWatchedFilesParams;
@@ -38,14 +40,13 @@ import org.eclipse.lsp4j.WorkspaceFolder;
 import org.eclipse.lsp4j.WorkspaceSymbol;
 import org.eclipse.lsp4j.WorkspaceSymbolParams;
 import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import org.eclipse.lsp4j.services.LanguageClient;
 import org.eclipse.lsp4j.services.WorkspaceService;
 
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.function.BiFunction;
 
 /**
@@ -56,10 +57,8 @@ public final class GroovyWorkspaceService implements WorkspaceService {
     static final String ORGANIZE_IMPORTS = "groovy.lsp.organizeImports";
     static final String SHOW_REFERENCES = "groovy.lsp.showReferences";
     static final String GOTO_SUPER_METHOD = "groovy.lsp.gotoSuperMethod";
-    private static final String GRAPE_ENABLED = "grapeEnabled";
-    private static final String AST_TEST_ENABLED = "astTestEnabled";
-    private static final Set<String> CORE_SETTING_KEYS = Set.of(
-            "classpath", "sourcePaths", GRAPE_ENABLED, AST_TEST_ENABLED);
+    private static final String GROOVY_SECTION = "groovy";
+    private static final long CONFIGURATION_TIMEOUT_SECONDS = 2L;
 
     private final LanguageServerContext context;
 
@@ -71,7 +70,7 @@ public final class GroovyWorkspaceService implements WorkspaceService {
     public void didChangeConfiguration(final DidChangeConfigurationParams params) {
         Object settings = params.getSettings();
         if (settings instanceof Map<?, ?> map) {
-            Object groovy = map.get("groovy");
+            Object groovy = map.get(GROOVY_SECTION);
             if (groovy instanceof Map<?, ?> groovyMap) {
                 apply(groovyMap);
             } else {
@@ -81,6 +80,36 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         context.scheduleRecompile(0);
     }
 
+    /**
+     * Requests section {@code groovy} via {@code workspace/configuration}
+     * when the client advertised that capability. Applied asynchronously
+     * so the JSON-RPC reader is not blocked waiting for the reply.
+     */
+    public void pullConfiguration() {
+        LanguageClient client = context.getClient();
+        if (client == null || !ClientFeatures.workspaceConfiguration(context.getClientCapabilities())) {
+            return;
+        }
+        ConfigurationItem item = new ConfigurationItem();
+        item.setSection(GROOVY_SECTION);
+        CompletableFuture<List<Object>> future;
+        try {
+            future = client.configuration(new ConfigurationParams(List.of(item)));
+        } catch (RuntimeException ignored) {
+            return;
+        }
+        if (future == null) {
+            return;
+        }
+        future.orTimeout(CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .whenComplete((values, error) -> {
+                    if (error == null) {
+                        applyPulled(values);
+                        context.scheduleRecompile(0);
+                    }
+                });
+    }
+
     @Override
     public void didChangeWatchedFiles(final DidChangeWatchedFilesParams params) {
         context.scheduleRecompile(100);
@@ -120,7 +149,7 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         if (core != null) {
             return core;
         }
-        return CompletableFuture.completedFuture(context.getExtensions().executeCommand(command, args, context));
+        return CompletableFuture.completedFuture(null);
     }
 
     private CompletableFuture<Object> coreCommand(final String command, final List<Object> args) {
@@ -160,42 +189,18 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         return CompletableFuture.completedFuture(action.apply(document, new Position(line, character)));
     }
 
-    @SuppressWarnings("unchecked")
     private void apply(final Map<?, ?> map) {
-        List<String> classpath = stringList(map.get("classpath"));
-        List<String> sourcePaths = stringList(map.get("sourcePaths"));
-        CompilerSettings current = context.getSettings();
-        boolean grape = map.containsKey(GRAPE_ENABLED)
-                ? Boolean.TRUE.equals(map.get(GRAPE_ENABLED)) : current.isGrapeEnabled();
-        boolean astTest = map.containsKey(AST_TEST_ENABLED)
-                ? Boolean.TRUE.equals(map.get(AST_TEST_ENABLED)) : current.isAstTestEnabled();
-        context.setSettings(new CompilerSettings(
-                classpath.isEmpty() ? current.getClasspath() : classpath,
-                sourcePaths.isEmpty() ? current.getSourcePaths() : sourcePaths,
-                current.getThroughPhase(), grape, astTest, extraSettings(map, current)));
+        context.setSettings(CompilerSettings.fromClientMap(map, context.getSettings()));
     }
 
-    private static Map<String, String> extraSettings(final Map<?, ?> map, final CompilerSettings current) {
-        Map<String, String> extra = new LinkedHashMap<>(current.getExtra());
-        for (Map.Entry<?, ?> entry : map.entrySet()) {
-            if (entry.getKey() != null) {
-                String key = entry.getKey().toString();
-                if (!CORE_SETTING_KEYS.contains(key)) {
-                    extra.put(key, extraValue(entry.getValue()));
-                }
-            }
-        }
-        return extra;
-    }
-
-    private static String extraValue(final Object value) {
-        if (value == null) {
-            return "";
+    private void applyPulled(final List<Object> values) {
+        if (values == null || values.isEmpty()) {
+            return;
         }
-        if (value instanceof List<?> list) {
-            return stringList(list).toString();
+        Object first = values.get(0);
+        if (first instanceof Map<?, ?> map) {
+            apply(map);
         }
-        return value.toString();
     }
 
     private void applyFolders(final List<WorkspaceFolder> folders, final boolean add) {
@@ -214,16 +219,4 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         }
     }
 
-    private static List<String> stringList(final Object value) {
-        if (!(value instanceof List<?> list)) {
-            return List.of();
-        }
-        List<String> result = new ArrayList<>();
-        for (Object item : list) {
-            if (item != null) {
-                result.add(item.toString());
-            }
-        }
-        return result;
-    }
 }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
index 22ebb86524..e8ed67f899 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
@@ -44,7 +44,6 @@ import org.eclipse.lsp4j.TypeHierarchyRegistrationOptions;
 import org.eclipse.lsp4j.WorkspaceFoldersOptions;
 import org.eclipse.lsp4j.WorkspaceServerCapabilities;
 
-import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -61,18 +60,6 @@ public final class ServerCapabilityFactory {
      * @return capabilities
      */
     public ServerCapabilities create(final PositionEncoding encoding) {
-        return create(encoding, List.of());
-    }
-
-    /**
-     * Builds server capabilities including extra execute-command names
-     * contributed by plugins.
-     *
-     * @param encoding negotiated position encoding
-     * @param extraCommands plugin command ids
-     * @return capabilities
-     */
-    public ServerCapabilities create(final PositionEncoding encoding, final List<String> extraCommands) {
         ServerCapabilities capabilities = new ServerCapabilities();
         capabilities.setPositionEncoding(encoding.protocolName());
         capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental);
@@ -133,18 +120,10 @@ public final class ServerCapabilityFactory {
         workspace.setFileOperations(fileOps);
         capabilities.setWorkspace(workspace);
 
-        List<String> commands = new ArrayList<>();
-        commands.add(GroovyWorkspaceService.ORGANIZE_IMPORTS);
-        commands.add(GroovyWorkspaceService.SHOW_REFERENCES);
-        commands.add(GroovyWorkspaceService.GOTO_SUPER_METHOD);
-        if (extraCommands != null) {
-            for (String command : extraCommands) {
-                if (command != null && !command.isBlank() && !commands.contains(command)) {
-                    commands.add(command);
-                }
-            }
-        }
-        capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(commands));
+        capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(List.of(
+                GroovyWorkspaceService.ORGANIZE_IMPORTS,
+                GroovyWorkspaceService.SHOW_REFERENCES,
+                GroovyWorkspaceService.GOTO_SUPER_METHOD)));
         return capabilities;
     }
 }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java
deleted file mode 100644
index eaf88eb8c4..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- *  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.spi;
-
-import org.apache.groovy.lang.annotation.Incubating;
-import org.codehaus.groovy.ast.ModuleNode;
-import org.codehaus.groovy.control.CompilationUnit;
-import org.codehaus.groovy.control.CompilerConfiguration;
-import org.eclipse.lsp4j.CodeAction;
-import org.eclipse.lsp4j.Command;
-import org.eclipse.lsp4j.Diagnostic;
-import org.eclipse.lsp4j.Range;
-import org.eclipse.lsp4j.jsonrpc.messages.Either;
-
-import java.net.URI;
-import java.util.List;
-
-/**
- * One contribution to the language server. Implementors ship on the
- * server classpath and register via {@code ServiceLoader},
- * or tests pass them to {@code ExtensionHost.of}. Every method has a
- * no-op default so a GDSL, STC, or MCP plugin implements only what it
- * needs.
- * <p>
- * Hooks run on the compile thread except {@code extraCodeActions} and
- * {@code executeCommand}, which run on the request thread and must
- * read {@link GroovyLspSession} rather than compiling. Hover and
- * completion stay in core: inject members in {@code afterCompile} so
- * the snapshot sees them. There is no second hover/completion SPI.
- */
-@Incubating
-public interface GroovyLspExtension {
-
-    /**
-     * Stable id used in compile fingerprints so enabling a plugin
-     * forces a recompile.
-     *
-     * @return a non-blank id
-     */
-    default String id() {
-        return getClass().getName();
-    }
-
-    /**
-     * Mutates {@code configuration} before the compilation unit is
-     * created (extra customizers, extra disabled transforms).
-     *
-     * @param configuration compiler configuration
-     */
-    default void configure(final CompilerConfiguration configuration) {
-    }
-
-    /**
-     * Like {@link #configure(CompilerConfiguration)} with a session so a
-     * plugin can read workspace folders and extra {@code groovy.*}
-     * settings (for example {@code groovy.dependencies}) before mutating
-     * the classpath.
-     *
-     * @param configuration compiler configuration
-     * @param session session, possibly {@code null} in tests
-     */
-    default void configure(final CompilerConfiguration configuration, final GroovyLspSession session) {
-        configure(configuration);
-    }
-
-    /**
-     * Called once after {@code initialized}. Load catalogs that need
-     * workspace folders here.
-     *
-     * @param session session
-     */
-    default void initialized(final GroovyLspSession session) {
-    }
-
-    /**
-     * Called when the session shuts down. Release plugin resources.
-     */
-    default void shutdown() {
-    }
-
-    /**
-     * Runs after {@code unit.compile} and before the snapshot is built.
-     * GDSL injection and a static-type-checking intelligence pass belong
-     * here. Must not generate class files.
-     *
-     * @param unit the compiled unit, possibly partial
-     */
-    default void afterCompile(final CompilationUnit unit) {
-    }
-
-    /**
-     * Extra diagnostics published with the compiler's own messages.
-     *
-     * @param uri document URI
-     * @param sourceText document text
-     * @return extra diagnostics, never {@code null}
-     */
-    default List<Diagnostic> extraDiagnostics(final URI uri, final String sourceText) {
-        return List.of();
-    }
-
-    /**
-     * Extra diagnostics with the compiled module when compile produced
-     * one. {@code module} is groovy-core AST, not a groovy-lsp internal
-     * type. Default delegates to
-     * {@link #extraDiagnostics(URI, String)}.
-     *
-     * @param uri document URI
-     * @param sourceText document text
-     * @param module compiled module, possibly {@code null}
-     * @return extra diagnostics, never {@code null}
-     */
-    default List<Diagnostic> extraDiagnostics(final URI uri, final String sourceText, final ModuleNode module) {
-        return extraDiagnostics(uri, sourceText);
-    }
-
-    /**
-     * Extra code actions for a range.
-     *
-     * @param uri document URI
-     * @param diagnostics client diagnostics
-     * @param range requested range
-     * @return extra actions, never {@code null}
-     */
-    default List<Either<Command, CodeAction>> extraCodeActions(final URI uri, final List<Diagnostic> diagnostics,
-                                                               final Range range) {
-        return List.of();
-    }
-
-    /**
-     * Extra code actions with source text so a plugin can build edits
-     * without a second document store. Default delegates to
-     * {@link #extraCodeActions(URI, List, Range)}.
-     *
-     * @param uri document URI
-     * @param sourceText document text, possibly {@code null}
-     * @param diagnostics client diagnostics
-     * @param range requested range
-     * @return extra actions, never {@code null}
-     */
-    default List<Either<Command, CodeAction>> extraCodeActions(final URI uri, final String sourceText,
-                                                               final List<Diagnostic> diagnostics,
-                                                               final Range range) {
-        return extraCodeActions(uri, diagnostics, range);
-    }
-
-    /**
-     * Extra {@code workspace/executeCommand} names to advertise.
-     *
-     * @return command identifiers, never {@code null}
-     */
-    default List<String> commands() {
-        return List.of();
-    }
-
-    /**
-     * Handles {@code command} when {@link #commands()} contains it.
-     *
-     * @param command command id
-     * @param arguments protocol arguments
-     * @param session session (edits, document text, folders, extra settings)
-     * @return the LSP result, possibly {@code null}
-     */
-    default Object executeCommand(final String command, final List<Object> arguments,
-                                  final GroovyLspSession session) {
-        return null;
-    }
-}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java
deleted file mode 100644
index 17f405d27d..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- *  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.spi;
-
-import org.apache.groovy.lang.annotation.Incubating;
-import org.eclipse.lsp4j.WorkspaceEdit;
-
-import java.net.URI;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Narrow session view for {@link GroovyLspExtension}. Plugins must not
- * compile or publish diagnostics through this type. Use
- * {@link GroovyLspExtension#afterCompile} to see the live AST.
- */
-@Incubating
-public interface GroovyLspSession {
-
-    /**
-     * Asks the client to apply {@code edit} when {@code workspace/applyEdit}
-     * is available. No-op if there is no client or the client did not
-     * advertise the capability.
-     *
-     * @param edit a workspace edit
-     */
-    void applyEdit(WorkspaceEdit edit);
-
-    /**
-     * Open-buffer or last-compiled text for {@code uri}.
-     *
-     * @param uri document URI
-     * @return the text, or {@code null} when unknown
-     */
-    default String documentText(URI uri) {
-        return null;
-    }
-
-    /**
-     * Workspace folders in registration order.
-     *
-     * @return folders, never {@code null}
-     */
-    default List<URI> workspaceFolders() {
-        return List.of();
-    }
-
-    /**
-     * Client {@code groovy.classpath} entries.
-     *
-     * @return classpath strings, never {@code null}
-     */
-    default List<String> classpath() {
-        return List.of();
-    }
-
-    /**
-     * Client {@code groovy.sourcePaths} entries.
-     *
-     * @return source paths, never {@code null}
-     */
-    default List<String> sourcePaths() {
-        return List.of();
-    }
-
-    /**
-     * Other {@code groovy.*} settings the core does not interpret
-     * ({@code groovy.dependencies}, a GDSL path, …). Values are the
-     * client's string form.
-     *
-     * @return extra settings, never {@code null}
-     */
-    default Map<String, String> extraSettings() {
-        return Map.of();
-    }
-
-    /**
-     * One extra setting.
-     *
-     * @param key setting name without the {@code groovy.} prefix
-     * @return the value, or {@code null}
-     */
-    default String extraSetting(final String key) {
-        return key == null ? null : extraSettings().get(key);
-    }
-
-    /**
-     * Negotiated LSP position encoding ({@code utf-16}, {@code utf-8},
-     * or {@code utf-32}).
-     *
-     * @return the protocol name, never {@code null}
-     */
-    default String positionEncoding() {
-        return "utf-16";
-    }
-}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java
deleted file mode 100644
index be9810e060..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- *  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.
- */
-/**
- * Incubating SPI so other Apache-2.0 Groovy language servers and
- * {@code groovy-mcp} can sit on groovy-lsp's compile and protocol loop
- * without forking it. A {@link GroovyLspExtension} is discovered with
- * {@code ServiceLoader} or registered in tests. Inject AST in
- * {@code afterCompile}; read folders and extra settings from
- * {@link GroovyLspSession}. There is no second hover/completion SPI.
- * <p>
- * This is not a slot for replacing the Groovy compiler with Eclipse JDT.
- * Joint Java/Groovy servers remain a parallel stack.
- */
-@Incubating
-package org.apache.groovy.lsp.spi;
-
-import org.apache.groovy.lang.annotation.Incubating;
diff --git a/subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension b/subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension
deleted file mode 100644
index 6f717bd16a..0000000000
--- a/subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension
+++ /dev/null
@@ -1,19 +0,0 @@
-#
-#  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.
-#
-org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingExtension
diff --git a/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc b/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
index e8524699d0..1a79db8bb0 100644
--- a/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
+++ b/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
@@ -53,7 +53,7 @@ The server advertises and implements:
 * Incremental text synchronization
 * Diagnostics (push and pull). Compiler messages plus unused-import
   hints. `@TypeChecked` / `@CompileStatic` members also receive a
-  default static-type-checking pass (see <<default-extension>>)
+  core static-type-checking pass (see <<static-type-checking>>)
 * Completion (keywords, snippets, members, workspace types with
   auto-import, GDK when a prefix is typed) and `completionItem/resolve`
 * Hover, signature help (overloads). Unique-arity hover on an unbound
@@ -126,17 +126,20 @@ document color, notebooks, inline completion, inline values, monikers,
 
 == Configuration
 
-Client settings under `groovy` (or the root of `workspace/didChangeConfiguration`):
+Client settings under `groovy` (or the root of
+`workspace/didChangeConfiguration`). After `initialized`, if the
+client advertised `workspace.configuration`, the server also pulls
+section `groovy` via `workspace/configuration` (asynchronously, so
+the JSON-RPC reader is not blocked).
 
 * `classpath` — extra jars and directories
 * `sourcePaths` — extra source roots relative to workspace folders
 * `grapeEnabled` — allow `@Grab` during compile (default `false`)
 * `astTestEnabled` — allow `@ASTTest` during compile (default `false`)
-* any other `groovy.*` key — stored as a string and passed to plugins
-  via `GroovyLspSession.extraSettings()` (for example
-  `groovy.dependencies`). The core does not download or interpret them.
-  Changing these keys forces a recompile and rebuilds the class loader
-  so a plugin that appends jars in `configure` is visible.
+* any other `groovy.*` key — stored as a string (for example
+  `groovy.dependencies`). The core does not download or interpret
+  them. Changing these keys forces a recompile and rebuilds the
+  class loader.
 
 == Classpath
 
@@ -168,20 +171,12 @@ AI tools are expected to use the same process, and a later
 `groovy-mcp` module is expected to wrap it so a model can query
 Groovy structure and types without embedding the compiler.
 
-It is meant to be used **directly by an IDE or editor**, and as a
-library other Apache-2.0 Groovy language servers can embed. Plugins
-implement `org.apache.groovy.lsp.spi.GroovyLspExtension` and register
-with `ServiceLoader` (tests use `ExtensionHost.of`). That is the seam
-for Jenkins GDSL, a forced STC pass, extra commands, and a later
-`groovy-mcp` without forking the stdio loop.
-
-A plugin that injects members in `afterCompile` is visible to core
-hover, completion, navigation and rename. There is no second
-hover/completion SPI. `GroovyLspSession` exposes document text,
-workspace folders, classpath, source paths, extra `groovy.*` settings
-(for example `groovy.dependencies`) and the negotiated position
-encoding. Extra diagnostics may see the compiled `ModuleNode`.
-Eclipse JDT / groovy-eclipse servers remain a parallel stack.
+It is meant to be used **directly by an IDE or editor**, and as an
+in-process language-services library (`feature/*` + `compile/*`) with
+the LSP server as the first client (`protocol/*`). A later
+`groovy-mcp` module is expected to wrap that same engine. There is no
+public extension SPI in this first cut. Eclipse JDT / groovy-eclipse
+servers remain a parallel stack. GDSL is not implemented here.
 
 It is not a complete workspace/IDE: it does not invoke Gradle or Maven,
 does not load GDSL itself, and does not debug or run tests. When the
@@ -218,11 +213,11 @@ Call-site parameter-name inlays use the same unique-arity helper as
 hover. They are display chrome: a later `groovy-mcp` wrapper must not
 treat them as facts, and they never insert named-argument text.
 
-== Default static-type-checking extension
+== Static type checking
+[[static-type-checking]]
 [[default-extension]]
 
-The server ships one built-in `GroovyLspExtension`
-(`groovy.lsp.stc`). After `SEMANTIC_ANALYSIS` it runs
+After `SEMANTIC_ANALYSIS` a core pass runs
 `StaticTypeCheckingVisitor` on classes and methods annotated
 `@TypeChecked` or `@CompileStatic` (not `@CompileDynamic` /
 `TypeCheckingMode.SKIP`) and copies `DIRECT_METHOD_CALL_TARGET` onto
@@ -232,13 +227,12 @@ rename of those calls, and unlabeled parameter-name inlays.
 It does **not** force STC on dynamic Groovy, does not raise the compile
 phase, and does not generate class files. STC errors for annotated
 members land on the source unit's error collector and are published as
-ordinary diagnostics. Additional plugins still register with
-`ServiceLoader`.
+ordinary diagnostics.
 
 == Honesty for AI tools
 
-Diagnostics are compiler messages, unused-import hints, and plugin
-extras. Hover `_inferred_` and inlay tooltip `inferred` mean the
+Diagnostics are compiler messages and unused-import hints. Hover
+`_inferred_` and inlay tooltip `inferred` mean the
 server guessed from unique arity or an initializer — not a compiler
 bind. Rename of an unbound call is refused. Models should treat
 inferred chrome as a hint, not as source to copy.
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy
index ce04b4c95f..6b2577c2a3 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy
@@ -22,7 +22,6 @@ import org.apache.groovy.lsp.GroovyLanguageServer
 import org.apache.groovy.lsp.GroovyLanguageServerLauncher
 import org.apache.groovy.lsp.internal.compile.AstQuery
 import org.apache.groovy.lsp.internal.compile.CompilationSnapshot
-import org.apache.groovy.lsp.internal.compile.CompiledDocument
 import org.apache.groovy.lsp.internal.compile.CompilerSettings
 import org.apache.groovy.lsp.internal.compile.GroovyCompiler
 import org.apache.groovy.lsp.internal.compile.Identifiers
@@ -33,11 +32,11 @@ import org.apache.groovy.lsp.internal.compile.WorkspaceScanner
 import org.apache.groovy.lsp.internal.position.PositionEncoding
 import org.apache.groovy.lsp.internal.position.Positions
 import org.apache.groovy.lsp.internal.protocol.ClientFeatures
+import org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingPass
+import org.apache.groovy.lsp.internal.protocol.GroovyWorkspaceService
 import org.apache.groovy.lsp.internal.util.GroovyKeywords
 import org.apache.groovy.lsp.internal.util.Uris
 import org.apache.groovy.lsp.internal.workspace.TextDocument
-import org.apache.groovy.lsp.spi.GroovyLspExtension
-import org.apache.groovy.lsp.spi.GroovyLspSession
 import org.codehaus.groovy.ast.ASTNode
 import org.codehaus.groovy.ast.ClassHelper
 import org.codehaus.groovy.ast.ClassNode
@@ -51,16 +50,14 @@ import org.codehaus.groovy.ast.expr.MethodCallExpression
 import org.codehaus.groovy.ast.expr.PropertyExpression
 import org.codehaus.groovy.ast.expr.StaticMethodCallExpression
 import org.codehaus.groovy.ast.expr.VariableExpression
-import org.codehaus.groovy.control.CompilerConfiguration
 import org.eclipse.lsp4j.ClientCapabilities
+import org.eclipse.lsp4j.DidChangeConfigurationParams
 import org.eclipse.lsp4j.DeclarationCapabilities
 import org.eclipse.lsp4j.DefinitionCapabilities
 import org.eclipse.lsp4j.DocumentSymbolCapabilities
 import org.eclipse.lsp4j.GeneralClientCapabilities
 import org.eclipse.lsp4j.ImplementationCapabilities
 import org.eclipse.lsp4j.InitializeParams
-import org.eclipse.lsp4j.Position
-import org.eclipse.lsp4j.Range
 import org.eclipse.lsp4j.TextDocumentClientCapabilities
 import org.eclipse.lsp4j.TypeDefinitionCapabilities
 import org.eclipse.lsp4j.WindowClientCapabilities
@@ -70,6 +67,7 @@ import org.eclipse.lsp4j.WorkspaceFolder
 import org.junit.jupiter.api.Test
 
 import java.nio.file.Files
+import java.util.concurrent.TimeUnit
 
 final class HelperCoverageTest {
 
@@ -81,6 +79,7 @@ final class HelperCoverageTest {
         assert !ClientFeatures.declarationLinks(null)
         assert !ClientFeatures.hierarchicalDocumentSymbols(null)
         assert !ClientFeatures.applyEdit(null)
+        assert !ClientFeatures.workspaceConfiguration(null)
         assert !ClientFeatures.workDoneProgress(null)
         def caps = new ClientCapabilities()
         assert !ClientFeatures.definitionLinks(caps)
@@ -95,6 +94,7 @@ final class HelperCoverageTest {
         caps.textDocument = text
         def workspace = new WorkspaceClientCapabilities()
         workspace.applyEdit = true
+        workspace.configuration = true
         caps.workspace = workspace
         def window = new WindowClientCapabilities()
         window.workDoneProgress = true
@@ -106,6 +106,7 @@ final class HelperCoverageTest {
         assert ClientFeatures.declarationLinks(caps)
         assert ClientFeatures.hierarchicalDocumentSymbols(caps)
         assert ClientFeatures.applyEdit(caps)
+        assert ClientFeatures.workspaceConfiguration(caps)
         assert ClientFeatures.workDoneProgress(caps)
     }
 
@@ -159,6 +160,25 @@ final class HelperCoverageTest {
         assert loader != null
         loader.close()
         def on = new CompilerSettings(['.'], ['src'], 3, true, true)
+        def merged = CompilerSettings.fromClientMap(null, on)
+        assert merged.classpath == on.classpath
+        assert CompilerSettings.fromClientMap([:], on).is(on)
+        def fromNull = CompilerSettings.fromClientMap(null, null)
+        assert fromNull.classpath.isEmpty()
+        def applied = CompilerSettings.fromClientMap(
+                [classpath: ['b.jar'], sourcePaths: ['lib'], grapeEnabled: true,
+                 astTestEnabled: true, extraKey: 'v', listed: ['a', null, 'b'], blank: null, (null): 'x'],
+                new CompilerSettings(['a.jar'], ['src'], 3, false, false, [keep: '1']))
+        assert applied.classpath == ['b.jar']
+        assert applied.sourcePaths == ['lib']
+        assert applied.grapeEnabled
+        assert applied.astTestEnabled
+        assert applied.extra.keep == '1'
+        assert applied.extra.extraKey == 'v'
+        assert applied.extra.listed == '[a, b]'
+        assert applied.extra.blank == ''
+        def keepLists = CompilerSettings.fromClientMap([grapeEnabled: false], applied)
+        assert keepLists.classpath == ['b.jar']
         def config = on.toConfiguration()
         assert !config.disabledGlobalASTTransformations.contains('groovy.grape.GrabAnnotationTransformation')
         on.createClassLoader(HelperCoverageTest.classLoader).withCloseable { urls ->
@@ -212,66 +232,59 @@ final class HelperCoverageTest {
     }
 
     @Test
-    void extensionHostIsolatesThrowingPlugins() {
-        def boom = new GroovyLspExtension() {
-            void configure(CompilerConfiguration configuration) { throw new IllegalStateException('c') }
-            void afterCompile(unit) { throw new IllegalStateException('a') }
-            List extraDiagnostics(URI uri, String text) { throw new IllegalStateException('d') }
-            List extraCodeActions(URI uri, List diagnostics, Range range) { throw new IllegalStateException('x') }
-            List<String> commands() { ['boom'] }
-            Object executeCommand(String command, List arguments, GroovyLspSession session) {
-                throw new IllegalStateException('e')
-            }
-        }
-        def host = ExtensionHost.of(null, boom)
-        host.configure(new CompilerConfiguration())
-        host.afterCompile(null)
-        assert host.extraDiagnostics(null).isEmpty()
-        def doc = new CompiledDocument(
-                URI.create('file:///tmp/X.groovy'), 1, 'class X {}', null, null, null)
-        assert host.extraDiagnostics(doc).isEmpty()
-        def td = new TextDocument(URI.create('file:///tmp/X.groovy'), 'groovy', 1, 'class X {}')
-        assert host.extraCodeActions(td, [],
-                new Range(new Position(0, 0), new Position(0, 1))).isEmpty()
-        assert host.executeCommand('boom', [], new GroovyLspSession() {
-            void applyEdit(WorkspaceEdit edit) {}
-        }) == null
-        assert host.executeCommand(null, [], null) == null
-        assert ExtensionHost.of((GroovyLspExtension[]) null) == ExtensionHost.none()
-        assert 'groovy.lsp.stc' in ExtensionHost.discover(null).ids()
-        assert host == ExtensionHost.of(boom)
-        assert host.hashCode() == ExtensionHost.of(boom).hashCode()
-        assert host.all().size() == 1
-        def defaults = new GroovyLspExtension() {}
-        assert defaults.id() == defaults.class.name
-        defaults.configure(new CompilerConfiguration())
-        defaults.afterCompile(null)
-        assert defaults.extraDiagnostics(URI.create('file:///tmp/X.groovy'), '').isEmpty()
-        assert defaults.extraDiagnostics(URI.create('file:///tmp/X.groovy'), '', null).isEmpty()
-        assert defaults.extraCodeActions(URI.create('file:///tmp/X.groovy'), [], null).isEmpty()
-        assert defaults.extraCodeActions(URI.create('file:///tmp/X.groovy'), 'class X {}', [], null).isEmpty()
-        assert defaults.commands().isEmpty()
-        assert defaults.executeCommand('x', [], null) == null
-        defaults.initialized(null)
-        defaults.shutdown()
-        def session = new GroovyLspSession() {
-            void applyEdit(WorkspaceEdit edit) {}
-        }
-        assert session.documentText(URI.create('file:///tmp/X.groovy')) == null
-        assert session.workspaceFolders().isEmpty()
-        assert session.classpath().isEmpty()
-        assert session.sourcePaths().isEmpty()
-        assert session.extraSettings().isEmpty()
-        assert session.extraSetting('x') == null
-        assert session.positionEncoding() == 'utf-16'
-        def blank = new GroovyLspExtension() {
-            String id() { '' }
-            List extraCodeActions(URI uri, String sourceText, List diagnostics, Range range) { null }
-        }
-        assert blank.class.name in ExtensionHost.of(blank).ids()
-        def blankDoc = new TextDocument(URI.create('file:///tmp/X.groovy'), 'groovy', 1, 'class X {}')
-        assert ExtensionHost.of(blank).extraCodeActions(blankDoc, [],
-                new Range(new Position(0, 0), new Position(0, 1))).isEmpty()
+    void contextSessionHelpersAndConfigurationPull() {
+        def context = new LanguageServerContext()
+        assert context.documentText(null) == null
+        assert context.documentText(URI.create('file:///tmp/missing.groovy')) == null
+        assert context.extraSetting(null) == null
+        assert context.extraSetting('x') == null
+        assert context.extraSettings().isEmpty()
+        assert context.classpath().isEmpty()
+        assert context.sourcePaths().isEmpty()
+        assert context.positionEncoding() == 'utf-16'
+        assert context.workspaceFolders().isEmpty()
+
+        def ws = new GroovyWorkspaceService(context)
+        ws.pullConfiguration()
+        def caps = new ClientCapabilities()
+        def workspace = new WorkspaceClientCapabilities()
+        workspace.configuration = true
+        caps.workspace = workspace
+        context.clientCapabilities = caps
+        ws.pullConfiguration()
+
+        def fixture = new LspFixture(context)
+        fixture.server.context.clientCapabilities.workspace.configuration = true
+        fixture.configurationSection = [classpath: ['lib/a.jar'], grapeEnabled: true, dependencies: 'g:a:1']
+        fixture.server.workspaceService.pullConfiguration()
+        assert 'lib/a.jar' in fixture.server.context.classpath()
+        assert fixture.server.context.settings.grapeEnabled
+        assert fixture.server.context.extraSetting('dependencies') == 'g:a:1'
+
+        fixture.configurationReply = []
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationReply = ['not-a-map']
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationReply = [null]
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationCompletesNull = true
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationCompletesNull = false
+        fixture.configurationReturnsNullFuture = true
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationReturnsNullFuture = false
+        fixture.failConfiguration = true
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.failConfiguration = false
+        fixture.configurationHangs = true
+        long started = System.nanoTime()
+        fixture.server.workspaceService.pullConfiguration()
+        assert System.nanoTime() - started < TimeUnit.MILLISECONDS.toNanos(200)
+        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
+        fixture.server.context.documents.clear()
+        assert fixture.server.context.documentText(URI.create(uri))?.contains('class Hello')
+        fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams('not-a-map'))
+        fixture.close()
     }
 
     @Test
@@ -348,7 +361,7 @@ final class HelperCoverageTest {
     @Test
     void privateUtilityConstructors() {
         [Identifiers, PackageGuess, ClientFeatures, GroovyKeywords, Uris,
-         AstQuery, Positions].each { Class type ->
+         AstQuery, Positions, StaticTypeCheckingPass].each { Class type ->
             def ctor = type.getDeclaredConstructor()
             ctor.accessible = true
             ctor.newInstance()
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy
index 85ed873124..23065bb0a8 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy
@@ -22,6 +22,7 @@ import org.apache.groovy.lsp.GroovyLanguageServer
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams
 import org.eclipse.lsp4j.ApplyWorkspaceEditResponse
 import org.eclipse.lsp4j.ClientCapabilities
+import org.eclipse.lsp4j.ConfigurationParams
 import org.eclipse.lsp4j.DeclarationCapabilities
 import org.eclipse.lsp4j.DefinitionCapabilities
 import org.eclipse.lsp4j.Diagnostic
@@ -57,6 +58,12 @@ class LspFixture implements LanguageClient {
     final List<WorkDoneProgressCreateParams> progressCreates = []
     final List<ProgressParams> progressNotifications = []
     boolean failProgress
+    boolean failConfiguration
+    boolean configurationReturnsNullFuture
+    boolean configurationCompletesNull
+    boolean configurationHangs
+    List<Object> configurationReply
+    Map configurationSection
 
     LspFixture() {
         this(new LanguageServerContext())
@@ -139,6 +146,26 @@ class LspFixture implements LanguageClient {
         messages.add(message)
     }
 
+    @Override
+    CompletableFuture<List<Object>> configuration(ConfigurationParams params) {
+        if (failConfiguration) {
+            throw new IllegalStateException('no config')
+        }
+        if (configurationHangs) {
+            return new CompletableFuture<List<Object>>()
+        }
+        if (configurationReturnsNullFuture) {
+            return null
+        }
+        if (configurationCompletesNull) {
+            return CompletableFuture.completedFuture(null)
+        }
+        if (configurationReply != null) {
+            return CompletableFuture.completedFuture(configurationReply)
+        }
+        CompletableFuture.completedFuture([configurationSection])
+    }
+
     @Override
     CompletableFuture<Void> createProgress(WorkDoneProgressCreateParams params) {
         if (failProgress) {
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy
new file mode 100644
index 0000000000..ecaac94359
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy
@@ -0,0 +1,232 @@
+/*
+ *  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.intelligence
+
+import groovy.transform.TypeChecked
+import org.apache.groovy.lsp.internal.LspFixture
+import org.apache.groovy.lsp.internal.compile.AstQuery
+import org.apache.groovy.lsp.internal.compile.CompilerSettings
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.control.CompilationFailedException
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.Phases
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+import org.eclipse.lsp4j.HoverParams
+import org.eclipse.lsp4j.Position
+import org.eclipse.lsp4j.TextDocumentIdentifier
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.objectweb.asm.Opcodes
+
+final class StaticTypeCheckingPassTest {
+
+    private LspFixture fixture
+
+    @AfterEach
+    void tearDown() {
+        fixture?.close()
+    }
+
+    @Test
+    void applyNullAndEmptyUnitAreNoOps() {
+        StaticTypeCheckingPass.apply(null)
+        StaticTypeCheckingPass.apply(new CompilationUnit())
+        StaticTypeCheckingPass.bindTargets(null)
+    }
+
+    @Test
+    void uncompiledSourceHasNoModule() {
+        def unit = new CompilationUnit()
+        unit.addSource('Empty.groovy', 'class Empty {}')
+        StaticTypeCheckingPass.apply(unit)
+    }
+
+    @Test
+    void typeCheckedCallIsBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        def call = firstCall(moduleOf(unit), 'foo')
+        assert call?.methodTarget != null
+        assert call.methodTarget.name == 'foo'
+    }
+
+    @Test
+    void compileStaticCallIsBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.CompileStatic
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        assert firstCall(moduleOf(unit), 'foo')?.methodTarget != null
+    }
+
+    @Test
+    void methodLevelTypeCheckedCallIsBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            class Hello {
+                @groovy.transform.TypeChecked
+                def foo(String name) { foo("x") }
+                def bar() { bar() }
+            }
+            '''.stripIndent())
+        def module = moduleOf(unit)
+        assert firstCall(module, 'foo')?.methodTarget != null
+        assert firstCall(module, 'bar')?.methodTarget == null
+    }
+
+    @Test
+    void skipModeClassIsNotBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            import groovy.transform.TypeChecked
+            import groovy.transform.TypeCheckingMode
+            @TypeChecked(TypeCheckingMode.SKIP)
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        assert firstCall(moduleOf(unit), 'foo')?.methodTarget == null
+    }
+
+    @Test
+    void unannotatedCallIsNotBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        assert firstCall(moduleOf(unit), 'foo')?.methodTarget == null
+    }
+
+    @Test
+    void typeCheckedLocalUsesInferredTypeOnHover() {
+        fixture = new LspFixture()
+        def src = '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo() {
+                    def n = "x"
+                    n
+                }
+            }
+            '''.stripIndent()
+        def uri = fixture.open('Hello.groovy', src)
+        def hover = fixture.server.textDocumentService.hover(
+                new HoverParams(new TextDocumentIdentifier(uri), new Position(3, 12))).get()
+        assert hover != null
+        def value = hover.contents.isRight() ? hover.contents.getRight().value : hover.contents.getLeft()
+        assert value.toString().contains('String')
+    }
+
+    @Test
+    void typeCheckedUnknownCallAddsAnError() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo() { unknownMethod() }
+            }
+            '''.stripIndent())
+        assert unit.errorCollector.errorCount > 0
+    }
+
+    @Test
+    void throwingClassDoesNotDropTheUnit() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        def source = unit.iterator().next()
+        def call = firstCall(source.AST, 'foo')
+        source.AST.classes.add(null)
+        source.AST.classes.add(new ExplodingClassNode())
+        StaticTypeCheckingPass.apply(unit)
+        assert call?.methodTarget != null
+    }
+
+    @Test
+    void bindTargetsCopiesMethodNodeMetadataOnly() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        def module = moduleOf(unit)
+        def call = firstCall(module, 'foo')
+        def bound = call.methodTarget
+        assert bound != null
+        StaticTypeCheckingPass.apply(unit)
+        assert call.methodTarget == bound
+        call.methodTarget = null
+        call.putNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET, 'not-a-method')
+        StaticTypeCheckingPass.bindTargets(module.classes[0])
+        assert call.methodTarget == null
+        call.putNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET, bound)
+        StaticTypeCheckingPass.bindTargets(module.classes[0])
+        assert call.methodTarget == bound
+    }
+
+    private static CompilationUnit unitOf(String name, String src) {
+        def unit = new CompilationUnit(CompilerSettings.defaults().toConfiguration())
+        unit.addSource(name, src)
+        try {
+            unit.compile(Phases.SEMANTIC_ANALYSIS)
+        } catch (CompilationFailedException ignored) {
+        }
+        StaticTypeCheckingPass.apply(unit)
+        unit
+    }
+
+    private static ModuleNode moduleOf(CompilationUnit unit) {
+        unit.iterator().next().AST
+    }
+
+    private static MethodCallExpression firstCall(ModuleNode module, String name) {
+        def call = null
+        AstQuery.walk(module, { node, ctx ->
+            if (node instanceof MethodCallExpression && node.methodAsString == name && call == null) {
+                call = node
+            }
+        })
+        call
+    }
+
+    private static final class ExplodingClassNode extends ClassNode {
+        ExplodingClassNode() {
+            super('Explode', Opcodes.ACC_PUBLIC, ClassHelper.OBJECT_TYPE)
+            addAnnotation(new AnnotationNode(ClassHelper.make(TypeChecked)))
+        }
+
+        @Override
+        List<MethodNode> getMethods() {
+            throw new IllegalStateException('boom')
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy
index ea0037f53c..4289330d9b 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy
@@ -30,6 +30,7 @@ import org.eclipse.lsp4j.DocumentFormattingParams
 import org.eclipse.lsp4j.DocumentHighlightParams
 import org.eclipse.lsp4j.HoverParams
 import org.eclipse.lsp4j.ImplementationParams
+import org.eclipse.lsp4j.InitializedParams
 import org.eclipse.lsp4j.PrepareRenameParams
 import org.eclipse.lsp4j.ReferenceContext
 import org.eclipse.lsp4j.ReferenceParams
@@ -127,11 +128,18 @@ final class ProtocolCoverageTest {
     @Test
     void configurationAndFolderChangesUpdateSettings() {
         fixture = new LspFixture()
+        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
         fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams(
-                [groovy: [grapeEnabled: true, astTestEnabled: true, classpath: ['.', null], sourcePaths: ['src']]]))
+                [groovy: [grapeEnabled: true, astTestEnabled: true, classpath: ['.', null],
+                          sourcePaths: ['src'], dependencies: 'g:a:1']]))
         assert fixture.server.context.settings.grapeEnabled
         assert fixture.server.context.settings.astTestEnabled
         assert '.' in fixture.server.context.settings.classpath
+        assert fixture.server.context.extraSetting('dependencies') == 'g:a:1'
+        assert fixture.server.context.extraSetting(null) == null
+        assert fixture.server.context.documentText(URI.create(uri))?.contains('class Hello')
+        assert fixture.server.context.positionEncoding() in ['utf-16', 'utf-8', 'utf-32']
+        assert 'src' in fixture.server.context.sourcePaths()
         fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams(
                 [grapeEnabled: false, classpath: 'not-a-list']))
         assert !fixture.server.context.settings.grapeEnabled
@@ -145,6 +153,11 @@ final class ProtocolCoverageTest {
         assert !fixture.server.context.workspaceFolders.contains(Uris.normalize(folder.toUri()))
         fixture.server.workspaceService.didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(
                 new WorkspaceFoldersChangeEvent([], [])))
+        fixture.configurationSection = [classpath: ['from-pull.jar'], gdsl: 'x.gdsl']
+        fixture.server.context.clientCapabilities.workspace.configuration = true
+        fixture.server.initialized(new InitializedParams())
+        assert 'from-pull.jar' in fixture.server.context.classpath()
+        assert fixture.server.context.extraSetting('gdsl') == 'x.gdsl'
     }
 
     @Test
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy
deleted file mode 100644
index 2c43ae9ade..0000000000
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy
+++ /dev/null
@@ -1,250 +0,0 @@
-/*
- *  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.spi
-
-import org.apache.groovy.lsp.internal.ExtensionHost
-import org.apache.groovy.lsp.internal.LanguageServerContext
-import org.apache.groovy.lsp.internal.LspFixture
-import org.apache.groovy.lsp.internal.compile.AstQuery
-import org.apache.groovy.lsp.internal.feature.SupportServices
-import org.apache.groovy.lsp.internal.position.PositionEncoding
-import org.apache.groovy.lsp.internal.protocol.ServerCapabilityFactory
-import org.apache.groovy.lsp.spi.GroovyLspExtension
-import org.apache.groovy.lsp.spi.GroovyLspSession
-import org.codehaus.groovy.control.CompilationUnit
-import org.eclipse.lsp4j.CodeAction
-import org.eclipse.lsp4j.CodeActionContext
-import org.eclipse.lsp4j.Command
-import org.eclipse.lsp4j.CodeActionParams
-import org.eclipse.lsp4j.Diagnostic
-import org.eclipse.lsp4j.DocumentDiagnosticParams
-import org.eclipse.lsp4j.DidChangeConfigurationParams
-import org.eclipse.lsp4j.ExecuteCommandParams
-import org.eclipse.lsp4j.HoverParams
-import org.eclipse.lsp4j.Position
-import org.eclipse.lsp4j.Range
-import org.eclipse.lsp4j.TextDocumentIdentifier
-import org.codehaus.groovy.ast.ModuleNode
-import org.codehaus.groovy.control.CompilerConfiguration
-import org.codehaus.groovy.ast.expr.MethodCallExpression
-import org.eclipse.lsp4j.jsonrpc.messages.Either
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.Test
-
-final class GroovyLspExtensionTest {
-
-    private LspFixture fixture
-
-    @AfterEach
-    void tearDown() {
-        fixture?.close()
-    }
-
-    @Test
-    void afterCompileCommandDiagnosticsAndCodeAction() {
-        def plugin = new RecordingExtension()
-        fixture = new LspFixture(new LanguageServerContext(ExtensionHost.of(plugin)))
-        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
-        assert plugin.compiles >= 1
-        assert fixture.publishedDiagnostics().any {
-            SupportServices.diagnosticMessage(it) == 'from-plugin'
-        }
-        def result = fixture.server.workspaceService.executeCommand(
-                new ExecuteCommandParams('groovy.lsp.extension.ping', [])).get()
-        assert result == 'pong'
-        def actions = fixture.server.textDocumentService.codeAction(
-                new CodeActionParams(new TextDocumentIdentifier(uri),
-                        new Range(new Position(0, 0), new Position(0, 1)),
-                        new CodeActionContext([]))).get()
-        assert actions.any { it.isRight() && it.getRight().title == 'Plugin action' }
-        def caps = new ServerCapabilityFactory().create(PositionEncoding.UTF16, plugin.commands())
-        assert 'groovy.lsp.extension.ping' in caps.executeCommandProvider.commands
-        assert 'groovy.lsp.organizeImports' in caps.executeCommandProvider.commands
-        def pull = fixture.server.textDocumentService.diagnostic(
-                new DocumentDiagnosticParams(new TextDocumentIdentifier(uri))).get()
-        def items = pull.getLeft().items
-        assert items.any { SupportServices.diagnosticMessage(it) == 'from-plugin' }
-    }
-
-    @Test
-    void throwingPluginDoesNotAbortCompile() {
-        def boom = new GroovyLspExtension() {
-            void afterCompile(CompilationUnit unit) {
-                throw new IllegalStateException('boom')
-            }
-        }
-        fixture = new LspFixture(new LanguageServerContext(ExtensionHost.of(boom)))
-        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
-        assert fixture.server.context.snapshot.get(uri).module != null
-    }
-
-    @Test
-    void unknownCommandStillReturnsNullWithoutPlugins() {
-        fixture = new LspFixture()
-        assert fixture.server.workspaceService.executeCommand(
-                new ExecuteCommandParams('unknown', [])).get() == null
-        assert ExtensionHost.none().ids().isEmpty()
-        assert ExtensionHost.of().ids().isEmpty()
-        assert 'groovy.lsp.stc' in ExtensionHost.discover(GroovyLspExtensionTest.classLoader).ids()
-    }
-
-    @Test
-    void sessionExposesFoldersTextSettingsAndEncoding() {
-        def plugin = new RecordingExtension()
-        fixture = new LspFixture(new LanguageServerContext(ExtensionHost.of(plugin)))
-        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
-        fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams(
-                [groovy: [classpath: ['lib/a.jar'], dependencies: 'g:a:1', gdsl: 'gdsl.groovy']]))
-        assert plugin.initializedSessions == 1
-        def session = fixture.server.context
-        assert session.documentText(URI.create(uri))?.contains('class Hello')
-        assert session.workspaceFolders() != null
-        assert session.extraSetting('dependencies') == 'g:a:1'
-        assert session.extraSetting('gdsl') == 'gdsl.groovy'
-        assert session.extraSetting(null) == null
-        assert session.positionEncoding() in ['utf-16', 'utf-8', 'utf-32']
-        assert 'lib/a.jar' in session.classpath()
-        assert plugin.configuredWithSession
-        assert plugin.lastModule != null
-        assert plugin.lastSourceText?.contains('class Hello')
-        fixture.close()
-        fixture = null
-        assert plugin.shutdowns == 1
-    }
-
-    @Test
-    void throwingIdAndCommandsDoNotAbortHost() {
-        def boom = new GroovyLspExtension() {
-            String id() { throw new IllegalStateException('id') }
-            List<String> commands() { throw new IllegalStateException('commands') }
-        }
-        def host = ExtensionHost.of(boom)
-        assert boom.class.name in host.ids()
-        assert host.commands().isEmpty()
-    }
-
-    @Test
-    void typeCheckedCallIsBoundByDefaultExtension() {
-        fixture = new LspFixture()
-        def src = '''\
-            @groovy.transform.TypeChecked
-            class Hello {
-                def foo(String name) { foo("x") }
-            }
-            '''.stripIndent()
-        def uri = fixture.open('Hello.groovy', src)
-        def module = fixture.server.context.snapshot.get(uri).module
-        def call = null
-        AstQuery.walk(module, { node, ctx ->
-            if (node instanceof MethodCallExpression && node.methodAsString == 'foo') {
-                call = node
-            }
-        })
-        assert call?.methodTarget != null
-        assert call.methodTarget.name == 'foo'
-    }
-
-    @Test
-    void typeCheckedLocalUsesInferredTypeOnHover() {
-        fixture = new LspFixture()
-        def src = '''\
-            @groovy.transform.TypeChecked
-            class Hello {
-                def foo() {
-                    def n = "x"
-                    n
-                }
-            }
-            '''.stripIndent()
-        def uri = fixture.open('Hello.groovy', src)
-        def hover = fixture.server.textDocumentService.hover(
-                new HoverParams(new TextDocumentIdentifier(uri), new Position(3, 12))).get()
-        assert hover != null
-        def value = hover.contents.isRight() ? hover.contents.getRight().value : hover.contents.getLeft()
-        assert value.toString().contains('String')
-    }
-
-    private static final class RecordingExtension implements GroovyLspExtension {
-        int compiles
-        int initializedSessions
-        int shutdowns
-        boolean configuredWithSession
-        ModuleNode lastModule
-        String lastSourceText
-
-        @Override
-        String id() {
-            'test.ping'
-        }
-
-        @Override
-        void configure(CompilerConfiguration configuration, GroovyLspSession session) {
-            configuredWithSession = session != null
-        }
-
-        @Override
-        void initialized(GroovyLspSession session) {
-            initializedSessions++
-        }
-
-        @Override
-        void shutdown() {
-            shutdowns++
-        }
-
-        @Override
-        void afterCompile(CompilationUnit unit) {
-            compiles++
-        }
-
-        @Override
-        List<Diagnostic> extraDiagnostics(URI uri, String sourceText, ModuleNode module) {
-            lastModule = module
-            lastSourceText = sourceText
-            extraDiagnostics(uri, sourceText)
-        }
-
-        @Override
-        List<Diagnostic> extraDiagnostics(URI uri, String sourceText) {
-            [new Diagnostic(new Range(new Position(0, 0), new Position(0, 1)), 'from-plugin')]
-        }
-
-        @Override
-        List<Either<Command, CodeAction>> extraCodeActions(URI uri, String sourceText,
-                                                           List<Diagnostic> diagnostics, Range range) {
-            lastSourceText = sourceText
-            extraCodeActions(uri, diagnostics, range)
-        }
-
-        @Override
-        List<Either<Command, CodeAction>> extraCodeActions(URI uri, List<Diagnostic> diagnostics, Range range) {
-            [Either.forRight(new CodeAction('Plugin action'))]
-        }
-
-        @Override
-        List<String> commands() {
-            ['groovy.lsp.extension.ping']
-        }
-
-        @Override
-        Object executeCommand(String command, List<Object> arguments, GroovyLspSession session) {
-            'pong'
-        }
-    }
-}