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/14 18:19:52 UTC
(groovy) 01/01: GROOVY-12411: Add a first-party Language Server Protocol implementation (groovy-lsp)
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 2b7a787c8b4898af7e1868a88a2204e868b60a78
Author: Daniel Sun <su...@apache.org>
AuthorDate: Tue Sep 15 02:30:41 2026 +0900
GROOVY-12411: Add a first-party Language Server Protocol implementation (groovy-lsp)
---
AGENTS.md | 1 +
ARCHITECTURE.md | 13 +-
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 +
.../apache/groovy/parser/antlr4/AstBuilder.java | 19 +-
.../parser/antlr4/util/PositionConfigureUtils.java | 73 ++
src/main/java/org/codehaus/groovy/ast/ASTNode.java | 22 +-
.../org/codehaus/groovy/ast/AnnotatedNode.java | 117 ++++
.../org/codehaus/groovy/ast/query/AstQuery.java | 55 +-
.../groovy/control/messages/LocatedMessage.java | 9 +-
.../antlr4/AnnotatedNodeNamePositionTest.groovy | 189 +++++
.../antlr4/PositionConfigureUtilsTest.groovy | 47 ++
.../codehaus/groovy/ast/query/AstQueryTest.groovy | 21 +
.../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 | 32 +
subprojects/groovy-lsp/ARCHITECTURE.md | 126 ++++
subprojects/groovy-lsp/README.adoc | 27 +
subprojects/groovy-lsp/build.gradle | 45 ++
.../apache/groovy/lsp/GroovyLanguageServer.java | 131 ++++
.../groovy/lsp/GroovyLanguageServerLauncher.java | 129 ++++
.../groovy/lsp/internal/LanguageServerContext.java | 352 ++++++++++
.../groovy/lsp/internal/compile/AstQuery.java | 264 +++++++
.../lsp/internal/compile/CompilationSnapshot.java | 86 +++
.../lsp/internal/compile/CompiledDocument.java | 95 +++
.../lsp/internal/compile/CompilerSettings.java | 138 ++++
.../lsp/internal/compile/GroovyCompiler.java | 217 ++++++
.../groovy/lsp/internal/compile/Identifiers.java | 67 ++
.../groovy/lsp/internal/compile/ImportSupport.java | 297 ++++++++
.../groovy/lsp/internal/compile/PackageGuess.java | 74 ++
.../groovy/lsp/internal/compile/TypeIndex.java | 151 ++++
.../lsp/internal/compile/WorkspaceLayout.java | 139 ++++
.../lsp/internal/compile/WorkspaceScanner.java | 94 +++
.../internal/diagnostic/DiagnosticConverter.java | 127 ++++
.../lsp/internal/feature/CodeActionService.java | 387 +++++++++++
.../lsp/internal/feature/CompletionService.java | 440 ++++++++++++
.../lsp/internal/feature/HierarchyService.java | 401 +++++++++++
.../groovy/lsp/internal/feature/HoverService.java | 184 +++++
.../lsp/internal/feature/NavigationService.java | 617 +++++++++++++++++
.../groovy/lsp/internal/feature/RenameService.java | 156 +++++
.../lsp/internal/feature/SourceGeneration.java | 303 ++++++++
.../lsp/internal/feature/SupportServices.java | 761 +++++++++++++++++++++
.../lsp/internal/feature/SymbolIdentity.java | 276 ++++++++
.../groovy/lsp/internal/feature/SymbolService.java | 194 ++++++
.../lsp/internal/position/PositionEncoding.java | 90 +++
.../groovy/lsp/internal/position/Positions.java | 355 ++++++++++
.../lsp/internal/protocol/ClientFeatures.java | 77 +++
.../protocol/GroovyTextDocumentService.java | 462 +++++++++++++
.../internal/protocol/GroovyWorkspaceService.java | 144 ++++
.../internal/protocol/ServerCapabilityFactory.java | 114 +++
.../groovy/lsp/internal/util/GroovyKeywords.java | 46 ++
.../org/apache/groovy/lsp/internal/util/Uris.java | 100 +++
.../lsp/internal/workspace/DocumentStore.java | 134 ++++
.../lsp/internal/workspace/TextDocument.java | 191 ++++++
.../groovy-lsp/src/spec/doc/groovy-lsp.adoc | 105 +++
.../groovy/lsp/GroovyLanguageServerTest.groovy | 184 +++++
.../apache/groovy/lsp/internal/LspFixture.groovy | 123 ++++
.../lsp/internal/compile/CompilerSafetyTest.groovy | 83 +++
.../internal/compile/WorkspaceLayoutTest.groovy | 136 ++++
.../diagnostic/DiagnosticConverterTest.groovy | 79 +++
.../lsp/internal/feature/JdtInspiredTest.groovy | 142 ++++
.../internal/feature/LanguageQualityTest.groovy | 211 ++++++
.../lsp/internal/feature/MetalsInspiredTest.groovy | 174 +++++
.../internal/feature/NavigationIdentityTest.groovy | 216 ++++++
.../internal/feature/SupportServicesTest.groovy | 145 ++++
.../lsp/internal/position/PositionsTest.groovy | 157 +++++
.../internal/protocol/ProtocolCoverageTest.groovy | 100 +++
.../internal/workspace/DocumentStoreTest.groovy | 102 +++
versions.properties | 1 +
79 files changed, 10976 insertions(+), 24 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..6488325594 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,12 @@ 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`). Declarations that have an
+ identifier also carry a name span on `AnnotatedNode`
+ (`nameLineNumber` / `nameColumnNumber` / `nameLastLineNumber` /
+ `nameLastColumnNumber`), stamped from the identifier token so
+ language servers can select the name rather than the whole
+ declaration.
- Visitors: `GroovyCodeVisitor` (expression + statement),
`GroovyClassVisitor` (class members), with `ClassCodeVisitorSupport`
/ `CodeVisitorSupport` as bases, and
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index a1d75e4ad5..c15ff0a217 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>
@@ -2666,6 +2667,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/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index dde9972427..24db9fd776 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -166,6 +166,7 @@ import static groovy.lang.Tuple.tuple;
import static org.apache.groovy.parser.antlr4.GroovyParser.*;
import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureAST;
import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureEndPosition;
+import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureNamePosition;
import static org.codehaus.groovy.ast.tools.GeneralUtils.assignX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.block;
import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX;
@@ -1470,6 +1471,7 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
}
configureAST(classNode, ctx);
+ configureNamePosition(classNode, ctx.identifier());
classNode.setSyntheticPublic(syntheticPublic);
classNode.setGenericsTypes(this.visitTypeParameters(ctx.typeParameters()));
boolean isInterfaceWithDefaultMethods = (isInterface && this.containsDefaultOrPrivateMethods(ctx));
@@ -1957,6 +1959,7 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
}
configureAST(methodNode, ctx);
+ configureNamePosition(methodNode, ctx.methodName());
validateMethodDeclaration(ctx, methodNode, modifierManager, classNode);
@@ -2333,6 +2336,8 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
} else {
configureAST(propertyNode, startNode, initialValue);
}
+ configureNamePosition(fieldNode, startNode);
+ configureNamePosition(propertyNode, startNode);
return propertyNode;
}
@@ -2372,6 +2377,7 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
} else {
configureAST(fieldNode, variableExpression, initialValue);
}
+ configureNamePosition(fieldNode, variableExpression);
}
private boolean isFieldDeclaration(final ModifierManager modifierManager, final ClassNode classNode) {
@@ -5265,12 +5271,15 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
Parameter parameter =
modifierManager
.processParameter(
- configureAST(
- new Parameter(
- classNode,
- this.visitVariableDeclaratorId(variableDeclaratorIdContext).getName()
+ configureNamePosition(
+ configureAST(
+ new Parameter(
+ classNode,
+ this.visitVariableDeclaratorId(variableDeclaratorIdContext).getName()
+ ),
+ ctx
),
- ctx
+ variableDeclaratorIdContext
)
);
parameter.putNodeMetaData(PARAMETER_MODIFIER_MANAGER, modifierManager);
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/util/PositionConfigureUtils.java b/src/main/java/org/apache/groovy/parser/antlr4/util/PositionConfigureUtils.java
index 84c959173f..4fad0cc5ba 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/util/PositionConfigureUtils.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/util/PositionConfigureUtils.java
@@ -19,10 +19,12 @@
package org.apache.groovy.parser.antlr4.util;
import groovy.lang.Tuple2;
+import org.antlr.v4.runtime.ParserRuleContext;
import org.antlr.v4.runtime.Token;
import org.antlr.v4.runtime.tree.TerminalNode;
import org.apache.groovy.parser.antlr4.GroovyParser;
import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
import static groovy.lang.Tuple.tuple;
@@ -165,6 +167,77 @@ public class PositionConfigureUtils {
return astNode;
}
+ /**
+ * Sets the identifier span of {@code node} from a single token. No-op when
+ * {@code node} or {@code token} is {@code null}.
+ *
+ * @param node annotated node whose name span should be set
+ * @param token token covering the identifier
+ * @return {@code node}
+ */
+ public static <T extends AnnotatedNode> T configureNamePosition(T node, Token token) {
+ if (node == null || token == null) {
+ return node;
+ }
+ node.setNameLineNumber(token.getLine());
+ node.setNameColumnNumber(token.getCharPositionInLine() + 1);
+ Tuple2<Integer, Integer> end = endPosition(token);
+ node.setNameLastLineNumber(end.getV1());
+ node.setNameLastColumnNumber(end.getV2());
+ return node;
+ }
+
+ /**
+ * Sets the identifier span of {@code node} from a parse-tree context
+ * (start token through exclusive end of the stop token). No-op when
+ * {@code node} or {@code ctx} is {@code null}, or when {@code ctx} has
+ * no start token.
+ *
+ * @param node annotated node whose name span should be set
+ * @param ctx parse context covering the identifier
+ * @return {@code node}
+ */
+ public static <T extends AnnotatedNode> T configureNamePosition(T node, ParserRuleContext ctx) {
+ if (node == null || ctx == null) {
+ return node;
+ }
+ Token start = ctx.getStart();
+ if (start == null) {
+ return node;
+ }
+ node.setNameLineNumber(start.getLine());
+ node.setNameColumnNumber(start.getCharPositionInLine() + 1);
+ Token stop = ctx.getStop();
+ if (stop == null) {
+ Tuple2<Integer, Integer> end = endPosition(start);
+ node.setNameLastLineNumber(end.getV1());
+ node.setNameLastColumnNumber(end.getV2());
+ } else {
+ Tuple2<Integer, Integer> end = endPosition(stop);
+ node.setNameLastLineNumber(end.getV1());
+ node.setNameLastColumnNumber(end.getV2());
+ }
+ return node;
+ }
+
+ /**
+ * Sets the identifier span of {@code node} from another AST node. When
+ * {@code source} is an {@link AnnotatedNode} with a name span, that span
+ * is copied; otherwise the source node's full span is used. No-op when
+ * {@code node} or {@code source} is {@code null}.
+ *
+ * @param node annotated node whose name span should be set
+ * @param source node supplying the identifier span
+ * @return {@code node}
+ */
+ public static <T extends AnnotatedNode> T configureNamePosition(T node, ASTNode source) {
+ if (node == null || source == null) {
+ return node;
+ }
+ node.setNamePosition(source);
+ return node;
+ }
+
/**
* {@link ASTNode} defaults to {@code -1}; line numbers are 1-based, so a
* last line of {@code 0} is also treated as missing.
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/ast/AnnotatedNode.java b/src/main/java/org/codehaus/groovy/ast/AnnotatedNode.java
index a324992a12..ac254341d7 100644
--- a/src/main/java/org/codehaus/groovy/ast/AnnotatedNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/AnnotatedNode.java
@@ -32,6 +32,10 @@ public class AnnotatedNode extends ASTNode implements GroovydocHolder<AnnotatedN
private List<AnnotationNode> annotations = Collections.emptyList();
private ClassNode declaringClass;
private boolean synthetic;
+ private int nameLineNumber = -1;
+ private int nameColumnNumber = -1;
+ private int nameLastLineNumber = -1;
+ private int nameLastColumnNumber = -1;
/**
* Returns all annotations attached to this AST node.
@@ -193,4 +197,117 @@ public class AnnotatedNode extends ASTNode implements GroovydocHolder<AnnotatedN
public void setSynthetic(boolean synthetic) {
this.synthetic = synthetic;
}
+
+ /**
+ * Copies the node's source span and, when {@code node} is an
+ * {@code AnnotatedNode} with a name span, the name span as well.
+ */
+ @Override
+ public void setSourcePosition(final ASTNode node) {
+ super.setSourcePosition(node);
+ if (node instanceof AnnotatedNode annotated && annotated.hasNamePosition()) {
+ setNamePosition(annotated);
+ }
+ }
+
+ /**
+ * Returns whether this node has an authored identifier span distinct from
+ * its full source span. Tooling uses this for rename, highlight, and the
+ * selection range of a definition (the name, not the whole declaration).
+ *
+ * @return {@code true} when {@link #getNameLineNumber()} is greater than 0
+ */
+ public boolean hasNamePosition() {
+ return nameLineNumber > 0 && nameColumnNumber > 0;
+ }
+
+ /**
+ * Copies the identifier span from {@code node}. When {@code node} is an
+ * {@code AnnotatedNode} that already has a name span, that span is used;
+ * otherwise the node's full source span is used.
+ *
+ * @param node the node supplying the identifier span
+ */
+ public void setNamePosition(final ASTNode node) {
+ if (node == null) {
+ return;
+ }
+ if (node instanceof AnnotatedNode annotated && annotated.hasNamePosition()) {
+ this.nameLineNumber = annotated.nameLineNumber;
+ this.nameColumnNumber = annotated.nameColumnNumber;
+ this.nameLastLineNumber = annotated.nameLastLineNumber;
+ this.nameLastColumnNumber = annotated.nameLastColumnNumber;
+ return;
+ }
+ this.nameLineNumber = node.getLineNumber();
+ this.nameColumnNumber = node.getColumnNumber();
+ this.nameLastLineNumber = node.getLastLineNumber();
+ this.nameLastColumnNumber = node.getLastColumnNumber();
+ }
+
+ /**
+ * Returns the 1-based line of the identifier, or {@code -1} when unset.
+ */
+ public int getNameLineNumber() {
+ return nameLineNumber;
+ }
+
+ /**
+ * Sets the 1-based line of the identifier. Use {@code -1} when unset.
+ *
+ * @param nameLineNumber the identifier start line
+ */
+ public void setNameLineNumber(final int nameLineNumber) {
+ this.nameLineNumber = nameLineNumber;
+ }
+
+ /**
+ * Returns the 1-based, code-point column of the identifier start, or
+ * {@code -1} when unset.
+ */
+ public int getNameColumnNumber() {
+ return nameColumnNumber;
+ }
+
+ /**
+ * Sets the 1-based, code-point column of the identifier start.
+ *
+ * @param nameColumnNumber the identifier start column
+ */
+ public void setNameColumnNumber(final int nameColumnNumber) {
+ this.nameColumnNumber = nameColumnNumber;
+ }
+
+ /**
+ * Returns the 1-based line where the identifier ends, or {@code -1} when unset.
+ */
+ public int getNameLastLineNumber() {
+ return nameLastLineNumber;
+ }
+
+ /**
+ * Sets the 1-based line where the identifier ends.
+ *
+ * @param nameLastLineNumber the identifier end line
+ */
+ public void setNameLastLineNumber(final int nameLastLineNumber) {
+ this.nameLastLineNumber = nameLastLineNumber;
+ }
+
+ /**
+ * Returns the exclusive 1-based, code-point end column of the identifier,
+ * or {@code -1} when unset.
+ */
+ public int getNameLastColumnNumber() {
+ return nameLastColumnNumber;
+ }
+
+ /**
+ * Sets the exclusive 1-based, code-point end column of the identifier.
+ *
+ * @param nameLastColumnNumber the identifier exclusive end column
+ */
+ public void setNameLastColumnNumber(final int nameLastColumnNumber) {
+ this.nameLastColumnNumber = nameLastColumnNumber;
+ }
}
diff --git a/src/main/java/org/codehaus/groovy/ast/query/AstQuery.java b/src/main/java/org/codehaus/groovy/ast/query/AstQuery.java
index 27241c7560..50c7095d4f 100644
--- a/src/main/java/org/codehaus/groovy/ast/query/AstQuery.java
+++ b/src/main/java/org/codehaus/groovy/ast/query/AstQuery.java
@@ -25,6 +25,7 @@ import org.codehaus.groovy.ast.FieldNode;
import org.codehaus.groovy.ast.InnerClassNode;
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.ArrayExpression;
import org.codehaus.groovy.ast.expr.AttributeExpression;
@@ -121,6 +122,8 @@ import java.util.stream.Stream;
* descending at a boundary type (for example {@code ClosureExpression}) and {@link #into(Class[])}
* to opt back in past a default boundary. Nested classes are currently the only default boundary,
* so {@code into(ClassNode.class)} is the only case in which {@link #into(Class[])} has an effect.
+ * Method, constructor, closure, for-loop and catch parameters are included as candidates;
+ * {@link ClassCodeVisitorSupport} visits only their annotations.
*
* <h2>Notes</h2>
* <ul>
@@ -489,7 +492,29 @@ public final class AstQuery<T extends ASTNode> {
@Override
protected void visitConstructorOrMethod(final MethodNode node, final boolean isConstructor) {
- enter(node, () -> super.visitConstructorOrMethod(node, isConstructor));
+ enter(node, () -> {
+ visitAnnotations(node);
+ for (Parameter parameter : node.getParameters()) {
+ visitParameterNode(parameter);
+ }
+ visitClassCodeContainer(node.getCode());
+ });
+ }
+
+ /**
+ * Parameters are not {@link ASTNode} children of a method in the default
+ * visitor walk, so queries would otherwise never see them.
+ */
+ private void visitParameterNode(final Parameter parameter) {
+ if (parameter == null || "forLoopDummyParameter".equals(parameter.getName())) {
+ return;
+ }
+ enter(parameter, () -> {
+ visitAnnotations(parameter);
+ if (parameter.hasInitialExpression()) {
+ parameter.getInitialExpression().visit(this);
+ }
+ });
}
@Override
@@ -505,7 +530,13 @@ public final class AstQuery<T extends ASTNode> {
// ---- statements ----
@Override public void visitBlockStatement(BlockStatement s) { enter(s, () -> super.visitBlockStatement(s)); }
- @Override public void visitForLoop(ForStatement s) { enter(s, () -> super.visitForLoop(s)); }
+ @Override public void visitForLoop(ForStatement s) {
+ enter(s, () -> {
+ visitParameterNode(s.getIndexVariable());
+ visitParameterNode(s.getValueVariable());
+ super.visitForLoop(s);
+ });
+ }
@Override public void visitWhileLoop(WhileStatement s) { enter(s, () -> super.visitWhileLoop(s)); }
@Override public void visitDoWhileLoop(DoWhileStatement s) { enter(s, () -> super.visitDoWhileLoop(s)); }
@Override public void visitIfElse(IfStatement s) { enter(s, () -> super.visitIfElse(s)); }
@@ -519,7 +550,12 @@ public final class AstQuery<T extends ASTNode> {
@Override public void visitContinueStatement(ContinueStatement s) { enter(s, () -> super.visitContinueStatement(s)); }
@Override public void visitThrowStatement(ThrowStatement s) { enter(s, () -> super.visitThrowStatement(s)); }
@Override public void visitSynchronizedStatement(SynchronizedStatement s) { enter(s, () -> super.visitSynchronizedStatement(s)); }
- @Override public void visitCatchStatement(CatchStatement s) { enter(s, () -> super.visitCatchStatement(s)); }
+ @Override public void visitCatchStatement(CatchStatement s) {
+ enter(s, () -> {
+ visitParameterNode(s.getVariable());
+ super.visitCatchStatement(s);
+ });
+ }
@Override public void visitEmptyStatement(EmptyStatement s) { enter(s, () -> super.visitEmptyStatement(s)); }
@Override public void visitYieldStatement(YieldStatement s) { enter(s, () -> super.visitYieldStatement(s)); }
@Override public void visitSwitchExpression(SwitchExpression e) { enter(e, () -> super.visitSwitchExpression(e)); }
@@ -534,7 +570,18 @@ public final class AstQuery<T extends ASTNode> {
@Override public void visitPrefixExpression(PrefixExpression e) { enter(e, () -> super.visitPrefixExpression(e)); }
@Override public void visitPostfixExpression(PostfixExpression e) { enter(e, () -> super.visitPostfixExpression(e)); }
@Override public void visitBooleanExpression(BooleanExpression e) { enter(e, () -> super.visitBooleanExpression(e)); }
- @Override public void visitClosureExpression(ClosureExpression e) { enter(e, () -> super.visitClosureExpression(e)); }
+ @Override public void visitClosureExpression(ClosureExpression e) {
+ enter(e, () -> {
+ if (e.isParameterSpecified()) {
+ for (Parameter parameter : e.getParameters()) {
+ visitParameterNode(parameter);
+ }
+ }
+ if (e.getCode() != null) {
+ e.getCode().visit(this);
+ }
+ });
+ }
@Override public void visitTupleExpression(TupleExpression e) { enter(e, () -> super.visitTupleExpression(e)); }
@Override public void visitListExpression(ListExpression e) { enter(e, () -> super.visitListExpression(e)); }
@Override public void visitMapExpression(MapExpression e) { enter(e, () -> super.visitMapExpression(e)); }
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/apache/groovy/parser/antlr4/AnnotatedNodeNamePositionTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/AnnotatedNodeNamePositionTest.groovy
new file mode 100644
index 0000000000..9fbf44db34
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/AnnotatedNodeNamePositionTest.groovy
@@ -0,0 +1,189 @@
+/*
+ * 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.parser.antlr4
+
+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.control.CompilationUnit
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.Phases
+import org.junit.jupiter.api.Test
+
+/**
+ * Identifier spans on {@link org.codehaus.groovy.ast.AnnotatedNode} are the
+ * name itself, not the whole declaration. Language servers use this for
+ * rename, highlight, and the selection range of a definition.
+ */
+final class AnnotatedNodeNamePositionTest {
+
+ @Test
+ void classNameSpanIsTheIdentifier() {
+ ClassNode cn = compileClass('class Hello {\n}\n')
+ assert cn.hasNamePosition()
+ assert [cn.nameLineNumber, cn.nameColumnNumber, cn.nameLastLineNumber, cn.nameLastColumnNumber] == [1, 7, 1, 12]
+ assert cn.columnNumber == 1
+ assert cn.lastLineNumber >= cn.nameLastLineNumber
+ }
+
+ @Test
+ void methodNameSpanIsTheIdentifier() {
+ MethodNode mn = compileClass('''\
+ class Hello {
+ def foo() {}
+ }
+ '''.stripIndent()).getDeclaredMethods('foo')[0]
+ assert mn.hasNamePosition()
+ assert mn.nameLineNumber == 2
+ assert mn.nameColumnNumber < mn.nameLastColumnNumber
+ String line = ' def foo() {}'
+ String name = line.substring(mn.nameColumnNumber - 1, mn.nameLastColumnNumber - 1)
+ assert name == 'foo'
+ assert mn.columnNumber < mn.nameColumnNumber
+ }
+
+ @Test
+ void fieldNameSpanIsTheIdentifier() {
+ FieldNode field = compileClass('''\
+ class Hello {
+ private int answer = 42
+ }
+ '''.stripIndent()).getDeclaredField('answer')
+ assert field.hasNamePosition()
+ String line = ' private int answer = 42'
+ String name = line.substring(field.nameColumnNumber - 1, field.nameLastColumnNumber - 1)
+ assert name == 'answer'
+ }
+
+ @Test
+ void propertyNameSpanIsTheIdentifier() {
+ def property = compileClass('''\
+ class Hello {
+ String name
+ }
+ '''.stripIndent()).getProperty('name')
+ assert property.hasNamePosition()
+ String line = ' String name'
+ String name = line.substring(property.nameColumnNumber - 1, property.nameLastColumnNumber - 1)
+ assert name == 'name'
+ }
+
+ @Test
+ void parameterNameSpanIsTheIdentifier() {
+ Parameter param = compileClass('''\
+ class Hello {
+ def foo(String bar) {}
+ }
+ '''.stripIndent()).getDeclaredMethods('foo')[0].parameters[0]
+ assert param.hasNamePosition()
+ String line = ' def foo(String bar) {}'
+ String name = line.substring(param.nameColumnNumber - 1, param.nameLastColumnNumber - 1)
+ assert name == 'bar'
+ assert param.columnNumber < param.nameColumnNumber
+ }
+
+ @Test
+ void setNamePositionCopiesFromAnnotatedNodeWithNameSpan() {
+ def source = compileClass('class Hello {\n}\n')
+ def target = new ClassNode('Other', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ target.setNamePosition(source)
+ assert target.hasNamePosition()
+ assert [target.nameLineNumber, target.nameColumnNumber, target.nameLastLineNumber, target.nameLastColumnNumber] ==
+ [source.nameLineNumber, source.nameColumnNumber, source.nameLastLineNumber, source.nameLastColumnNumber]
+ }
+
+ @Test
+ void setNamePositionFallsBackToFullSpan() {
+ def source = new org.codehaus.groovy.ast.ASTNode()
+ source.lineNumber = 4
+ source.columnNumber = 2
+ source.lastLineNumber = 4
+ source.lastColumnNumber = 9
+ def target = new ClassNode('Other', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ target.setNamePosition(source)
+ assert [target.nameLineNumber, target.nameColumnNumber, target.nameLastLineNumber, target.nameLastColumnNumber] == [4, 2, 4, 9]
+ }
+
+ @Test
+ void setNamePositionIgnoresNull() {
+ def target = new ClassNode('Other', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ target.setNamePosition(null)
+ assert !target.hasNamePosition()
+ }
+
+ @Test
+ void setSourcePositionCopiesNameWhenPresent() {
+ def source = compileClass('class Hello {\n}\n')
+ def target = new ClassNode('Other', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ target.setSourcePosition(source)
+ assert target.hasNamePosition()
+ assert target.nameColumnNumber == source.nameColumnNumber
+ assert target.columnNumber == source.columnNumber
+ }
+
+ @Test
+ void setSourcePositionDoesNotInventNameWhenAbsent() {
+ def source = new ClassNode('Other', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ source.lineNumber = 1
+ source.columnNumber = 1
+ source.lastLineNumber = 1
+ source.lastColumnNumber = 6
+ def target = new ClassNode('Copy', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ target.setSourcePosition(source)
+ assert !target.hasNamePosition()
+ assert target.columnNumber == 1
+ }
+
+ @Test
+ void nameAccessorsRoundTrip() {
+ def node = new ClassNode('Hello', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ node.nameLineNumber = 2
+ node.nameColumnNumber = 7
+ node.nameLastLineNumber = 2
+ node.nameLastColumnNumber = 12
+ assert node.hasNamePosition()
+ assert node.nameLineNumber == 2
+ assert node.nameColumnNumber == 7
+ assert node.nameLastLineNumber == 2
+ assert node.nameLastColumnNumber == 12
+ }
+
+ @Test
+ void syntheticScriptClassHasNoAuthoredNameSpan() {
+ ModuleNode module = compileModule('println 1\n')
+ ClassNode script = module.scriptClassDummy ?: module.classes[0]
+ assert script.script
+ // the wrapping script class is generated; it must not look authored
+ assert !script.hasNamePosition() || script.nameLineNumber < 0
+ }
+
+ private static ClassNode compileClass(String src) {
+ compileModule(src).classes.find { !it.script }
+ }
+
+ private static ModuleNode compileModule(String src) {
+ def config = new CompilerConfiguration()
+ def unit = new CompilationUnit(config)
+ unit.addSource('NamePosition.groovy', src)
+ unit.compile(Phases.CONVERSION)
+ unit.AST.modules[0]
+ }
+}
diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/PositionConfigureUtilsTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/PositionConfigureUtilsTest.groovy
index 598b6d7920..4ca76f0aa5 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/PositionConfigureUtilsTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/PositionConfigureUtilsTest.groovy
@@ -202,6 +202,53 @@ final class PositionConfigureUtilsTest {
assertPos node, 3, 2, 3, 5
}
+ @Test
+ void configureNamePositionFromToken() {
+ def node = new org.codehaus.groovy.ast.ClassNode('Hello', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ PositionConfigureUtils.configureNamePosition(node, token('Hello', 1, 6))
+ assert node.hasNamePosition()
+ assert [node.nameLineNumber, node.nameColumnNumber, node.nameLastLineNumber, node.nameLastColumnNumber] == [1, 7, 1, 12]
+ }
+
+ @Test
+ void configureNamePositionFromContextUsesStartAndStop() {
+ def node = new org.codehaus.groovy.ast.ClassNode('Hello', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ def nameCtx = ctx(token('Hel', 2, 6), token('lo', 2, 9))
+ PositionConfigureUtils.configureNamePosition(node, nameCtx)
+ assert [node.nameLineNumber, node.nameColumnNumber, node.nameLastLineNumber, node.nameLastColumnNumber] == [2, 7, 2, 12]
+ }
+
+ @Test
+ void configureNamePositionFromContextWithNullStopUsesStartToken() {
+ def node = new org.codehaus.groovy.ast.ClassNode('X', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ def nameCtx = ctx(token('X', 4, 0), null)
+ PositionConfigureUtils.configureNamePosition(node, nameCtx)
+ assert [node.nameLineNumber, node.nameColumnNumber, node.nameLastLineNumber, node.nameLastColumnNumber] == [4, 1, 4, 2]
+ }
+
+ @Test
+ void configureNamePositionNullsAreNoops() {
+ def node = new org.codehaus.groovy.ast.ClassNode('Hello', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ assert PositionConfigureUtils.configureNamePosition(node, (Token) null).is(node)
+ assert !node.hasNamePosition()
+ assert PositionConfigureUtils.configureNamePosition(null, token('x', 1, 0)) == null
+ assert PositionConfigureUtils.configureNamePosition(null, (GroovyParser.GroovyParserRuleContext) null) == null
+ assert PositionConfigureUtils.configureNamePosition(node, (GroovyParser.GroovyParserRuleContext) null).is(node)
+ assert PositionConfigureUtils.configureNamePosition(null, (ASTNode) null) == null
+ assert PositionConfigureUtils.configureNamePosition(node, (ASTNode) null).is(node)
+ def noStart = new GroovyParser.GroovyParserRuleContext()
+ PositionConfigureUtils.configureNamePosition(node, noStart)
+ assert !node.hasNamePosition()
+ }
+
+ @Test
+ void configureNamePositionFromAstNodeCopiesSpan() {
+ def node = new org.codehaus.groovy.ast.ClassNode('Hello', 0, org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE)
+ def source = positioned(3, 2, 3, 7)
+ PositionConfigureUtils.configureNamePosition(node, source)
+ assert [node.nameLineNumber, node.nameColumnNumber, node.nameLastLineNumber, node.nameLastColumnNumber] == [3, 2, 3, 7]
+ }
+
private static CommonToken token(String text, int line, int charPos) {
CommonToken token = new CommonToken(0, text)
token.setLine(line)
diff --git a/src/test/groovy/org/codehaus/groovy/ast/query/AstQueryTest.groovy b/src/test/groovy/org/codehaus/groovy/ast/query/AstQueryTest.groovy
index 44421b7b01..7ee8853b8f 100644
--- a/src/test/groovy/org/codehaus/groovy/ast/query/AstQueryTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/ast/query/AstQueryTest.groovy
@@ -22,6 +22,7 @@ import groovy.test.GroovyTestCase
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.Parameter
import org.codehaus.groovy.ast.expr.BinaryExpression
import org.codehaus.groovy.ast.expr.ConstantExpression
import org.codehaus.groovy.ast.expr.MethodCallExpression
@@ -128,6 +129,26 @@ final class AstQueryTest extends GroovyTestCase {
assert AstQuery.from(empty).andSelf().descendants(EmptyExpression).count() == 1
}
+ void testMethodParametersAreVisited() {
+ def m = method(parse('def foo(String bar, int baz = 1) { bar + baz }'), 'foo')
+ def params = AstQuery.from(m).descendants(Parameter).list()
+ assert params*.name == ['bar', 'baz']
+ }
+
+ void testClosureAndCatchParametersAreVisited() {
+ def m = method(parse('def foo() { try { { String x -> x } } catch (Exception e) { e } }'), 'foo')
+ def names = AstQuery.from(m).descendants(Parameter).list()*.name
+ assert 'x' in names
+ assert 'e' in names
+ }
+
+ void testForLoopValueParameterIsVisitedAndDummyIsNot() {
+ def m = method(parse('def foo(xs) { for (v in xs) { v } }'), 'foo')
+ def names = AstQuery.from(m).descendants(Parameter).list()*.name
+ assert 'v' in names
+ assert !('forLoopDummyParameter' in names)
+ }
+
void testQueryIsImmutableAndReRunnable() {
def code = method(parse('def go() { foo(); foo() }'), 'go').code
def q = AstQuery.from(code).descendants(MethodCallExpression)
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..cf42578b52
--- /dev/null
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -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.
+-->
+
+# 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, and the test harness — see
+[`ARCHITECTURE.md`](ARCHITECTURE.md).
+
+## 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..9f17144805
--- /dev/null
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -0,0 +1,126 @@
+<!--
+ 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`),
+`kotlin-lsp`, Metals, and Eclipse JDT LS informed the shape, but
+this module is original code under Apache License 2.0. It compiles with the in-tree Groovy compiler
+rather than a fork, so language changes land here automatically.
+
+## 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.** `CompilationUnit.compile(Phases.SEMANTIC_ANALYSIS)` with
+ `tolerance = 0` (unlimited). Class generation is skipped. 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.
+- **Edits.** `groovy.lsp.organizeImports` preserves star, static and
+ alias imports, drops unused simple imports, and 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
+ and settings skips a full recompile when nothing changed. 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.
+- **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 spans live on `AnnotatedNode` name coordinates
+ so rename/highlight select the name, not the whole declaration.
+ 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..9c0005e7b0
--- /dev/null
+++ b/subprojects/groovy-lsp/README.adoc
@@ -0,0 +1,27 @@
+//////////////////////////////////////////
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+//////////////////////////////////////////
+
+= Groovy Language Server Module
+
+Implements the Language Server Protocol for Apache Groovy.
+
+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..549cb4ff17
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
@@ -0,0 +1,131 @@
+/*
+ * 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.getWorkspaceFolders().add(Uris.parse(folder.getUri()));
+ }
+ } else if (params.getRootUri() != null) {
+ context.getWorkspaceFolders().add(Uris.parse(params.getRootUri()));
+ }
+ 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);
+ }
+
+ @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..581d90a9a3
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServerLauncher.java
@@ -0,0 +1,129 @@
+/*
+ * 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.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.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.
+ */
+public final class GroovyLanguageServerLauncher {
+
+ private GroovyLanguageServerLauncher() {
+ }
+
+ /**
+ * Launches the language server.
+ *
+ * @param args {@code --help}, {@code --version}, or {@code --socket <port>}
+ * @throws Exception when the transport fails
+ */
+ public static void main(final String[] args) throws Exception {
+ 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));
+ Integer code = run(in, out, true);
+ if (code != null) {
+ System.exit(code);
+ }
+ }
+
+ /**
+ * 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 Exception when the transport fails
+ */
+ public static Integer run(final InputStream in, final OutputStream out, final boolean waitForExit)
+ throws Exception {
+ 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);
+ for (int i = 0; i < list.size(); i++) {
+ String arg = list.get(i);
+ 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 + 1 < list.size()) {
+ parsed.port = Integer.parseInt(list.get(++i));
+ }
+ }
+ return parsed;
+ }
+ }
+}
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..71136bcbba
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
@@ -0,0 +1,352 @@
+/*
+ * 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.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.protocol.ClientFeatures;
+import org.apache.groovy.lsp.internal.workspace.DocumentStore;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.eclipse.lsp4j.ClientCapabilities;
+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.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Mutable session state for one language-server process.
+ */
+public final class LanguageServerContext {
+
+ 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 volatile LanguageClient client;
+ private volatile ClientCapabilities clientCapabilities;
+ private volatile PositionEncoding positionEncoding = PositionEncoding.UTF16;
+ private volatile CompilerSettings settings = CompilerSettings.defaults();
+ private volatile CompilationSnapshot snapshot = 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;
+
+ /**
+ * Compiles open documents plus workspace sources and publishes diagnostics.
+ * Unchanged inputs reuse the last snapshot.
+ */
+ public synchronized CompilationSnapshot recompile() {
+ List<URI> folders;
+ synchronized (workspaceFolders) {
+ folders = new ArrayList<>(workspaceFolders);
+ }
+ CompilerSettings effective = WorkspaceLayout.withInferred(settings, folders);
+ List<Path> extra = scanner.scan(folders, effective.getSourcePaths());
+ Collection<TextDocument> open = documents.snapshot();
+ String fingerprint = fingerprint(open, extra, effective);
+ long generation = ++compileGeneration;
+ if (fingerprint.equals(compileFingerprint) && snapshot != CompilationSnapshot.EMPTY) {
+ return snapshot;
+ }
+ String token = "groovy-compile-" + generation;
+ beginProgress(token, "Compiling Groovy");
+ CompilationSnapshot compiled;
+ try {
+ compiled = compiler.compile(open, extra, effective, getClass().getClassLoader());
+ } finally {
+ endProgress(token);
+ }
+ if (generation != compileGeneration) {
+ return snapshot;
+ }
+ compileFingerprint = fingerprint;
+ snapshot = compiled;
+ publishDiagnostics();
+ return snapshot;
+ }
+
+ /**
+ * 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;
+ if (languageClient == null) {
+ return;
+ }
+ for (var document : snapshot.documents()) {
+ List<org.eclipse.lsp4j.Diagnostic> converted = new ArrayList<>(
+ diagnostics.convert(document, positionEncoding));
+ converted.addAll(ImportSupport.unusedDiagnostics(document, positionEncoding));
+ languageClient.publishDiagnostics(new PublishDiagnosticsParams(
+ document.getUri().toString(), converted));
+ }
+ }
+
+ private void beginProgress(final String token, final String title) {
+ LanguageClient languageClient = client;
+ if (languageClient == null || !ClientFeatures.workDoneProgress(clientCapabilities)) {
+ 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;
+ if (languageClient == null || !ClientFeatures.workDoneProgress(clientCapabilities)) {
+ 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();
+ }
+
+ public DocumentStore getDocuments() {
+ return documents;
+ }
+
+ 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 LanguageClient getClient() {
+ return client;
+ }
+
+ public void setClient(final LanguageClient client) {
+ this.client = client;
+ }
+
+ public ClientCapabilities getClientCapabilities() {
+ return clientCapabilities;
+ }
+
+ public void setClientCapabilities(final ClientCapabilities clientCapabilities) {
+ this.clientCapabilities = clientCapabilities;
+ }
+
+ public PositionEncoding getPositionEncoding() {
+ return positionEncoding;
+ }
+
+ public void setPositionEncoding(final PositionEncoding positionEncoding) {
+ this.positionEncoding = positionEncoding;
+ }
+
+ public CompilerSettings getSettings() {
+ return settings;
+ }
+
+ public void setSettings(final CompilerSettings settings) {
+ this.settings = settings;
+ compileFingerprint = null;
+ }
+
+ public CompilationSnapshot getSnapshot() {
+ return snapshot;
+ }
+
+ public void setSnapshot(final CompilationSnapshot snapshot) {
+ this.snapshot = snapshot;
+ }
+
+ public boolean isInitialized() {
+ return initialized;
+ }
+
+ public void setInitialized(final boolean initialized) {
+ this.initialized = initialized;
+ }
+
+ 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;
+ }
+
+ /**
+ * 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());
+ 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..7c2cde3530
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/AstQuery.java
@@ -0,0 +1,264 @@
+/*
+ * 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.Positions;
+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.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.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.query.AstContext;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+
+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);
+ }
+
+ /**
+ * 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(action);
+ }
+ BlockStatement block = module.getStatementBlock();
+ if (block != null) {
+ org.codehaus.groovy.ast.query.AstQuery.from(block)
+ .andSelf()
+ .forEach(action);
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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()) {
+ if (classNode.isScript() && classNode.getLineNumber() <= 0) {
+ continue;
+ }
+ 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);
+ }
+ }
+ }
+ return nodes;
+ }
+
+ /**
+ * 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..0d9a9da134
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilationSnapshot.java
@@ -0,0 +1,86 @@
+/*
+ * 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.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Immutable result of one workspace compile.
+ */
+public final class CompilationSnapshot {
+
+ public static final CompilationSnapshot EMPTY = new CompilationSnapshot(Map.of());
+
+ private final Map<URI, CompiledDocument> documents;
+ private volatile TypeIndex types;
+
+ /**
+ * @param documents compiled documents keyed by normalized URI
+ */
+ public CompilationSnapshot(final Map<URI, CompiledDocument> documents) {
+ this.documents = Map.copyOf(documents);
+ }
+
+ /**
+ * @return types declared in this snapshot
+ */
+ public TypeIndex types() {
+ TypeIndex index = types;
+ if (index == null) {
+ index = TypeIndex.of(this);
+ types = index;
+ }
+ return index;
+ }
+
+ /**
+ * @param uri document URI
+ * @return the compiled document, or {@code null}
+ */
+ public CompiledDocument get(final URI uri) {
+ return documents.get(Uris.normalize(uri));
+ }
+
+ /**
+ * @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..04cf4fc74c
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompiledDocument.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.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;
+
+ /**
+ * 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();
+ }
+
+ public URI getUri() {
+ return uri;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ 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..7e0c6c58a3
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
@@ -0,0 +1,138 @@
+/*
+ * 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.List;
+
+
+/**
+ * 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 final class CompilerSettings {
+
+ private final List<String> classpath;
+ private final List<String> sourcePaths;
+ private final int throughPhase;
+ private final boolean grapeEnabled;
+ private final boolean astTestEnabled;
+
+ /**
+ * 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
+ */
+ public CompilerSettings(final List<String> classpath, final List<String> sourcePaths, final int throughPhase,
+ final boolean grapeEnabled, final boolean astTestEnabled) {
+ this.classpath = classpath == null ? List.of() : List.copyOf(classpath);
+ this.sourcePaths = sourcePaths == null ? List.of() : List.copyOf(sourcePaths);
+ this.throughPhase = throughPhase;
+ this.grapeEnabled = grapeEnabled;
+ this.astTestEnabled = astTestEnabled;
+ }
+
+ /**
+ * @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);
+ }
+
+ 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;
+ }
+
+ /**
+ * 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));
+ }
+ java.util.Set<String> disabled = new java.util.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..65529a5aae
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
@@ -0,0 +1,217 @@
+/*
+ * 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.util.Uris;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.codehaus.groovy.ast.ModuleNode;
+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 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.util.Collection;
+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 GroovyClassLoader groovyLoader;
+ private URLClassLoader classpathLoader;
+ private LoaderKey loaderKey;
+
+ /**
+ * 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) {
+ CompilerConfiguration configuration = settings.toConfiguration();
+ applyProcessFlags(settings);
+ GroovyClassLoader loader = loaderFor(settings, parentLoader, configuration);
+ CompilationUnit unit = new CompilationUnit(configuration, null, loader);
+ Map<URI, Integer> versions = new LinkedHashMap<>();
+ Map<URI, String> texts = new LinkedHashMap<>();
+
+ if (openDocuments != null) {
+ for (TextDocument document : openDocuments) {
+ if (!Uris.isGroovyDocument(document.getUri(), document.getLanguageId())) {
+ continue;
+ }
+ URI uri = Uris.normalize(document.getUri());
+ unit.addSource(uri.toString(), document.getText());
+ versions.put(uri, document.getVersion());
+ texts.put(uri, document.getText());
+ }
+ }
+ if (extraFiles != null) {
+ for (Path path : extraFiles) {
+ if (path == null || !Files.isRegularFile(path)) {
+ continue;
+ }
+ URI uri = Uris.normalize(path.toUri());
+ if (texts.containsKey(uri)) {
+ continue;
+ }
+ if (!Uris.isGroovyDocument(uri, null)) {
+ continue;
+ }
+ 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
+ }
+ }
+ }
+
+ try {
+ unit.compile(settings.getThroughPhase());
+ } catch (CompilationFailedException ignored) {
+ // keep the partial AST and collected messages
+ }
+
+ Map<URI, CompiledDocument> compiled = CompilationSnapshot.newMap();
+ var sourceIterator = unit.iterator();
+ while (sourceIterator.hasNext()) {
+ SourceUnit sourceUnit = sourceIterator.next();
+ URI uri;
+ try {
+ uri = Uris.parse(sourceUnit.getName());
+ } catch (IllegalArgumentException ex) {
+ continue;
+ }
+ ModuleNode module = sourceUnit.getAST();
+ compiled.put(uri, new CompiledDocument(uri, versions.getOrDefault(uri, -1),
+ texts.getOrDefault(uri, ""), module, sourceUnit, sourceUnit.getErrorCollector()));
+ }
+ 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));
+ }
+ return new CompilationSnapshot(compiled);
+ }
+
+ /**
+ * {@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("groovy.asttest.enable", Boolean.toString(settings.isAstTestEnabled()));
+ System.setProperty("groovy.grape.enable", Boolean.toString(settings.isGrapeEnabled()));
+ }
+
+ private synchronized GroovyClassLoader loaderFor(final CompilerSettings settings, final ClassLoader parentLoader,
+ final CompilerConfiguration configuration) {
+ LoaderKey key = new LoaderKey(settings.getClasspath(), 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;
+ }
+
+ private static final class LoaderKey {
+ private final List<String> classpath;
+ private final boolean grape;
+ private final boolean astTest;
+ private final ClassLoader parent;
+
+ private LoaderKey(final List<String> classpath, final boolean grape, final boolean astTest,
+ final ClassLoader parent) {
+ this.classpath = classpath;
+ this.grape = grape;
+ this.astTest = astTest;
+ this.parent = parent;
+ }
+
+ @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) && parent == key.parent;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(classpath, 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..81cf4ff424
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/Identifiers.java
@@ -0,0 +1,67 @@
+/*
+ * 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;
+
+/**
+ * Word-boundary identifier scans, used to skip files that cannot contain a
+ * reference and to detect unused imports.
+ */
+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;
+ }
+}
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..9858fed28e
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/ImportSupport.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.compile;
+
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.codehaus.groovy.ast.ImportNode;
+import org.codehaus.groovy.ast.ModuleNode;
+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.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 = Set.of(
+ "java.lang", "java.util", "java.io", "java.net", "groovy.lang", "groovy.util");
+ private static final Set<String> DEFAULT_TYPES = Set.of("java.math.BigDecimal", "java.math.BigInteger");
+
+ private ImportSupport() {
+ }
+
+ /**
+ * @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 (imp.isStar()) {
+ continue;
+ }
+ String ident = importedName(imp);
+ if (ident == null || ident.isEmpty()) {
+ continue;
+ }
+ if (Identifiers.count(text, ident) <= 1) {
+ unused.add(imp);
+ }
+ }
+ return unused;
+ }
+
+ /**
+ * @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/PackageGuess.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/PackageGuess.java
new file mode 100644
index 0000000000..0a3e804d8f
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/PackageGuess.java
@@ -0,0 +1,74 @@
+/*
+ * 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) {
+ if (folder == null || !"file".equalsIgnoreCase(folder.getScheme())) {
+ continue;
+ }
+ 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 "";
+ }
+}
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..231b3eb189
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/TypeIndex.java
@@ -0,0 +1,151 @@
+/*
+ * 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);
+ }
+ }
+ 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..6efb2225c4
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceLayout.java
@@ -0,0 +1,139 @@
+/*
+ * 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()) {
+ for (String sourcePath : sourcePaths) {
+ Path extra = Path.of(sourcePath);
+ roots.add(extra.isAbsolute() ? extra : folder.resolve(sourcePath));
+ }
+ return roots;
+ }
+ for (String conventional : List.of("src/main/groovy", "src/test/groovy", "src/main/java", "src/test/java")) {
+ Path path = folder.resolve(conventional);
+ if (Files.isDirectory(path)) {
+ roots.add(path);
+ }
+ }
+ if (roots.isEmpty()) {
+ Path src = folder.resolve("src");
+ if (Files.isDirectory(src)) {
+ roots.add(src);
+ }
+ }
+ if (roots.isEmpty()) {
+ roots.add(folder);
+ }
+ return roots;
+ }
+
+ /**
+ * 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) {
+ if (folder == null || !"file".equalsIgnoreCase(folder.getScheme())) {
+ continue;
+ }
+ Path path = Uris.toPath(folder);
+ if (sourcePaths.isEmpty()) {
+ for (Path root : searchRoots(path, List.of())) {
+ if (!root.equals(path)) {
+ sourcePaths.add(path.relativize(root).toString());
+ }
+ }
+ }
+ if (classpath.isEmpty()) {
+ classpath.addAll(classpathJars(path));
+ }
+ }
+ }
+ return new CompilerSettings(classpath, sourcePaths, current.getThroughPhase(),
+ current.isGrapeEnabled(), current.isAstTestEnabled());
+ }
+}
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..abad7ca426
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/WorkspaceScanner.java
@@ -0,0 +1,94 @@
+/*
+ * 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;
+
+/**
+ * Finds Groovy 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;
+ }
+ if (!Files.isDirectory(root)) {
+ continue;
+ }
+ try (Stream<Path> stream = Files.walk(root)) {
+ stream.filter(Files::isRegularFile)
+ .filter(this::isGroovyFile)
+ .filter(p -> !isIgnored(root.relativize(p)))
+ .limit(MAX_FILES - files.size())
+ .forEach(files::add);
+ } catch (IOException ignored) {
+ // skip unreadable trees
+ }
+ }
+ return files;
+ }
+
+ private boolean isGroovyFile(final Path path) {
+ String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
+ return name.endsWith(".groovy") || name.endsWith(".gvy") || name.endsWith(".gy") || name.endsWith(".gsh");
+ }
+
+ 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..e16c8aa7ea
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/diagnostic/DiagnosticConverter.java
@@ -0,0 +1,127 @@
+/*
+ * 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.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.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));
+ }
+ }
+ 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;
+ String msg;
+ DiagnosticSeverity severity = fallback;
+ if (message instanceof SyntaxErrorMessage syntax) {
+ SyntaxException cause = syntax.getCause();
+ range = Positions.toRange(cause.getStartLine(), cause.getStartColumn(),
+ cause.getEndLine(), cause.getEndColumn(), text, encoding);
+ msg = cause.getOriginalMessage();
+ } 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);
+ msg = groovy.text();
+ }
+ if (range == null) {
+ range = new Range(new Position(0, 0), new Position(0, 0));
+ }
+ if (msg == null || msg.isBlank()) {
+ msg = message.toString();
+ }
+ Diagnostic diagnostic = new Diagnostic(range, msg, severity, SOURCE);
+ if (msg != null && msg.toLowerCase().contains("deprecated")) {
+ diagnostic.setTags(List.of(org.eclipse.lsp4j.DiagnosticTag.Deprecated));
+ }
+ if (msg != null && msg.toLowerCase().contains("unused")) {
+ diagnostic.setTags(List.of(org.eclipse.lsp4j.DiagnosticTag.Unnecessary));
+ if (severity == DiagnosticSeverity.Warning) {
+ diagnostic.setSeverity(DiagnosticSeverity.Hint);
+ }
+ }
+ return diagnostic;
+ }
+}
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..140463d926
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CodeActionService.java
@@ -0,0 +1,387 @@
+/*
+ * 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.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.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.eclipse.lsp4j.CodeAction;
+import org.eclipse.lsp4j.CodeActionKind;
+import org.eclipse.lsp4j.Command;
+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;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Extra code actions inspired by Metals: unused imports, implement
+ * abstracts, insert inferred types, add package, create missing class.
+ */
+public final class CodeActionService {
+
+ private static final Pattern UNRESOLVED_CLASS = Pattern.compile("unable to resolve class ([\\w.]+)", Pattern.CASE_INSENSITIVE);
+
+ /**
+ * @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<org.eclipse.lsp4j.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.getUri());
+ 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);
+ 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()) {
+ if (classNode.isInterface() || classNode.isScript() || classNode.getLineNumber() <= 0) {
+ continue;
+ }
+ Range full = Positions.toRange(classNode, compiled.getText(), encoding);
+ if (range != null && full != null && !overlaps(full, range)) {
+ continue;
+ }
+ 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) {
+ if (edits == null || edits.isEmpty()) {
+ return;
+ }
+ CodeAction action = new CodeAction(title);
+ action.setKind(kind);
+ action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(), edits)));
+ 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;
+ }
+ CodeAction action = new CodeAction("Remove unused imports");
+ action.setKind(CodeActionKind.QuickFix);
+ action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(), edits)));
+ actions.add(Either.forRight(action));
+ }
+
+ 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);
+ }
+ CodeAction action = new CodeAction("Add package " + guessed);
+ action.setKind(CodeActionKind.Source);
+ action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(), List.of(edit))));
+ actions.add(Either.forRight(action));
+ }
+
+ 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()) {
+ if (classNode.isInterface() || classNode.isAbstract() || classNode.isEnum() || classNode.isScript()) {
+ continue;
+ }
+ if (range != null && !overlapsName(classNode, compiled.getText(), encoding, range)) {
+ continue;
+ }
+ List<MethodNode> missing = missingAbstracts(classNode);
+ if (missing.isEmpty()) {
+ continue;
+ }
+ Range classRange = Positions.toRange(classNode, compiled.getText(), encoding);
+ if (classRange == null) {
+ continue;
+ }
+ String stub = stubs(missing);
+ Position insert = classRange.getEnd();
+ String line = Positions.lineText(compiled.getText(), insert.getLine() + 1);
+ int brace = line.lastIndexOf('}');
+ if (brace >= 0) {
+ insert = Positions.toLsp(insert.getLine() + 1, brace + 1, line, encoding);
+ }
+ CodeAction action = new CodeAction("Implement abstract methods");
+ action.setKind(CodeActionKind.QuickFix);
+ action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(),
+ List.of(new TextEdit(new Range(insert, insert), stub)))));
+ actions.add(Either.forRight(action));
+ }
+ }
+
+ 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<>();
+ fields.addAll(classNode.getFields());
+ for (PropertyNode property : classNode.getProperties()) {
+ if (property.getField() != null) {
+ fields.add(property.getField());
+ }
+ }
+ for (FieldNode field : fields) {
+ ClassNode type = field.getType();
+ if (!SymbolIdentity.isResolvedType(type) && field.getInitialExpression() != null) {
+ type = field.getInitialExpression().getType();
+ }
+ if (field.getLineNumber() <= 0 && field.getNameLineNumber() <= 0
+ || !SymbolIdentity.isResolvedType(type)) {
+ continue;
+ }
+ Range name = Positions.toNameRange(field, compiled.getText(), encoding);
+ if (name == null || (range != null && !overlaps(name, range))) {
+ continue;
+ }
+ int groovyLine = field.getNameLineNumber() > 0 ? field.getNameLineNumber() : field.getLineNumber();
+ String line = Positions.lineText(compiled.getText(), groovyLine);
+ int defAt = wordIndex(line, "def");
+ if (defAt < 0) {
+ continue;
+ }
+ Position start = Positions.toLsp(groovyLine, defAt + 1, line, encoding);
+ Position end = Positions.toLsp(groovyLine, defAt + 4, line, encoding);
+ CodeAction action = new CodeAction("Insert inferred type " + type.getNameWithoutPackage());
+ action.setKind(CodeActionKind.QuickFix);
+ action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(),
+ List.of(new TextEdit(new Range(start, end), type.getNameWithoutPackage())))));
+ actions.add(Either.forRight(action));
+ }
+ }
+ }
+
+ private static void addCreateClass(final List<Either<Command, CodeAction>> actions,
+ final CompiledDocument compiled,
+ final List<org.eclipse.lsp4j.Diagnostic> diagnostics,
+ final PositionEncoding encoding) {
+ if (diagnostics == null) {
+ return;
+ }
+ for (org.eclipse.lsp4j.Diagnostic diagnostic : diagnostics) {
+ String message = SupportServices.diagnosticMessage(diagnostic);
+ if (message == null) {
+ continue;
+ }
+ Matcher matcher = UNRESOLVED_CLASS.matcher(message);
+ if (!matcher.find()) {
+ continue;
+ }
+ String className = matcher.group(1);
+ if (className.contains(".")) {
+ className = className.substring(className.lastIndexOf('.') + 1);
+ }
+ if (!Character.isUpperCase(className.codePointAt(0))) {
+ continue;
+ }
+ Range end = compiled.getText().isEmpty() ? new Range(new Position(0, 0), new Position(0, 0))
+ : new Range(documentEnd(compiled, encoding), documentEnd(compiled, encoding));
+ String stub = "\nclass " + className + " {\n}\n";
+ CodeAction action = new CodeAction("Create class " + className);
+ action.setKind(CodeActionKind.QuickFix);
+ action.setDiagnostics(List.of(diagnostic));
+ action.setEdit(new WorkspaceEdit(Map.of(compiled.getUri().toString(),
+ List.of(new TextEdit(end, stub)))));
+ actions.add(Either.forRight(action));
+ }
+ }
+
+ static List<MethodNode> missingAbstracts(final ClassNode classNode) {
+ List<MethodNode> missing = new ArrayList<>();
+ for (MethodNode method : classNode.getAbstractMethods()) {
+ if (method.isSynthetic()) {
+ continue;
+ }
+ ClassNode owner = method.getDeclaringClass();
+ if (owner != null && ("groovy.lang.GroovyObject".equals(owner.getName())
+ || "groovy.lang.GroovyObjectSupport".equals(owner.getName()))) {
+ continue;
+ }
+ MethodNode declared = classNode.getDeclaredMethod(method.getName(), method.getParameters());
+ if (declared != null && declared.getCode() != null && !declared.isAbstract()) {
+ continue;
+ }
+ if (declared == method) {
+ continue;
+ }
+ missing.add(method);
+ }
+ return missing;
+ }
+
+ private static String stubs(final List<MethodNode> methods) {
+ StringBuilder builder = new StringBuilder();
+ for (MethodNode method : methods) {
+ builder.append(" @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 }\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;
+ }
+ if (left.getEnd().getLine() < right.getStart().getLine()
+ || left.getStart().getLine() > right.getEnd().getLine()) {
+ return false;
+ }
+ return true;
+ }
+
+ 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') {
+ line += 1;
+ } else if (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..4a514d1b20
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/CompletionService.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.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.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.PropertyExpression;
+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.getUri());
+ ModuleNode module = compiled == null ? null : compiled.getModule();
+
+ if (!memberAccess) {
+ addMatching(items, GroovyKeywords.KEYWORDS, prefix, CompletionItemKind.Keyword);
+ addMatching(items, GroovyKeywords.MODIFIERS, prefix, CompletionItemKind.Keyword);
+ addSnippets(items, prefix);
+ }
+
+ if (module != null) {
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(module, groovy[0], groovy[1]);
+ ClassNode receiver = receiverOf(node);
+ if (memberAccess && receiver != null) {
+ addMembers(items, receiver, prefix);
+ if (prefix.length() >= 2) {
+ addGdk(items, prefix);
+ }
+ } else if (!memberAccess) {
+ addLocals(items, module, groovy[0], groovy[1], prefix);
+ for (ClassNode classNode : module.getClasses()) {
+ addMembers(items, classNode, prefix);
+ }
+ addWorkspaceTypes(items, compiled, snapshot, prefix, encoding);
+ }
+ } 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 == null) {
+ 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 = 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) {
+ int[] groovy = document.toGroovy(position, encoding);
+ String line = org.apache.groovy.lsp.internal.position.Positions.lineText(document.getText(), groovy[0]);
+ int col = Math.max(groovy[1] - 1, 0);
+ int cpCount = line.codePointCount(0, line.length());
+ if (col > cpCount) {
+ col = cpCount;
+ }
+ int end = line.offsetByCodePoints(0, col);
+ int start = end;
+ while (start > 0) {
+ int cp = line.codePointBefore(start);
+ if (!Character.isJavaIdentifierPart(cp) && cp != '$') {
+ break;
+ }
+ start -= Character.charCount(cp);
+ }
+ return line.substring(start, end);
+ }
+
+ static boolean isMemberAccess(final TextDocument document, final Position position,
+ final PositionEncoding encoding) {
+ int[] groovy = document.toGroovy(position, encoding);
+ String line = org.apache.groovy.lsp.internal.position.Positions.lineText(document.getText(), groovy[0]);
+ int col = Math.max(groovy[1] - 1, 0);
+ int cpCount = line.codePointCount(0, line.length());
+ if (col > cpCount) {
+ col = cpCount;
+ }
+ int idx = line.offsetByCodePoints(0, col);
+ while (idx > 0) {
+ int cp = line.codePointBefore(idx);
+ if (!Character.isWhitespace(cp)) {
+ break;
+ }
+ idx -= Character.charCount(cp);
+ }
+ while (idx > 0) {
+ int cp = line.codePointBefore(idx);
+ if (!Character.isJavaIdentifierPart(cp) && cp != '$') {
+ break;
+ }
+ idx -= Character.charCount(cp);
+ }
+ return idx > 0 && line.codePointBefore(idx) == '.';
+ }
+
+ 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(hit.iface() ? CompletionItemKind.Interface
+ : hit.enumeration() ? CompletionItemKind.Enum : CompletionItemKind.Class);
+ 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()) {
+ if (method.isSynthetic() || !seen.add(method.getName() + "/" + method.getParameters().length)) {
+ continue;
+ }
+ if (!prefixMatches(method.getName(), prefix)) {
+ continue;
+ }
+ 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);
+ }
+ 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 MethodNode findMethod(final CompilationSnapshot snapshot, final String owner, final String name,
+ final String descriptor) {
+ if (owner == null || name == null) {
+ return null;
+ }
+ for (var document : snapshot.documents()) {
+ if (document.getModule() == null) {
+ continue;
+ }
+ for (ClassNode classNode : document.getModule().getClasses()) {
+ if (!owner.equals(classNode.getName())) {
+ continue;
+ }
+ for (MethodNode method : classNode.getMethods()) {
+ if (name.equals(method.getName()) && (descriptor == null || descriptor.isEmpty()
+ || descriptor.equals(method.getTypeDescriptor()))) {
+ return method;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ 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;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, String> stringMap(final Object data) {
+ if (!(data instanceof Map<?, ?> map)) {
+ return null;
+ }
+ 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..79177dffb6
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HierarchyService.java
@@ -0,0 +1,401 @@
+/*
+ * 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.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.eclipse.lsp4j.CallHierarchyIncomingCall;
+import org.eclipse.lsp4j.CallHierarchyItem;
+import org.eclipse.lsp4j.CallHierarchyOutgoingCall;
+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 org.eclipse.lsp4j.Position position,
+ final PositionEncoding encoding) {
+ CompiledDocument compiled = snapshot == null ? null : snapshot.get(document.getUri());
+ if (compiled == null || compiled.getModule() == null) {
+ return List.of();
+ }
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ 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 || snapshot == null) {
+ return List.of();
+ }
+ Map<String, Incoming> grouped = new LinkedHashMap<>();
+ for (CompiledDocument document : snapshot.documents()) {
+ if (document.getModule() == null) {
+ continue;
+ }
+ AstQuery.walk(document.getModule(), (node, ctx) -> {
+ if (!isCallTo(node, method, ctx, snapshot)) {
+ return;
+ }
+ MethodNode enclosing = ctx.enclosingMethod();
+ if (enclosing == null || enclosing.getLineNumber() <= 0) {
+ return;
+ }
+ Range range = Positions.toIdentifierRange(node, document.getText(), encoding);
+ if (range == null) {
+ return;
+ }
+ String key = SymbolIdentity.keyOf(enclosing);
+ Incoming incoming = grouped.computeIfAbsent(key, ignored -> {
+ Incoming created = new Incoming();
+ created.item = callItem(enclosing, document.getUri(), document.getText(), encoding);
+ created.ranges = new ArrayList<>();
+ return created;
+ });
+ incoming.ranges.add(range);
+ });
+ }
+ 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 || snapshot == null) {
+ return List.of();
+ }
+ Map<String, Outgoing> grouped = new LinkedHashMap<>();
+ String bodyText = textOf(method, snapshot);
+ org.codehaus.groovy.ast.query.AstQuery.from(method.getCode())
+ .andSelf()
+ .forEach((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 org.eclipse.lsp4j.Position position,
+ final PositionEncoding encoding) {
+ CompiledDocument compiled = snapshot == null ? null : snapshot.get(document.getUri());
+ if (compiled == null || compiled.getModule() == null) {
+ return List.of();
+ }
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ 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 MethodNode methodOf(final ASTNode node, final CompilationSnapshot snapshot) {
+ if (node instanceof MethodNode method) {
+ return method;
+ }
+ if (node instanceof MethodCallExpression call) {
+ return NavigationService.resolveMethod(call, snapshot);
+ }
+ if (node instanceof StaticMethodCallExpression call) {
+ return NavigationService.resolveStatic(call, snapshot);
+ }
+ return null;
+ }
+
+ 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) {
+ if (node instanceof MethodCallExpression call) {
+ return NavigationService.resolveMethod(call, snapshot);
+ }
+ if (node instanceof StaticMethodCallExpression call) {
+ return NavigationService.resolveStatic(call, snapshot);
+ }
+ 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 boolean isCallTo(final ASTNode node, final MethodNode method, final org.codehaus.groovy.ast.query.AstContext ctx,
+ final CompilationSnapshot snapshot) {
+ if (node instanceof MethodCallExpression call) {
+ return NavigationService.callMatches(call, method, ctx, snapshot);
+ }
+ 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 = stringMap(item.getData());
+ if (data == null) {
+ return findMethod(snapshot, item.getName(), null, null);
+ }
+ return findMethod(snapshot, data.get("name"), data.get("owner"), data.get("desc"));
+ }
+
+ private static ClassNode typeFromItem(final TypeHierarchyItem item, final CompilationSnapshot snapshot) {
+ if (item == null || snapshot == null) {
+ return null;
+ }
+ Map<String, String> data = stringMap(item.getData());
+ String name = data == null ? item.getName() : 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 MethodNode findMethod(final CompilationSnapshot snapshot, final String name,
+ final String owner, final String descriptor) {
+ if (name == null) {
+ return null;
+ }
+ MethodNode fallback = null;
+ for (CompiledDocument document : snapshot.documents()) {
+ if (document.getModule() == null) {
+ continue;
+ }
+ for (ClassNode classNode : document.getModule().getClasses()) {
+ if (owner != null && !owner.isEmpty() && !owner.equals(classNode.getName())) {
+ continue;
+ }
+ for (MethodNode method : classNode.getMethods()) {
+ if (!name.equals(method.getName()) || method.getLineNumber() <= 0) {
+ continue;
+ }
+ if (descriptor != null && !descriptor.isEmpty()
+ && descriptor.equals(method.getTypeDescriptor())) {
+ return method;
+ }
+ fallback = method;
+ }
+ }
+ }
+ return fallback;
+ }
+
+ private static String textOf(final ASTNode node, final CompilationSnapshot snapshot) {
+ URI uri = NavigationService.uriOf(node, null, snapshot,
+ node instanceof MethodNode method && method.getDeclaringClass() != null
+ ? method.getDeclaringClass().getModule()
+ : node instanceof ClassNode classNode ? classNode.getModule() : null);
+ 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 : org.apache.groovy.lsp.internal.util.Uris.parse(item.getUri());
+ } catch (IllegalArgumentException ex) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, String> stringMap(final Object data) {
+ if (!(data instanceof Map<?, ?> map)) {
+ return null;
+ }
+ 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;
+ }
+
+ 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..8dcf904062
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/HoverService.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.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.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.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.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.getUri());
+ ASTNode node = null;
+ if (compiled != null && compiled.getModule() != null) {
+ int[] groovy = document.toGroovy(position, encoding);
+ node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ }
+ String markdown = node == null ? null : render(resolveForHover(node, snapshot));
+ if (markdown == null || markdown.isBlank()) {
+ markdown = identifierHover(document, 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 Position position,
+ final PositionEncoding encoding) {
+ String prefix = CompletionService.prefixAt(document, position, encoding);
+ if (prefix == null || prefix.isEmpty()) {
+ return null;
+ }
+ return codeFence(prefix);
+ }
+
+ static String render(final ASTNode node) {
+ 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 codeFence(field.getType().getNameWithoutPackage() + " " + field.getName()) + docs(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 codeFence(variable.getType().getNameWithoutPackage() + " " + variable.getName());
+ }
+ if (node instanceof ClassExpression classExpression) {
+ return codeFence(classExpression.getType().getName());
+ }
+ if (node instanceof MethodCallExpression call) {
+ MethodNode target = call.getMethodTarget();
+ if (target != null) {
+ return codeFence(signature(target)) + docs(target);
+ }
+ return codeFence(call.getMethodAsString() + "(...)");
+ }
+ if (node instanceof org.codehaus.groovy.ast.expr.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 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..677d2fbd74
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/NavigationService.java
@@ -0,0 +1,617 @@
+/*
+ * 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.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.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) {
+ return locate(document, snapshot, position, encoding, Target.DEFINITION);
+ }
+
+ /**
+ * 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.getUri());
+ if (compiled == null || compiled.getModule() == null) {
+ return List.of();
+ }
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ 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);
+ }
+
+ /**
+ * 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.getUri());
+ if (compiled == null || compiled.getModule() == null) {
+ return List.of();
+ }
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ 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);
+ }
+ for (CompiledDocument other : snapshot.documents()) {
+ if (other.getModule() == null) {
+ continue;
+ }
+ if (!Identifiers.containsWord(other.getText(), identity.name())) {
+ continue;
+ }
+ AstQuery.walk(other.getModule(), (hit, ctx) -> {
+ if (!includeDeclaration && identity.isDeclaration(hit)) {
+ return;
+ }
+ if (identity.refersTo(hit, ctx, snapshot)) {
+ addLocation(locations, seen, other.getUri(), hit, other.getText(), encoding);
+ }
+ });
+ }
+ return locations;
+ }
+
+ 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.getUri());
+ if (compiled == null || compiled.getModule() == null) {
+ return List.of();
+ }
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ 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) {
+ Variable accessed = variable.getAccessedVariable();
+ if (target == Target.TYPE) {
+ return variable.getType();
+ }
+ if (accessed instanceof ASTNode ast) {
+ return ast;
+ }
+ }
+ if (node instanceof ClassExpression classExpression) {
+ return classExpression.getType();
+ }
+ if (node instanceof MethodCallExpression call) {
+ if (target == Target.TYPE) {
+ return call.getType();
+ }
+ MethodNode method = resolveMethod(call, snapshot);
+ if (method != null) {
+ return method;
+ }
+ }
+ if (node instanceof StaticMethodCallExpression call) {
+ MethodNode method = resolveStatic(call, snapshot);
+ if (method != null) {
+ return method;
+ }
+ }
+ if (node instanceof ConstructorCallExpression ctor) {
+ return ctor.getType();
+ }
+ if (node instanceof PropertyExpression property) {
+ if (target == Target.TYPE) {
+ return property.getType();
+ }
+ ASTNode dest = resolveProperty(property);
+ if (dest != null) {
+ return dest;
+ }
+ }
+ 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;
+ }
+ if (node instanceof MethodNode || node instanceof FieldNode || node instanceof PropertyNode
+ || node instanceof ClassNode || node instanceof Parameter) {
+ if (target == Target.TYPE && node instanceof FieldNode field) {
+ return field.getType();
+ }
+ if (target == Target.TYPE && node instanceof Parameter parameter) {
+ return parameter.getType();
+ }
+ return node;
+ }
+ return null;
+ }
+
+ static MethodNode resolveMethod(final MethodCallExpression call, final CompilationSnapshot snapshot) {
+ if (call == null) {
+ return null;
+ }
+ if (call.getMethodTarget() != null) {
+ return call.getMethodTarget();
+ }
+ String name = call.getMethodAsString();
+ if (name == null) {
+ return null;
+ }
+ int argc = argumentCount(call.getArguments());
+ MethodNode found = findMethod(typeOf(call.getObjectExpression()), name, argc);
+ if (found != null) {
+ return found;
+ }
+ if (call.isImplicitThis()) {
+ return findMethod(enclosingClass(call, snapshot), name, argc);
+ }
+ return null;
+ }
+
+ static MethodNode resolveStatic(final StaticMethodCallExpression call, final CompilationSnapshot snapshot) {
+ if (call == null) {
+ return null;
+ }
+ return findMethod(call.getOwnerType(), call.getMethodAsString(), argumentCount(call.getArguments()));
+ }
+
+ 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, final CompilationSnapshot snapshot) {
+ 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) {
+ 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;
+ }
+
+ 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
+ }
+ }
+ if (snapshot != null && dest instanceof ClassNode classNode) {
+ for (CompiledDocument document : snapshot.documents()) {
+ if (document.getModule() == null) {
+ continue;
+ }
+ for (ClassNode other : document.getModule().getClasses()) {
+ if (sameType(other, classNode)) {
+ return document.getUri();
+ }
+ }
+ }
+ }
+ return fallback;
+ }
+
+ private 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 = 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 || 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 MethodNode findMethod(final ClassNode type, final String name, final int argc) {
+ if (!SymbolIdentity.isResolvedType(type) || name == null) {
+ return null;
+ }
+ MethodNode best = null;
+ for (MethodNode method : type.getMethods(name)) {
+ if (method.isSynthetic()) {
+ continue;
+ }
+ int count = method.getParameters() == null ? 0 : method.getParameters().length;
+ if (count == argc) {
+ return method;
+ }
+ if (best == null) {
+ best = method;
+ }
+ }
+ return best;
+ }
+
+ 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;
+ }
+
+ private 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..352a721c27
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/RenameService.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.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.workspace.TextDocument;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassNode;
+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.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.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Prepare-rename and rename using reference locations.
+ */
+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.getUri());
+ if (compiled == null || compiled.getModule() == null) {
+ return null;
+ }
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ 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) {
+ if (newName == null || newName.isBlank() || !isIdentifier(newName)) {
+ return new WorkspaceEdit(Map.of());
+ }
+ List<org.eclipse.lsp4j.Location> locations = navigation.references(document, snapshot, position, encoding, true);
+ Map<String, List<TextEdit>> changes = new HashMap<>();
+ for (org.eclipse.lsp4j.Location location : locations) {
+ changes.computeIfAbsent(location.getUri(), key -> new ArrayList<>())
+ .add(new TextEdit(location.getRange(), newName));
+ }
+ WorkspaceEdit edit = new WorkspaceEdit(changes);
+ CompiledDocument compiled = snapshot == null ? null : snapshot.get(document.getUri());
+ if (compiled != null && compiled.getModule() != null) {
+ int[] groovy = document.toGroovy(position, encoding);
+ ASTNode node = AstQuery.nodeAt(compiled.getModule(), groovy[0], groovy[1]);
+ addFileRename(edit, document, node, newName, changes);
+ }
+ return edit;
+ }
+
+ 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 path = document.getUri().getPath();
+ if (path == null || !path.endsWith(".groovy")) {
+ return;
+ }
+ String fileName = Path.of(path).getFileName().toString();
+ String stem = fileName.substring(0, fileName.length() - ".groovy".length());
+ if (!stem.equals(classNode.getNameWithoutPackage())) {
+ return;
+ }
+ String oldUri = document.getUri().toString();
+ String newUri = oldUri.substring(0, oldUri.length() - fileName.length()) + newName + ".groovy";
+ List<Either<TextDocumentEdit, ResourceOperation>> documentChanges = new ArrayList<>();
+ for (Map.Entry<String, List<TextEdit>> entry : changes.entrySet()) {
+ List<Either<TextEdit, org.eclipse.lsp4j.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.isEmpty()) {
+ return false;
+ }
+ int first = name.codePointAt(0);
+ if (!Character.isJavaIdentifierStart(first)) {
+ return false;
+ }
+ for (int i = Character.charCount(first); 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..d1eaae3617
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SourceGeneration.java
@@ -0,0 +1,303 @@
+/*
+ * 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.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 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 (!Modifier.isPrivate(field.getModifiers())) {
+ continue;
+ }
+ if (classNode.getProperty(field.getName()) != null) {
+ continue;
+ }
+ if (hasAccessor(classNode, field)) {
+ continue;
+ }
+ fields.add(field);
+ }
+ return fields;
+ }
+
+ 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("\n }\n")
+ .append(" void set").append(cap).append('(').append(type).append(' ').append(field.getName())
+ .append(") {\n this.").append(field.getName()).append(" = ").append(field.getName())
+ .append("\n }\n");
+ }
+ 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\n }\n");
+ } 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("\n }\n");
+ }
+ 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()) {
+ if (method.isSynthetic() || method.getLineNumber() <= 0 || hasOverride(method)) {
+ continue;
+ }
+ if (!overrides(method, superClass) && !overridesInterface(method, classNode)) {
+ continue;
+ }
+ Range name = Positions.toNameRange(method, compiled.getText(), encoding);
+ if (name == null) {
+ continue;
+ }
+ Position start = new Position(name.getStart().getLine(), 0);
+ String line = Positions.lineText(compiled.getText(), name.getStart().getLine() + 1);
+ int indent = 0;
+ while (indent < line.length() && (line.charAt(indent) == ' ' || line.charAt(indent) == '\t')) {
+ indent += 1;
+ }
+ edits.add(new TextEdit(new Range(start, start), line.substring(0, indent) + "@Override\n"));
+ }
+ 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 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..c38ebe301c
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SupportServices.java
@@ -0,0 +1,761 @@
+/*
+ * 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.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.ClassNode;
+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.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+
+import org.eclipse.lsp4j.CallHierarchyIncomingCall;
+import org.eclipse.lsp4j.CallHierarchyItem;
+import org.eclipse.lsp4j.CallHierarchyOutgoingCall;
+import org.eclipse.lsp4j.CodeAction;
+import org.eclipse.lsp4j.CodeActionKind;
+import org.eclipse.lsp4j.CodeLens;
+import org.eclipse.lsp4j.Command;
+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.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.TypeHierarchyItem;
+import org.eclipse.lsp4j.WorkspaceEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+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 HierarchyService hierarchy = new HierarchyService();
+ 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.getUri());
+ 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;
+ if (node instanceof MethodCallExpression call) {
+ argc = argumentCount(call.getArguments());
+ active = activeParameter(call.getArguments(), groovy[0], groovy[1]);
+ ClassNode receiver = call.getObjectExpression() == null ? null : call.getObjectExpression().getType();
+ overloads.addAll(NavigationService.overloads(receiver, call.getMethodAsString()));
+ if (overloads.isEmpty()) {
+ MethodNode resolved = NavigationService.resolveMethod(call, snapshot);
+ if (resolved != null) {
+ overloads.addAll(NavigationService.overloads(resolved.getDeclaringClass(), call.getMethodAsString()));
+ if (overloads.isEmpty()) {
+ overloads.add(resolved);
+ }
+ }
+ }
+ } else if (node instanceof StaticMethodCallExpression call) {
+ argc = argumentCount(call.getArguments());
+ active = activeParameter(call.getArguments(), groovy[0], groovy[1]);
+ overloads.addAll(NavigationService.overloads(call.getOwnerType(), call.getMethodAsString()));
+ } else if (node instanceof ConstructorCallExpression ctor && ctor.getType() != null) {
+ argc = argumentCount(ctor.getArguments());
+ active = activeParameter(ctor.getArguments(), groovy[0], groovy[1]);
+ 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) {
+ List<org.eclipse.lsp4j.Location> refs = new NavigationService()
+ .references(document, snapshot, position, encoding, true);
+ List<DocumentHighlight> highlights = new ArrayList<>();
+ for (org.eclipse.lsp4j.Location location : refs) {
+ if (document.getUri().toString().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);
+ if (node instanceof ClassNode) {
+ range.setKind(FoldingRangeKind.Region);
+ }
+ ranges.add(range);
+ }
+ }
+ addCommentFolds(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;
+ Range target = range == null ? document.fullRange(enc) : range;
+ String[] lines = text.split("\n", -1);
+ int startLine = target.getStart().getLine();
+ int endLine = Math.min(target.getEnd().getLine(), lines.length - 1);
+ String indentUnit = insertSpaces ? " ".repeat(Math.max(tabSize, 1)) : "\t";
+ int indent = 0;
+ List<String> formatted = new ArrayList<>();
+ for (int i = 0; i < lines.length; i++) {
+ String line = rtrim(lines[i]).replace("\t", insertSpaces ? indentUnit : "\t");
+ String stripped = line.stripLeading();
+ if (stripped.startsWith("}") || stripped.startsWith(")") || stripped.startsWith("]")) {
+ indent = Math.max(indent - 1, 0);
+ }
+ String rebuilt = stripped.isEmpty() ? "" : indentUnit.repeat(indent) + stripped;
+ if (i >= startLine && i <= endLine) {
+ formatted.add(rebuilt);
+ } else {
+ formatted.add(lines[i]);
+ }
+ if (stripped.endsWith("{") || stripped.endsWith("(") && !stripped.contains(")")) {
+ indent += 1;
+ }
+ }
+ 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));
+ }
+
+ /**
+ * 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
+ * @param encoding negotiated encoding
+ * @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, final PositionEncoding encoding) {
+ if (document == null || !"\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 = 0;
+ while (indent < previous.length() && (previous.charAt(indent) == ' ' || previous.charAt(indent) == '\t')) {
+ indent += 1;
+ }
+ if (rtrim(previous).endsWith("{")) {
+ indent += Math.max(tabSize, 1);
+ }
+ String current = lines[position.getLine()];
+ int existing = 0;
+ while (existing < current.length() && (current.charAt(existing) == ' ' || current.charAt(existing) == '\t')) {
+ existing += 1;
+ }
+ String pad;
+ if (insertSpaces) {
+ pad = " ".repeat(indent);
+ } else {
+ pad = "\t".repeat(Math.max(indent / Math.max(tabSize, 1), 0));
+ }
+ if (pad.equals(current.substring(0, existing))) {
+ return List.of();
+ }
+ return List.of(new TextEdit(new Range(new Position(position.getLine(), 0),
+ new Position(position.getLine(), existing)), 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 || document.getModule() == null) {
+ return new SemanticTokens(data);
+ }
+ int prevLine = 0;
+ int prevChar = 0;
+ List<int[]> tokens = new ArrayList<>();
+ for (ASTNode node : AstQuery.declarations(document.getModule())) {
+ Range nameRange = node instanceof AnnotatedNode annotated
+ ? Positions.toNameRange(annotated, document.getText(), encoding)
+ : Positions.toRange(node, document.getText(), encoding);
+ if (nameRange == null || !overlaps(nameRange, range)) {
+ continue;
+ }
+ 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, 1});
+ }
+ addKeywordTokens(document.getText(), tokens, encoding, range);
+ tokens.sort((a, b) -> a[0] != b[0] ? Integer.compare(a[0], b[0]) : Integer.compare(a[1], b[1]));
+ 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) {
+ List<InlayHint> hints = new ArrayList<>();
+ if (document == null || document.getModule() == null) {
+ return hints;
+ }
+ for (ClassNode classNode : document.getModule().getClasses()) {
+ for (MethodNode method : classNode.getMethods()) {
+ if (method.isSynthetic() || method.getCode() == null) {
+ continue;
+ }
+ for (Parameter parameter : method.getParameters()) {
+ if (!parameter.isDynamicTyped()) {
+ continue;
+ }
+ Range nameRange = Positions.toNameRange(parameter, document.getText(), encoding);
+ if (nameRange == null || (range != null && nameRange.getStart().getLine() < range.getStart().getLine())) {
+ continue;
+ }
+ InlayHint hint = new InlayHint(nameRange.getStart(),
+ Either.forLeft(parameter.getType().getNameWithoutPackage() + " "));
+ hint.setKind(InlayHintKind.Type);
+ hint.setPaddingRight(true);
+ hints.add(hint);
+ }
+ }
+ }
+ return hints;
+ }
+
+ public List<Either<Command, CodeAction>> codeActions(final TextDocument document,
+ final CompilationSnapshot snapshot,
+ final List<org.eclipse.lsp4j.Diagnostic> diagnostics,
+ final Range range, final PositionEncoding encoding,
+ final java.util.Collection<java.net.URI> folders,
+ final List<String> sourcePaths) {
+ List<Either<Command, CodeAction>> actions = new ArrayList<>();
+ CompiledDocument compiled = snapshot == null ? null : snapshot.get(document.getUri());
+ 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 (org.eclipse.lsp4j.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));
+ }
+ }
+ }
+ 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 = new TextDocument(document.getUri(), "groovy", document.getVersion(), document.getText());
+ NavigationService navigation = new NavigationService();
+ for (ASTNode node : AstQuery.declarations(document.getModule())) {
+ if (!(node instanceof ClassNode) && !(node instanceof MethodNode)) {
+ continue;
+ }
+ if (node instanceof MethodNode method && method.isSynthetic()) {
+ continue;
+ }
+ Range range = Positions.toNameRange((org.codehaus.groovy.ast.AnnotatedNode) node, document.getText(), encoding);
+ if (range == null) {
+ continue;
+ }
+ 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"));
+ lenses.add(lens);
+ }
+ return lenses;
+ }
+
+ public List<CallHierarchyItem> prepareCallHierarchy(final TextDocument document,
+ final CompilationSnapshot snapshot,
+ final Position position, final PositionEncoding encoding) {
+ return hierarchy.prepareCallHierarchy(document, snapshot, position, encoding);
+ }
+
+ public List<CallHierarchyIncomingCall> incomingCalls(final CallHierarchyItem item,
+ final CompilationSnapshot snapshot,
+ final PositionEncoding encoding) {
+ return hierarchy.incomingCalls(item, snapshot, encoding);
+ }
+
+ public List<CallHierarchyOutgoingCall> outgoingCalls(final CallHierarchyItem item,
+ final CompilationSnapshot snapshot,
+ final PositionEncoding encoding) {
+ return hierarchy.outgoingCalls(item, snapshot, encoding);
+ }
+
+ public List<TypeHierarchyItem> prepareTypeHierarchy(final TextDocument document,
+ final CompilationSnapshot snapshot,
+ final Position position, final PositionEncoding encoding) {
+ return hierarchy.prepareTypeHierarchy(document, snapshot, position, encoding);
+ }
+
+ public List<TypeHierarchyItem> supertypes(final TypeHierarchyItem item, final CompilationSnapshot snapshot,
+ final PositionEncoding encoding) {
+ return hierarchy.supertypes(item, snapshot, encoding);
+ }
+
+ public List<TypeHierarchyItem> subtypes(final TypeHierarchyItem item, final CompilationSnapshot snapshot,
+ final PositionEncoding encoding) {
+ return hierarchy.subtypes(item, snapshot, encoding);
+ }
+
+ 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.getUri());
+ ModuleNode module = compiled == null ? null : compiled.getModule();
+ for (Position position : positions) {
+ SelectionRange current = null;
+ if (module != null) {
+ int[] groovy = document.toGroovy(position, encoding);
+ List<ASTNode> path = AstQuery.containing(module, groovy[0], groovy[1]);
+ 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())) {
+ Range range = Positions.toRange(imp, document.getText(), encoding);
+ if (range == null) {
+ continue;
+ }
+ String target = importTarget(imp, document, snapshot);
+ if (target == null) {
+ continue;
+ }
+ links.add(new DocumentLink(range, target));
+ }
+ return links;
+ }
+
+ 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 range = Positions.toRange(imp, document.getText(), encoding);
+ if (range == null) {
+ continue;
+ }
+ if (first == null || comparePosition(range.getStart(), first.getStart()) < 0) {
+ first = range;
+ }
+ if (last == null || comparePosition(range.getEnd(), last.getEnd()) > 0) {
+ last = range;
+ }
+ }
+ names.sort(importOrder());
+ if (first == null || last == 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 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 addKeywordTokens(final String text, final List<int[]> tokens,
+ final PositionEncoding encoding, final Range range) {
+ String[] lines = text.split("\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ if (range != null && (i < range.getStart().getLine() || i > range.getEnd().getLine())) {
+ continue;
+ }
+ String line = lines[i];
+ for (String keyword : GroovyKeywords.KEYWORDS) {
+ int idx = 0;
+ while ((idx = line.indexOf(keyword, idx)) >= 0) {
+ boolean startOk = idx == 0 || !Character.isJavaIdentifierPart(line.charAt(idx - 1));
+ int end = idx + keyword.length();
+ boolean endOk = end >= line.length() || !Character.isJavaIdentifierPart(line.charAt(end));
+ if (startOk && endOk) {
+ int startChar = Positions.groovyColumnToCharacter(line,
+ Positions.characterToGroovyColumn(line, idx, PositionEncoding.UTF16), encoding);
+ int endChar = Positions.groovyColumnToCharacter(line,
+ Positions.characterToGroovyColumn(line, end, PositionEncoding.UTF16), encoding);
+ tokens.add(new int[]{i, startChar, Math.max(endChar - startChar, 1), 8, 0});
+ }
+ idx = end;
+ }
+ }
+ }
+ }
+
+ 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++) {
+ Expression arg = exprs.get(i);
+ if (arg.getLineNumber() <= 0) {
+ continue;
+ }
+ int lastLine = arg.getLastLineNumber() > 0 ? arg.getLastLineNumber() : arg.getLineNumber();
+ int lastColumn = arg.getLastColumnNumber() > 0 ? arg.getLastColumnNumber() : arg.getColumnNumber();
+ if (line > lastLine || (line == lastLine && column > lastColumn)) {
+ active = i + 1;
+ }
+ }
+ if (active >= exprs.size()) {
+ active = exprs.size() - 1;
+ }
+ return Math.max(active, 0);
+ }
+
+ private static boolean overlaps(final Range token, final Range range) {
+ if (range == null || token == null) {
+ return true;
+ }
+ if (token.getEnd().getLine() < range.getStart().getLine()
+ || token.getStart().getLine() > range.getEnd().getLine()) {
+ return false;
+ }
+ return true;
+ }
+
+ 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 int argumentCount(final Expression arguments) {
+ if (arguments instanceof TupleExpression tuple) {
+ return tuple.getExpressions().size();
+ }
+ return arguments == null ? 0 : 1;
+ }
+
+ 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 hit == null ? List.of(new TypeIndex.TypeHit(className, className, null, false, false, ""))
+ : List.of(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 "";
+ }
+ if (startLine == endLine) {
+ String line = lines[startLine];
+ int start = Math.min(first.getStart().getCharacter(), line.length());
+ int end = Math.min(last.getEnd().getCharacter(), line.length());
+ return line.substring(Math.max(start, 0), Math.max(end, start));
+ }
+ return text;
+ }
+
+ private 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 && imp.getPackageName() != null) {
+ return null;
+ }
+ if (snapshot != null && className != 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();
+ }
+ }
+ }
+ }
+ if (document.getUri() == null || !"file".equalsIgnoreCase(document.getUri().getScheme()) || className == null) {
+ return null;
+ }
+ java.nio.file.Path parent = org.apache.groovy.lsp.internal.util.Uris.toPath(document.getUri()).getParent();
+ if (parent == null) {
+ return null;
+ }
+ return parent.resolve(className.replace('.', '/') + ".groovy").toUri().toString();
+ }
+
+ private static int tokenType(final ASTNode node) {
+ if (node instanceof ClassNode classNode) {
+ if (classNode.isInterface()) {
+ return 3;
+ }
+ if (classNode.isEnum()) {
+ return 2;
+ }
+ return 1;
+ }
+ if (node instanceof MethodNode) {
+ return 4;
+ }
+ if (node instanceof org.codehaus.groovy.ast.PropertyNode) {
+ return 5;
+ }
+ if (node instanceof org.codehaus.groovy.ast.FieldNode) {
+ return 5;
+ }
+ if (node instanceof Parameter) {
+ return 7;
+ }
+ return 6;
+ }
+
+ static String diagnosticMessage(final org.eclipse.lsp4j.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..a6afef2f30
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolIdentity.java
@@ -0,0 +1,276 @@
+/*
+ * 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, final CompilationSnapshot snapshot) {
+ if (node == null) {
+ return false;
+ }
+ if (node == declaration) {
+ return true;
+ }
+ return switch (kind) {
+ case TYPE -> refersToType(node);
+ case METHOD -> refersToMethod(node, context, snapshot);
+ case FIELD -> refersToField(node);
+ case VARIABLE -> refersToVariable(node);
+ };
+ }
+
+ static ASTNode declarationOf(final ASTNode node, final CompilationSnapshot snapshot) {
+ if (node instanceof VariableExpression variable) {
+ Variable accessed = variable.getAccessedVariable();
+ if (accessed instanceof ASTNode ast && ast.getLineNumber() > 0) {
+ return ast;
+ }
+ return variable;
+ }
+ if (node instanceof MethodCallExpression call) {
+ MethodNode target = NavigationService.resolveMethod(call, snapshot);
+ return target != null ? target : call;
+ }
+ if (node instanceof StaticMethodCallExpression call) {
+ MethodNode target = NavigationService.resolveStatic(call, snapshot);
+ return target != null ? target : call;
+ }
+ if (node instanceof PropertyExpression property) {
+ ASTNode target = NavigationService.resolveProperty(property);
+ return target != null ? target : 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;
+ }
+
+ 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();
+ }
+ if (node instanceof FieldNode field) {
+ return "F:" + ownerName(field.getDeclaringClass()) + "#" + field.getName();
+ }
+ if (node instanceof PropertyNode property) {
+ return "P:" + ownerName(property.getDeclaringClass()) + "#" + property.getName();
+ }
+ if (node instanceof Parameter parameter) {
+ return "V:" + parameter.getName() + "@" + parameter.getLineNumber() + ":" + parameter.getColumnNumber();
+ }
+ if (node instanceof VariableExpression variable) {
+ return "V:" + variable.getName() + "@" + variable.getLineNumber() + ":" + variable.getColumnNumber();
+ }
+ if (node instanceof MethodCallExpression call) {
+ return "M:?#" + call.getMethodAsString();
+ }
+ if (node instanceof StaticMethodCallExpression call) {
+ return "M:" + ownerName(call.getOwnerType()) + "#" + call.getMethodAsString();
+ }
+ if (node instanceof PropertyExpression property) {
+ return "F:?#" + property.getPropertyAsString();
+ }
+ if (node instanceof ClassExpression classExpression && classExpression.getType() != null) {
+ return "C:" + classExpression.getType().getName();
+ }
+ return null;
+ }
+
+ private static Kind kindOf(final ASTNode node) {
+ if (node instanceof MethodNode || node instanceof MethodCallExpression
+ || node instanceof StaticMethodCallExpression) {
+ return Kind.METHOD;
+ }
+ if (node instanceof ClassNode || node instanceof ClassExpression
+ || node instanceof ConstructorCallExpression) {
+ return Kind.TYPE;
+ }
+ if (node instanceof FieldNode || node instanceof PropertyNode || node instanceof PropertyExpression) {
+ return Kind.FIELD;
+ }
+ return Kind.VARIABLE;
+ }
+
+ private boolean refersToType(final ASTNode node) {
+ if (node instanceof ClassNode || node instanceof ClassExpression || node instanceof ConstructorCallExpression) {
+ return key.equals(keyOf(node));
+ }
+ return false;
+ }
+
+ private boolean refersToMethod(final ASTNode node, final AstContext context, final CompilationSnapshot snapshot) {
+ if (node instanceof MethodNode) {
+ return key.equals(keyOf(node));
+ }
+ if (node instanceof MethodCallExpression call) {
+ if (declaration instanceof MethodNode method) {
+ return NavigationService.callMatches(call, method, context, snapshot);
+ }
+ return name != null && name.equals(call.getMethodAsString()) && key.equals(keyOf(call));
+ }
+ if (node instanceof StaticMethodCallExpression call) {
+ if (declaration instanceof MethodNode method) {
+ return NavigationService.staticCallMatches(call, method);
+ }
+ return key.equals(keyOf(call));
+ }
+ return false;
+ }
+
+ private boolean refersToField(final ASTNode node) {
+ if (node instanceof FieldNode || node instanceof PropertyNode) {
+ return ownerAndName(key).equals(ownerAndName(keyOf(node)));
+ }
+ if (node instanceof PropertyExpression property) {
+ ASTNode target = NavigationService.resolveProperty(property);
+ if (target != null) {
+ return isDeclaration(target) || ownerAndName(key).equals(ownerAndName(keyOf(target)));
+ }
+ String propertyName = property.getPropertyAsString();
+ return name != null && name.equals(propertyName)
+ && NavigationService.receiverMatchesOwner(property.getObjectExpression(), ownerFromKey(key));
+ }
+ if (node instanceof VariableExpression variable) {
+ Variable accessed = variable.getAccessedVariable();
+ return accessed == declaration || (accessed instanceof ASTNode ast && isDeclaration(ast));
+ }
+ return false;
+ }
+
+ private boolean refersToVariable(final ASTNode node) {
+ if (node == declaration) {
+ return true;
+ }
+ if (node instanceof VariableExpression variable) {
+ Variable accessed = variable.getAccessedVariable();
+ if (accessed == declaration) {
+ return true;
+ }
+ return accessed instanceof ASTNode ast && key.equals(keyOf(ast));
+ }
+ return key.equals(keyOf(node));
+ }
+
+ private static String ownerName(final ClassNode type) {
+ return type == null ? "" : type.getName();
+ }
+
+ private static String ownerAndName(final String key) {
+ if (key == null) {
+ return "";
+ }
+ int colon = key.indexOf(':');
+ return colon < 0 ? key : key.substring(colon + 1);
+ }
+
+ private static String ownerFromKey(final String key) {
+ if (key == null) {
+ return "";
+ }
+ int colon = key.indexOf(':');
+ int hash = key.indexOf('#');
+ if (colon < 0 || hash < 0 || hash <= colon) {
+ return "";
+ }
+ return key.substring(colon + 1, hash);
+ }
+
+ static boolean isResolvedType(final ClassNode type) {
+ if (type == null || ClassHelper.isDynamicTyped(type) || ClassHelper.isObjectType(type)) {
+ return false;
+ }
+ String name = type.getName();
+ return name != null && !name.isEmpty();
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolService.java
new file mode 100644
index 0000000000..e486977f92
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/SymbolService.java
@@ -0,0 +1,194 @@
+/*
+ * 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.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.PropertyNode;
+import org.eclipse.lsp4j.DocumentSymbol;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SymbolInformation;
+import org.eclipse.lsp4j.SymbolKind;
+import org.eclipse.lsp4j.WorkspaceSymbol;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Document and workspace symbols.
+ */
+public final class SymbolService {
+
+ /**
+ * Hierarchical document symbols for {@code document}.
+ *
+ * @param document compiled document
+ * @param encoding negotiated encoding
+ * @return document symbols
+ */
+ public List<DocumentSymbol> documentSymbols(final CompiledDocument document, final PositionEncoding encoding) {
+ List<DocumentSymbol> symbols = new ArrayList<>();
+ if (document == null || document.getModule() == null) {
+ return symbols;
+ }
+ for (ASTNode node : AstQuery.declarations(document.getModule())) {
+ if (node instanceof ClassNode classNode && !(classNode.isScript() && classNode.getLineNumber() <= 0)) {
+ DocumentSymbol symbol = toDocumentSymbol(classNode, document.getText(), encoding);
+ if (symbol != null) {
+ List<DocumentSymbol> children = new ArrayList<>();
+ for (ASTNode member : AstQuery.declarations(document.getModule())) {
+ if (member instanceof MethodNode method && method.getDeclaringClass() == classNode) {
+ DocumentSymbol child = toDocumentSymbol(method, document.getText(), encoding);
+ if (child != null) {
+ children.add(child);
+ }
+ } else if (member instanceof FieldNode field && field.getDeclaringClass() == classNode) {
+ DocumentSymbol child = toDocumentSymbol(field, document.getText(), encoding);
+ if (child != null) {
+ children.add(child);
+ }
+ } else if (member instanceof PropertyNode property && property.getDeclaringClass() == classNode) {
+ DocumentSymbol child = toDocumentSymbol(property, document.getText(), encoding);
+ if (child != null) {
+ children.add(child);
+ }
+ }
+ }
+ symbol.setChildren(children);
+ symbols.add(symbol);
+ }
+ }
+ }
+ return symbols;
+ }
+
+ /**
+ * Workspace symbols matching {@code query}.
+ *
+ * @param snapshot latest compile
+ * @param query case-insensitive substring
+ * @param encoding negotiated encoding
+ * @return workspace symbols
+ */
+ public List<WorkspaceSymbol> workspaceSymbols(final CompilationSnapshot snapshot, final String query,
+ final PositionEncoding encoding) {
+ String needle = query == null ? "" : query.toLowerCase(Locale.ROOT);
+ List<WorkspaceSymbol> symbols = new ArrayList<>();
+ if (snapshot == null) {
+ return symbols;
+ }
+ for (CompiledDocument document : snapshot.documents()) {
+ for (ASTNode node : AstQuery.declarations(document.getModule())) {
+ String name = NavigationService.nameOf(node);
+ if (name == null || (!needle.isEmpty() && !name.toLowerCase(Locale.ROOT).contains(needle))) {
+ continue;
+ }
+ Range range = node instanceof AnnotatedNode annotated
+ ? Positions.toNameRange(annotated, document.getText(), encoding)
+ : Positions.toRange(node, document.getText(), encoding);
+ if (range == null) {
+ continue;
+ }
+ WorkspaceSymbol symbol = new WorkspaceSymbol(name, kindOf(node),
+ Either.forLeft(new org.eclipse.lsp4j.Location(document.getUri().toString(), range)));
+ symbols.add(symbol);
+ }
+ }
+ return symbols;
+ }
+
+ /**
+ * Legacy symbol information for clients that do not support hierarchical symbols.
+ *
+ * @param document compiled document
+ * @param encoding negotiated encoding
+ * @return symbol information
+ */
+ public List<SymbolInformation> documentSymbolInformation(final CompiledDocument document,
+ final PositionEncoding encoding) {
+ List<SymbolInformation> symbols = new ArrayList<>();
+ if (document == null || document.getModule() == null) {
+ return symbols;
+ }
+ for (ASTNode node : AstQuery.declarations(document.getModule())) {
+ String name = NavigationService.nameOf(node);
+ Range range = node instanceof AnnotatedNode annotated
+ ? Positions.toNameRange(annotated, document.getText(), encoding)
+ : Positions.toRange(node, document.getText(), encoding);
+ if (name == null || range == null) {
+ continue;
+ }
+ symbols.add(new SymbolInformation(name, kindOf(node),
+ new org.eclipse.lsp4j.Location(document.getUri().toString(), range)));
+ }
+ return symbols;
+ }
+
+ private static DocumentSymbol toDocumentSymbol(final AnnotatedNode node, final String text,
+ final PositionEncoding encoding) {
+ String name = NavigationService.nameOf(node);
+ Range range = Positions.toRange(node, text, encoding);
+ Range selection = Positions.toNameRange(node, text, encoding);
+ if (name == null || range == null || selection == null) {
+ return null;
+ }
+ return new DocumentSymbol(name, kindOf(node), range, selection);
+ }
+
+ static SymbolKind kindOf(final ASTNode node) {
+ if (node instanceof ClassNode classNode) {
+ if (classNode.isInterface()) {
+ return SymbolKind.Interface;
+ }
+ if (classNode.isEnum()) {
+ return SymbolKind.Enum;
+ }
+ return SymbolKind.Class;
+ }
+ if (node instanceof ConstructorNode) {
+ return SymbolKind.Constructor;
+ }
+ if (node instanceof MethodNode) {
+ return SymbolKind.Method;
+ }
+ if (node instanceof FieldNode) {
+ return SymbolKind.Field;
+ }
+ if (node instanceof PropertyNode) {
+ return SymbolKind.Property;
+ }
+ return SymbolKind.Variable;
+ }
+
+ static Range emptyRange() {
+ return new Range(new Position(0, 0), new Position(0, 0));
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/position/PositionEncoding.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/position/PositionEncoding.java
new file mode 100644
index 0000000000..da6de069d7
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/position/PositionEncoding.java
@@ -0,0 +1,90 @@
+/*
+ * 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.position;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Negotiated LSP position encoding. Groovy AST columns are Unicode code
+ * points; the protocol default is UTF-16 code units.
+ */
+public enum PositionEncoding {
+ /** UTF-8 code units (bytes). */
+ UTF8("utf-8"),
+ /** UTF-16 code units. Always supported. */
+ UTF16("utf-16"),
+ /** Unicode code points. Matches Groovy AST columns. */
+ UTF32("utf-32");
+
+ private final String protocolName;
+
+ PositionEncoding(final String protocolName) {
+ this.protocolName = protocolName;
+ }
+
+ /**
+ * Returns the LSP {@code PositionEncodingKind} string.
+ *
+ * @return the protocol name
+ */
+ public String protocolName() {
+ return protocolName;
+ }
+
+ /**
+ * Parses a protocol encoding name.
+ *
+ * @param name protocol name, possibly {@code null}
+ * @return the encoding, or {@code null} when unrecognized
+ */
+ public static PositionEncoding fromProtocol(final String name) {
+ if (name == null) {
+ return null;
+ }
+ String n = name.toLowerCase(Locale.ROOT);
+ for (PositionEncoding encoding : values()) {
+ if (encoding.protocolName.equals(n)) {
+ return encoding;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Picks the first encoding the server supports from the client's
+ * preference list. Defaults to {@link #UTF16} when the client omits
+ * the capability, matching LSP 3.17.
+ *
+ * @param clientEncodings client preference, most-preferred first
+ * @return the encoding to advertise in {@code InitializeResult}
+ */
+ public static PositionEncoding negotiate(final List<String> clientEncodings) {
+ if (clientEncodings == null || clientEncodings.isEmpty()) {
+ return UTF16;
+ }
+ for (String name : clientEncodings) {
+ PositionEncoding encoding = fromProtocol(name);
+ if (encoding != null) {
+ return encoding;
+ }
+ }
+ return UTF16;
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/position/Positions.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/position/Positions.java
new file mode 100644
index 0000000000..9a9cc03a6c
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/position/Positions.java
@@ -0,0 +1,355 @@
+/*
+ * 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.position;
+
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.expr.AttributeExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+/**
+ * Converts between Groovy AST coordinates (1-based line, 1-based exclusive
+ * end column, Unicode code points) and LSP coordinates (0-based line,
+ * 0-based character in the negotiated {@link PositionEncoding}).
+ */
+public final class Positions {
+
+ private Positions() {
+ }
+
+ /**
+ * Converts a Groovy caret (1-based line and column) to an LSP position.
+ *
+ * @param groovyLine 1-based line
+ * @param groovyColumn 1-based code-point column
+ * @param lineText the line text without terminator, or {@code ""}
+ * @param encoding negotiated encoding
+ * @return the LSP position
+ */
+ public static Position toLsp(final int groovyLine, final int groovyColumn, final String lineText,
+ final PositionEncoding encoding) {
+ int line = Math.max(groovyLine, 1) - 1;
+ int column = Math.max(groovyColumn, 1);
+ return new Position(line, groovyColumnToCharacter(lineText, column, encoding));
+ }
+
+ /**
+ * Converts an LSP position to a Groovy 1-based code-point column on that line.
+ *
+ * @param position LSP position
+ * @param lineText the line text without terminator
+ * @param encoding negotiated encoding
+ * @return 1-based Groovy column
+ */
+ public static int toGroovyColumn(final Position position, final String lineText,
+ final PositionEncoding encoding) {
+ Objects.requireNonNull(position, "position");
+ return characterToGroovyColumn(lineText, position.getCharacter(), encoding);
+ }
+
+ /**
+ * Converts a Groovy AST node span to an LSP range.
+ *
+ * @param node positioned node
+ * @param document document text
+ * @param encoding negotiated encoding
+ * @return the range, or {@code null} when the node has no source span
+ */
+ public static Range toRange(final ASTNode node, final String document, final PositionEncoding encoding) {
+ if (node == null || node.getLineNumber() <= 0) {
+ return null;
+ }
+ return toRange(node.getLineNumber(), node.getColumnNumber(),
+ node.getLastLineNumber(), node.getLastColumnNumber(), document, encoding);
+ }
+
+ /**
+ * Converts an identifier span to an LSP range, falling back to the full
+ * node span when no name position was recorded.
+ *
+ * @param node annotated node
+ * @param document document text
+ * @param encoding negotiated encoding
+ * @return the selection range
+ */
+ public static Range toNameRange(final AnnotatedNode node, final String document,
+ final PositionEncoding encoding) {
+ if (node == null) {
+ return null;
+ }
+ if (node.hasNamePosition()) {
+ return toRange(node.getNameLineNumber(), node.getNameColumnNumber(),
+ node.getNameLastLineNumber(), node.getNameLastColumnNumber(), document, encoding);
+ }
+ return toRange(node, document, encoding);
+ }
+
+ /**
+ * Selection range for an identifier: the method/property name of a call,
+ * the recorded name span of an annotated node, otherwise the full node.
+ *
+ * @param node positioned node
+ * @param document document text
+ * @param encoding negotiated encoding
+ * @return the range, or {@code null}
+ */
+ public static Range toIdentifierRange(final ASTNode node, final String document,
+ final PositionEncoding encoding) {
+ if (node instanceof MethodCallExpression call && call.getMethod() != null) {
+ Range range = toRange(call.getMethod(), document, encoding);
+ if (range != null) {
+ return range;
+ }
+ }
+ if (node instanceof PropertyExpression property && property.getProperty() != null) {
+ Range range = toRange(property.getProperty(), document, encoding);
+ if (range != null) {
+ return range;
+ }
+ }
+ if (node instanceof AttributeExpression attribute && attribute.getProperty() != null) {
+ Range range = toRange(attribute.getProperty(), document, encoding);
+ if (range != null) {
+ return range;
+ }
+ }
+ if (node instanceof AnnotatedNode annotated) {
+ return toNameRange(annotated, document, encoding);
+ }
+ return toRange(node, document, encoding);
+ }
+
+ /**
+ * Converts a Groovy span to an LSP range.
+ *
+ * @param startLine 1-based start line
+ * @param startColumn 1-based start column
+ * @param endLine 1-based end line
+ * @param endColumn exclusive 1-based end column
+ * @param document full document text
+ * @param encoding negotiated encoding
+ * @return the range
+ */
+ public static Range toRange(final int startLine, final int startColumn, final int endLine, final int endColumn,
+ final String document, final PositionEncoding encoding) {
+ int lastLine = endLine > 0 ? endLine : startLine;
+ int lastColumn = endColumn > 0 ? endColumn : startColumn;
+ String startText = lineText(document, startLine);
+ String endText = startLine == lastLine ? startText : lineText(document, lastLine);
+ return new Range(
+ toLsp(startLine, startColumn, startText, encoding),
+ toLsp(lastLine, lastColumn, endText, encoding));
+ }
+
+ /**
+ * Returns whether {@code (line, column)} (Groovy caret) lies in {@code node}.
+ * The end column is exclusive.
+ *
+ * @param node positioned node
+ * @param line 1-based line
+ * @param column 1-based column
+ * @return {@code true} when the caret is inside the node
+ */
+ public static boolean contains(final ASTNode node, final int line, final int column) {
+ if (node == null || node.getLineNumber() <= 0) {
+ return false;
+ }
+ int lastLine = node.getLastLineNumber() > 0 ? node.getLastLineNumber() : node.getLineNumber();
+ int lastColumn = node.getLastColumnNumber() > 0 ? node.getLastColumnNumber() : node.getColumnNumber();
+ if (line < node.getLineNumber() || line > lastLine) {
+ return false;
+ }
+ if (line == node.getLineNumber() && column < node.getColumnNumber()) {
+ return false;
+ }
+ if (line == lastLine && column >= lastColumn) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns a ranking for "innermost node": later start, then earlier end.
+ * Each coordinate occupies 16 bits so the fields do not overlap.
+ *
+ * @param node positioned node
+ * @return a comparable key; {@code null} nodes rank last
+ */
+ public static long innerScore(final ASTNode node) {
+ if (node == null || node.getLineNumber() <= 0) {
+ return Long.MIN_VALUE;
+ }
+ int lastLine = node.getLastLineNumber() > 0 ? node.getLastLineNumber() : node.getLineNumber();
+ int lastColumn = node.getLastColumnNumber() > 0 ? node.getLastColumnNumber() : node.getColumnNumber();
+ return (pack16(node.getLineNumber()) << 48)
+ | (pack16(node.getColumnNumber()) << 32)
+ | ((0xFFFFL - pack16(lastLine)) << 16)
+ | (0xFFFFL - pack16(lastColumn));
+ }
+
+ private static long pack16(final int value) {
+ if (value < 0) {
+ return 0L;
+ }
+ return Math.min(value, 0xFFFF);
+ }
+
+ /**
+ * Converts a 1-based Groovy column to an LSP character offset on {@code lineText}.
+ *
+ * @param lineText line without terminator
+ * @param groovyColumn 1-based code-point column
+ * @param encoding negotiated encoding
+ * @return LSP character offset
+ */
+ public static int groovyColumnToCharacter(final String lineText, final int groovyColumn,
+ final PositionEncoding encoding) {
+ String line = lineText == null ? "" : lineText;
+ int codePoints = Math.max(groovyColumn, 1) - 1;
+ int total = line.codePointCount(0, line.length());
+ if (codePoints > total) {
+ codePoints = total;
+ }
+ return codePointsToCharacter(line, codePoints, encoding);
+ }
+
+ /**
+ * Converts an LSP character offset on {@code lineText} to a 1-based Groovy column.
+ *
+ * @param lineText line without terminator
+ * @param character LSP character offset
+ * @param encoding negotiated encoding
+ * @return 1-based Groovy column
+ */
+ public static int characterToGroovyColumn(final String lineText, final int character,
+ final PositionEncoding encoding) {
+ String line = lineText == null ? "" : lineText;
+ int max = characterCount(line, encoding);
+ int clamped = character < 0 ? 0 : Math.min(character, max);
+ return characterToCodePoints(line, clamped, encoding) + 1;
+ }
+
+ static int codePointsToCharacter(final String line, final int codePointOffset,
+ final PositionEncoding encoding) {
+ if (codePointOffset <= 0) {
+ return 0;
+ }
+ int utf16 = line.offsetByCodePoints(0, codePointOffset);
+ return switch (encoding == null ? PositionEncoding.UTF16 : encoding) {
+ case UTF16 -> utf16;
+ case UTF32 -> codePointOffset;
+ case UTF8 -> line.substring(0, utf16).getBytes(StandardCharsets.UTF_8).length;
+ };
+ }
+
+ static int characterToCodePoints(final String line, final int character,
+ final PositionEncoding encoding) {
+ PositionEncoding enc = encoding == null ? PositionEncoding.UTF16 : encoding;
+ if (character <= 0) {
+ return 0;
+ }
+ return switch (enc) {
+ case UTF32 -> Math.min(character, line.codePointCount(0, line.length()));
+ case UTF16 -> {
+ int idx = Math.min(character, line.length());
+ if (idx > 0 && idx < line.length() && Character.isLowSurrogate(line.charAt(idx))
+ && Character.isHighSurrogate(line.charAt(idx - 1))) {
+ idx -= 1;
+ }
+ yield line.codePointCount(0, idx);
+ }
+ case UTF8 -> codePointsForUtf8Prefix(line, character);
+ };
+ }
+
+ static int characterCount(final String line, final PositionEncoding encoding) {
+ PositionEncoding enc = encoding == null ? PositionEncoding.UTF16 : encoding;
+ return switch (enc) {
+ case UTF16 -> line.length();
+ case UTF32 -> line.codePointCount(0, line.length());
+ case UTF8 -> line.getBytes(StandardCharsets.UTF_8).length;
+ };
+ }
+
+ /**
+ * Returns the text of the 1-based line, without its terminator.
+ *
+ * @param document full document
+ * @param groovyLine 1-based line number
+ * @return the line, or {@code ""} when out of range
+ */
+ public static String lineText(final String document, final int groovyLine) {
+ if (document == null || groovyLine < 1) {
+ return "";
+ }
+ int current = 1;
+ int start = 0;
+ int i = 0;
+ while (i < document.length()) {
+ char c = document.charAt(i);
+ if (c == '\n' || c == '\r') {
+ if (current == groovyLine) {
+ return document.substring(start, i);
+ }
+ if (c == '\r' && i + 1 < document.length() && document.charAt(i + 1) == '\n') {
+ i += 1;
+ }
+ current += 1;
+ start = i + 1;
+ }
+ i += 1;
+ }
+ if (current == groovyLine) {
+ return document.substring(start);
+ }
+ return "";
+ }
+
+ private static int codePointsForUtf8Prefix(final String line, final int byteCount) {
+ byte[] bytes = line.getBytes(StandardCharsets.UTF_8);
+ int limit = Math.min(Math.max(byteCount, 0), bytes.length);
+ int i = 0;
+ int codePoints = 0;
+ while (i < limit) {
+ int b = bytes[i] & 0xFF;
+ int len;
+ if (b < 0x80) {
+ len = 1;
+ } else if (b < 0xE0) {
+ len = 2;
+ } else if (b < 0xF0) {
+ len = 3;
+ } else {
+ len = 4;
+ }
+ if (i + len > limit) {
+ break;
+ }
+ i += len;
+ codePoints += 1;
+ }
+ return codePoints;
+ }
+}
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
new file mode 100644
index 0000000000..f342911b05
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
@@ -0,0 +1,77 @@
+/*
+ * 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.protocol;
+
+import org.eclipse.lsp4j.ClientCapabilities;
+import org.eclipse.lsp4j.DeclarationCapabilities;
+import org.eclipse.lsp4j.DefinitionCapabilities;
+import org.eclipse.lsp4j.DocumentSymbolCapabilities;
+import org.eclipse.lsp4j.ImplementationCapabilities;
+import org.eclipse.lsp4j.TextDocumentClientCapabilities;
+import org.eclipse.lsp4j.TypeDefinitionCapabilities;
+import org.eclipse.lsp4j.WorkspaceClientCapabilities;
+
+/**
+ * Reads optional client capability flags. Missing capabilities mean the
+ * conservative LSP default (no {@code LocationLink}, no applyEdit, …).
+ */
+public final class ClientFeatures {
+
+ private ClientFeatures() {
+ }
+
+ public static boolean definitionLinks(final ClientCapabilities capabilities) {
+ DefinitionCapabilities definition = text(capabilities) == null ? null : text(capabilities).getDefinition();
+ return definition != null && Boolean.TRUE.equals(definition.getLinkSupport());
+ }
+
+ public static boolean typeDefinitionLinks(final ClientCapabilities capabilities) {
+ TypeDefinitionCapabilities typeDefinition = text(capabilities) == null ? null : text(capabilities).getTypeDefinition();
+ return typeDefinition != null && Boolean.TRUE.equals(typeDefinition.getLinkSupport());
+ }
+
+ public static boolean implementationLinks(final ClientCapabilities capabilities) {
+ ImplementationCapabilities implementation = text(capabilities) == null ? null : text(capabilities).getImplementation();
+ return implementation != null && Boolean.TRUE.equals(implementation.getLinkSupport());
+ }
+
+ public static boolean declarationLinks(final ClientCapabilities capabilities) {
+ DeclarationCapabilities declaration = text(capabilities) == null ? null : text(capabilities).getDeclaration();
+ return declaration != null && Boolean.TRUE.equals(declaration.getLinkSupport());
+ }
+
+ public static boolean hierarchicalDocumentSymbols(final ClientCapabilities capabilities) {
+ DocumentSymbolCapabilities symbols = text(capabilities) == null ? null : text(capabilities).getDocumentSymbol();
+ return symbols != null && Boolean.TRUE.equals(symbols.getHierarchicalDocumentSymbolSupport());
+ }
+
+ public static boolean applyEdit(final ClientCapabilities capabilities) {
+ WorkspaceClientCapabilities workspace = capabilities == null ? null : capabilities.getWorkspace();
+ return workspace != null && Boolean.TRUE.equals(workspace.getApplyEdit());
+ }
+
+ public static boolean workDoneProgress(final ClientCapabilities capabilities) {
+ return capabilities != null && capabilities.getWindow() != null
+ && Boolean.TRUE.equals(capabilities.getWindow().getWorkDoneProgress());
+ }
+
+ private static TextDocumentClientCapabilities text(final ClientCapabilities capabilities) {
+ return capabilities == null ? null : capabilities.getTextDocument();
+ }
+}
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
new file mode 100644
index 0000000000..1904f94509
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
@@ -0,0 +1,462 @@
+/*
+ * 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.protocol;
+
+import org.apache.groovy.lsp.internal.LanguageServerContext;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.workspace.TextDocument;
+import org.eclipse.lsp4j.CallHierarchyIncomingCall;
+import org.eclipse.lsp4j.CallHierarchyIncomingCallsParams;
+import org.eclipse.lsp4j.CallHierarchyItem;
+import org.eclipse.lsp4j.CallHierarchyOutgoingCall;
+import org.eclipse.lsp4j.CallHierarchyOutgoingCallsParams;
+import org.eclipse.lsp4j.CallHierarchyPrepareParams;
+import org.eclipse.lsp4j.CodeAction;
+import org.eclipse.lsp4j.CodeActionParams;
+import org.eclipse.lsp4j.CodeLens;
+import org.eclipse.lsp4j.CodeLensParams;
+import org.eclipse.lsp4j.Command;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionList;
+import org.eclipse.lsp4j.CompletionParams;
+import org.eclipse.lsp4j.DeclarationParams;
+import org.eclipse.lsp4j.DefinitionParams;
+import org.eclipse.lsp4j.DidChangeTextDocumentParams;
+import org.eclipse.lsp4j.DidCloseTextDocumentParams;
+import org.eclipse.lsp4j.DidOpenTextDocumentParams;
+import org.eclipse.lsp4j.DidSaveTextDocumentParams;
+import org.eclipse.lsp4j.DocumentDiagnosticParams;
+import org.eclipse.lsp4j.DocumentDiagnosticReport;
+import org.eclipse.lsp4j.DocumentFormattingParams;
+import org.eclipse.lsp4j.DocumentHighlight;
+import org.eclipse.lsp4j.DocumentHighlightParams;
+import org.eclipse.lsp4j.DocumentLink;
+import org.eclipse.lsp4j.DocumentLinkParams;
+import org.eclipse.lsp4j.DocumentOnTypeFormattingParams;
+import org.eclipse.lsp4j.DocumentRangeFormattingParams;
+import org.eclipse.lsp4j.DocumentSymbol;
+import org.eclipse.lsp4j.DocumentSymbolParams;
+import org.eclipse.lsp4j.FoldingRange;
+import org.eclipse.lsp4j.FoldingRangeRequestParams;
+import org.eclipse.lsp4j.Hover;
+import org.eclipse.lsp4j.HoverParams;
+import org.eclipse.lsp4j.ImplementationParams;
+import org.eclipse.lsp4j.InlayHint;
+import org.eclipse.lsp4j.InlayHintParams;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.LocationLink;
+import org.eclipse.lsp4j.PrepareRenameParams;
+import org.eclipse.lsp4j.PublishDiagnosticsParams;
+import org.eclipse.lsp4j.PrepareRenameResult;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.ReferenceParams;
+import org.eclipse.lsp4j.RelatedFullDocumentDiagnosticReport;
+import org.eclipse.lsp4j.RenameParams;
+import org.eclipse.lsp4j.SelectionRange;
+import org.eclipse.lsp4j.SelectionRangeParams;
+import org.eclipse.lsp4j.SemanticTokens;
+import org.eclipse.lsp4j.SemanticTokensParams;
+import org.eclipse.lsp4j.SemanticTokensRangeParams;
+import org.eclipse.lsp4j.SignatureHelp;
+import org.eclipse.lsp4j.SignatureHelpParams;
+import org.eclipse.lsp4j.SymbolInformation;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.TypeDefinitionParams;
+import org.eclipse.lsp4j.TypeHierarchyItem;
+import org.eclipse.lsp4j.TypeHierarchyPrepareParams;
+import org.eclipse.lsp4j.TypeHierarchySubtypesParams;
+import org.eclipse.lsp4j.TypeHierarchySupertypesParams;
+import org.eclipse.lsp4j.WorkspaceEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import org.eclipse.lsp4j.jsonrpc.messages.Either3;
+import org.eclipse.lsp4j.services.TextDocumentService;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Dispatches {@code textDocument/*} requests to feature services.
+ */
+public final class GroovyTextDocumentService implements TextDocumentService {
+
+ private final LanguageServerContext context;
+
+ public GroovyTextDocumentService(final LanguageServerContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public void didOpen(final DidOpenTextDocumentParams params) {
+ context.getDocuments().open(params.getTextDocument());
+ context.scheduleRecompile(50);
+ }
+
+ @Override
+ public void didChange(final DidChangeTextDocumentParams params) {
+ context.getDocuments().change(params.getTextDocument().getUri(), params.getTextDocument().getVersion(),
+ params.getContentChanges(), context.getPositionEncoding());
+ context.scheduleRecompile(150);
+ }
+
+ @Override
+ public void didClose(final DidCloseTextDocumentParams params) {
+ String uri = params.getTextDocument().getUri();
+ context.getDocuments().close(uri);
+ if (context.getClient() != null) {
+ context.getClient().publishDiagnostics(new PublishDiagnosticsParams(uri, List.of()));
+ }
+ context.scheduleRecompile(50);
+ }
+
+ @Override
+ public void didSave(final DidSaveTextDocumentParams params) {
+ context.scheduleRecompile(0);
+ }
+
+ @Override
+ public CompletableFuture<Either<List<CompletionItem>, CompletionList>> completion(final CompletionParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(Either.forRight(new CompletionList(false, List.of())));
+ }
+ CompletionList list = context.getCompletions().complete(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding());
+ return CompletableFuture.completedFuture(Either.forRight(list));
+ }
+
+ @Override
+ public CompletableFuture<CompletionItem> resolveCompletionItem(final CompletionItem unresolved) {
+ return CompletableFuture.completedFuture(context.getCompletions().resolve(unresolved, context.getSnapshot()));
+ }
+
+ @Override
+ public CompletableFuture<Hover> hover(final HoverParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(null);
+ }
+ return CompletableFuture.completedFuture(
+ context.getHovers().hover(document, context.getSnapshot(), params.getPosition(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<SignatureHelp> signatureHelp(final SignatureHelpParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(new SignatureHelp(List.of(), 0, 0));
+ }
+ return CompletableFuture.completedFuture(
+ context.getSupport().signatureHelp(document, context.getSnapshot(), params.getPosition(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> definition(
+ final DefinitionParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return emptyLinks();
+ }
+ return asLocations(context.getNavigation().definition(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding()), ClientFeatures.definitionLinks(context.getClientCapabilities()));
+ }
+
+ @Override
+ public CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> typeDefinition(
+ final TypeDefinitionParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return emptyLinks();
+ }
+ return asLocations(context.getNavigation().typeDefinition(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding()), ClientFeatures.typeDefinitionLinks(context.getClientCapabilities()));
+ }
+
+ @Override
+ public CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> implementation(
+ final ImplementationParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return emptyLinks();
+ }
+ return asLocations(context.getNavigation().implementation(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding()), ClientFeatures.implementationLinks(context.getClientCapabilities()));
+ }
+
+ @Override
+ public CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> declaration(
+ final DeclarationParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return emptyLinks();
+ }
+ return asLocations(context.getNavigation().declaration(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding()), ClientFeatures.declarationLinks(context.getClientCapabilities()));
+ }
+
+ @Override
+ public CompletableFuture<List<? extends Location>> references(final ReferenceParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ boolean include = params.getContext() == null || params.getContext().isIncludeDeclaration();
+ return CompletableFuture.completedFuture(
+ context.getNavigation().references(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding(), include));
+ }
+
+ @Override
+ public CompletableFuture<List<? extends DocumentHighlight>> documentHighlight(final DocumentHighlightParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ return CompletableFuture.completedFuture(
+ context.getSupport().highlights(document, context.getSnapshot(), params.getPosition(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<Either<SymbolInformation, DocumentSymbol>>> documentSymbol(
+ final DocumentSymbolParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ List<Either<SymbolInformation, DocumentSymbol>> result = new java.util.ArrayList<>();
+ if (ClientFeatures.hierarchicalDocumentSymbols(context.getClientCapabilities())) {
+ for (DocumentSymbol symbol : context.getSymbols().documentSymbols(compiled, context.getPositionEncoding())) {
+ result.add(Either.forRight(symbol));
+ }
+ } else {
+ for (SymbolInformation symbol : context.getSymbols().documentSymbolInformation(compiled, context.getPositionEncoding())) {
+ result.add(Either.forLeft(symbol));
+ }
+ }
+ return CompletableFuture.completedFuture(result);
+ }
+
+ @Override
+ public CompletableFuture<List<Either<Command, CodeAction>>> codeAction(final CodeActionParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ return CompletableFuture.completedFuture(
+ context.getSupport().codeActions(document, context.getSnapshot(),
+ params.getContext() == null ? List.of() : params.getContext().getDiagnostics(),
+ params.getRange(), context.getPositionEncoding(),
+ context.getWorkspaceFolders(), context.getSettings().getSourcePaths()));
+ }
+
+ @Override
+ public CompletableFuture<List<? extends CodeLens>> codeLens(final CodeLensParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ return CompletableFuture.completedFuture(
+ context.getSupport().codeLenses(compiled, context.getSnapshot(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<? extends TextEdit>> formatting(final DocumentFormattingParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ int tab = params.getOptions() == null ? 4 : params.getOptions().getTabSize();
+ boolean spaces = params.getOptions() == null || params.getOptions().isInsertSpaces();
+ return CompletableFuture.completedFuture(
+ context.getSupport().format(document, null, tab, spaces, context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<? extends TextEdit>> rangeFormatting(final DocumentRangeFormattingParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ int tab = params.getOptions() == null ? 4 : params.getOptions().getTabSize();
+ boolean spaces = params.getOptions() == null || params.getOptions().isInsertSpaces();
+ return CompletableFuture.completedFuture(
+ context.getSupport().format(document, params.getRange(), tab, spaces, context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<? extends TextEdit>> onTypeFormatting(final DocumentOnTypeFormattingParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ int tab = params.getOptions() == null ? 4 : params.getOptions().getTabSize();
+ boolean spaces = params.getOptions() == null || params.getOptions().isInsertSpaces();
+ return CompletableFuture.completedFuture(context.getSupport().onTypeFormat(document, params.getPosition(),
+ params.getCh(), tab, spaces, context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<Either3<Range, PrepareRenameResult, org.eclipse.lsp4j.PrepareRenameDefaultBehavior>> prepareRename(
+ final PrepareRenameParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(null);
+ }
+ Either<Range, PrepareRenameResult> result = context.getRename().prepareRename(
+ document, context.getSnapshot(), params.getPosition(), context.getPositionEncoding());
+ if (result == null) {
+ return CompletableFuture.completedFuture(null);
+ }
+ if (result.isLeft()) {
+ return CompletableFuture.completedFuture(Either3.forFirst(result.getLeft()));
+ }
+ return CompletableFuture.completedFuture(Either3.forSecond(result.getRight()));
+ }
+
+ @Override
+ public CompletableFuture<WorkspaceEdit> rename(final RenameParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(new WorkspaceEdit(java.util.Map.of()));
+ }
+ return CompletableFuture.completedFuture(
+ context.getRename().rename(document, context.getSnapshot(), params.getPosition(), params.getNewName(),
+ context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<FoldingRange>> foldingRange(final FoldingRangeRequestParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ return CompletableFuture.completedFuture(context.getSupport().folding(compiled));
+ }
+
+ @Override
+ public CompletableFuture<List<SelectionRange>> selectionRange(final SelectionRangeParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ return CompletableFuture.completedFuture(
+ context.getSupport().selectionRanges(document, context.getSnapshot(), params.getPositions(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<SemanticTokens> semanticTokensFull(final SemanticTokensParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ return CompletableFuture.completedFuture(context.getSupport().semanticTokens(compiled, context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<SemanticTokens> semanticTokensRange(final SemanticTokensRangeParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ return CompletableFuture.completedFuture(
+ context.getSupport().semanticTokens(compiled, context.getPositionEncoding(), params.getRange()));
+ }
+
+ @Override
+ public CompletableFuture<List<InlayHint>> inlayHint(final InlayHintParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ return CompletableFuture.completedFuture(
+ context.getSupport().inlayHints(compiled, params.getRange(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<DocumentLink>> documentLink(final DocumentLinkParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ return CompletableFuture.completedFuture(
+ context.getSupport().documentLinks(compiled, context.getSnapshot(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<DocumentDiagnosticReport> diagnostic(final DocumentDiagnosticParams params) {
+ CompiledDocument compiled = context.getSnapshot().get(params.getTextDocument().getUri());
+ RelatedFullDocumentDiagnosticReport report = new RelatedFullDocumentDiagnosticReport(
+ context.getDiagnostics().convert(compiled, context.getPositionEncoding()));
+ return CompletableFuture.completedFuture(new DocumentDiagnosticReport(report));
+ }
+
+ @Override
+ public CompletableFuture<List<CallHierarchyItem>> prepareCallHierarchy(final CallHierarchyPrepareParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ return CompletableFuture.completedFuture(
+ context.getSupport().prepareCallHierarchy(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<CallHierarchyIncomingCall>> callHierarchyIncomingCalls(
+ final CallHierarchyIncomingCallsParams params) {
+ return CompletableFuture.completedFuture(context.getSupport().incomingCalls(
+ params.getItem(), context.getSnapshot(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<CallHierarchyOutgoingCall>> callHierarchyOutgoingCalls(
+ final CallHierarchyOutgoingCallsParams params) {
+ return CompletableFuture.completedFuture(context.getSupport().outgoingCalls(
+ params.getItem(), context.getSnapshot(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<TypeHierarchyItem>> prepareTypeHierarchy(final TypeHierarchyPrepareParams params) {
+ TextDocument document = require(params.getTextDocument().getUri());
+ if (document == null) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ return CompletableFuture.completedFuture(
+ context.getSupport().prepareTypeHierarchy(document, context.getSnapshot(), params.getPosition(),
+ context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<TypeHierarchyItem>> typeHierarchySupertypes(final TypeHierarchySupertypesParams params) {
+ return CompletableFuture.completedFuture(context.getSupport().supertypes(
+ params.getItem(), context.getSnapshot(), context.getPositionEncoding()));
+ }
+
+ @Override
+ public CompletableFuture<List<TypeHierarchyItem>> typeHierarchySubtypes(final TypeHierarchySubtypesParams params) {
+ return CompletableFuture.completedFuture(context.getSupport().subtypes(
+ params.getItem(), context.getSnapshot(), context.getPositionEncoding()));
+ }
+
+ private TextDocument require(final String uri) {
+ TextDocument document = context.getDocuments().get(uri);
+ if (document != null) {
+ return document;
+ }
+ CompiledDocument compiled = context.getSnapshot().get(uri);
+ if (compiled == null) {
+ return null;
+ }
+ return new TextDocument(compiled.getUri(), "groovy", compiled.getVersion(), compiled.getText());
+ }
+
+ private static CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> emptyLinks() {
+ return CompletableFuture.completedFuture(Either.forRight(List.of()));
+ }
+
+ private static CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> asLocations(
+ final List<LocationLink> links, final boolean linkSupport) {
+ if (linkSupport) {
+ return CompletableFuture.completedFuture(Either.forRight(links));
+ }
+ List<Location> locations = new java.util.ArrayList<>();
+ for (LocationLink link : links) {
+ locations.add(new Location(link.getTargetUri(), link.getTargetSelectionRange()));
+ }
+ return CompletableFuture.completedFuture(Either.forLeft(locations));
+ }
+}
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
new file mode 100644
index 0000000000..be2985224d
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
@@ -0,0 +1,144 @@
+/*
+ * 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.protocol;
+
+import org.apache.groovy.lsp.internal.LanguageServerContext;
+import org.apache.groovy.lsp.internal.compile.CompiledDocument;
+import org.apache.groovy.lsp.internal.compile.CompilerSettings;
+import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
+import org.eclipse.lsp4j.DidChangeConfigurationParams;
+import org.eclipse.lsp4j.DidChangeWatchedFilesParams;
+import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams;
+import org.eclipse.lsp4j.ExecuteCommandParams;
+import org.eclipse.lsp4j.SymbolInformation;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.WorkspaceEdit;
+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.WorkspaceService;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Workspace folders, configuration, watched files, symbols and commands.
+ */
+public final class GroovyWorkspaceService implements WorkspaceService {
+
+ private final LanguageServerContext context;
+
+ public GroovyWorkspaceService(final LanguageServerContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public void didChangeConfiguration(final DidChangeConfigurationParams params) {
+ Object settings = params.getSettings();
+ if (settings instanceof Map<?, ?> map) {
+ Object groovy = map.get("groovy");
+ if (groovy instanceof Map<?, ?> groovyMap) {
+ apply(groovyMap);
+ } else {
+ apply(map);
+ }
+ }
+ context.scheduleRecompile(0);
+ }
+
+ @Override
+ public void didChangeWatchedFiles(final DidChangeWatchedFilesParams params) {
+ context.scheduleRecompile(100);
+ }
+
+ @Override
+ public void didChangeWorkspaceFolders(final DidChangeWorkspaceFoldersParams params) {
+ if (params.getEvent() == null) {
+ return;
+ }
+ if (params.getEvent().getAdded() != null) {
+ for (WorkspaceFolder folder : params.getEvent().getAdded()) {
+ context.getWorkspaceFolders().add(org.apache.groovy.lsp.internal.util.Uris.parse(folder.getUri()));
+ }
+ }
+ if (params.getEvent().getRemoved() != null) {
+ for (WorkspaceFolder folder : params.getEvent().getRemoved()) {
+ context.getWorkspaceFolders().remove(org.apache.groovy.lsp.internal.util.Uris.parse(folder.getUri()));
+ }
+ }
+ context.scheduleRecompile(0);
+ }
+
+ @Override
+ public CompletableFuture<Either<List<? extends SymbolInformation>, List<? extends WorkspaceSymbol>>> symbol(
+ final WorkspaceSymbolParams params) {
+ List<WorkspaceSymbol> symbols = context.getSymbols().workspaceSymbols(context.getSnapshot(),
+ params.getQuery(), context.getPositionEncoding());
+ return CompletableFuture.completedFuture(Either.forRight(symbols));
+ }
+
+ @Override
+ public CompletableFuture<Object> executeCommand(final ExecuteCommandParams params) {
+ String command = params.getCommand();
+ List<Object> args = params.getArguments() == null ? List.of() : params.getArguments();
+ if ("groovy.lsp.organizeImports".equals(command) && !args.isEmpty()) {
+ String uri = String.valueOf(args.get(0));
+ CompiledDocument compiled = context.getSnapshot().get(uri);
+ List<TextEdit> edits = context.getSupport().organizeImports(compiled, context.getPositionEncoding());
+ WorkspaceEdit edit = new WorkspaceEdit(Map.of(uri, edits));
+ if (ClientFeatures.applyEdit(context.getClientCapabilities()) && context.getClient() != null
+ && !edits.isEmpty()) {
+ context.getClient().applyEdit(new ApplyWorkspaceEditParams(edit));
+ }
+ return CompletableFuture.completedFuture(edit);
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @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("grapeEnabled")
+ ? Boolean.TRUE.equals(map.get("grapeEnabled")) : current.isGrapeEnabled();
+ boolean astTest = map.containsKey("astTestEnabled")
+ ? Boolean.TRUE.equals(map.get("astTestEnabled")) : current.isAstTestEnabled();
+ context.setSettings(new CompilerSettings(
+ classpath.isEmpty() ? current.getClasspath() : classpath,
+ sourcePaths.isEmpty() ? current.getSourcePaths() : sourcePaths,
+ current.getThroughPhase(), grape, astTest));
+ }
+
+ 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
new file mode 100644
index 0000000000..3610eda234
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
@@ -0,0 +1,114 @@
+/*
+ * 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.protocol;
+
+import org.apache.groovy.lsp.internal.feature.SupportServices;
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.eclipse.lsp4j.CallHierarchyRegistrationOptions;
+import org.eclipse.lsp4j.CodeActionOptions;
+import org.eclipse.lsp4j.CodeLensOptions;
+import org.eclipse.lsp4j.CompletionOptions;
+import org.eclipse.lsp4j.DiagnosticRegistrationOptions;
+import org.eclipse.lsp4j.DocumentLinkOptions;
+import org.eclipse.lsp4j.DocumentOnTypeFormattingOptions;
+import org.eclipse.lsp4j.ExecuteCommandOptions;
+import org.eclipse.lsp4j.FoldingRangeProviderOptions;
+import org.eclipse.lsp4j.RenameOptions;
+import org.eclipse.lsp4j.SemanticTokensWithRegistrationOptions;
+import org.eclipse.lsp4j.ServerCapabilities;
+import org.eclipse.lsp4j.SignatureHelpOptions;
+import org.eclipse.lsp4j.TextDocumentSyncKind;
+import org.eclipse.lsp4j.TypeHierarchyRegistrationOptions;
+import org.eclipse.lsp4j.WorkspaceFoldersOptions;
+import org.eclipse.lsp4j.WorkspaceServerCapabilities;
+
+import java.util.List;
+
+/**
+ * Advertises LSP 3.18 capabilities. Features that do not apply to Groovy
+ * (document color, notebooks, inline completion) are omitted rather than
+ * stubbed.
+ */
+public final class ServerCapabilityFactory {
+
+ /**
+ * Builds server capabilities for the negotiated encoding.
+ *
+ * @param encoding negotiated position encoding
+ * @return capabilities
+ */
+ public ServerCapabilities create(final PositionEncoding encoding) {
+ ServerCapabilities capabilities = new ServerCapabilities();
+ capabilities.setPositionEncoding(encoding.protocolName());
+ capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental);
+
+ CompletionOptions completion = new CompletionOptions(true, List.of(".", "@"));
+ capabilities.setCompletionProvider(completion);
+ capabilities.setHoverProvider(true);
+ capabilities.setSignatureHelpProvider(new SignatureHelpOptions(List.of("(", ",")));
+ capabilities.setDefinitionProvider(true);
+ capabilities.setTypeDefinitionProvider(true);
+ capabilities.setImplementationProvider(true);
+ capabilities.setDeclarationProvider(true);
+ capabilities.setReferencesProvider(true);
+ capabilities.setDocumentHighlightProvider(true);
+ capabilities.setDocumentSymbolProvider(true);
+ capabilities.setWorkspaceSymbolProvider(true);
+
+ CodeActionOptions codeAction = new CodeActionOptions(List.of(
+ "quickfix", "source.organizeImports", "source",
+ "source.generate.accessors", "source.generate.toString",
+ "source.generate.hashCodeEquals", "source.generate.constructors"));
+ codeAction.setResolveProvider(false);
+ capabilities.setCodeActionProvider(codeAction);
+ capabilities.setCodeLensProvider(new CodeLensOptions(false));
+ capabilities.setDocumentFormattingProvider(true);
+ capabilities.setDocumentRangeFormattingProvider(true);
+ capabilities.setDocumentOnTypeFormattingProvider(new DocumentOnTypeFormattingOptions("\n"));
+ capabilities.setDocumentLinkProvider(new DocumentLinkOptions(false));
+
+ RenameOptions rename = new RenameOptions();
+ rename.setPrepareProvider(true);
+ capabilities.setRenameProvider(rename);
+
+ capabilities.setFoldingRangeProvider(new FoldingRangeProviderOptions());
+ capabilities.setSelectionRangeProvider(true);
+
+ SemanticTokensWithRegistrationOptions tokens = new SemanticTokensWithRegistrationOptions(SupportServices.legend());
+ tokens.setFull(true);
+ tokens.setRange(true);
+ capabilities.setSemanticTokensProvider(tokens);
+
+ capabilities.setInlayHintProvider(true);
+ capabilities.setDiagnosticProvider(new DiagnosticRegistrationOptions(true, false));
+ capabilities.setCallHierarchyProvider(new CallHierarchyRegistrationOptions());
+ capabilities.setTypeHierarchyProvider(new TypeHierarchyRegistrationOptions());
+
+ WorkspaceFoldersOptions folders = new WorkspaceFoldersOptions();
+ folders.setSupported(true);
+ folders.setChangeNotifications(true);
+ WorkspaceServerCapabilities workspace = new WorkspaceServerCapabilities();
+ workspace.setWorkspaceFolders(folders);
+ capabilities.setWorkspace(workspace);
+
+ capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(
+ List.of("groovy.lsp.organizeImports")));
+ return capabilities;
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
new file mode 100644
index 0000000000..4d0b868652
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/GroovyKeywords.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.lsp.internal.util;
+
+import java.util.List;
+
+/**
+ * Groovy keywords and commonly completed literals. Kept as a list rather
+ * than parsed from the lexer so the language server does not depend on
+ * ANTLR token vocabulary details.
+ */
+public final class GroovyKeywords {
+
+ public static final List<String> KEYWORDS = List.of(
+ "as", "assert", "break", "case", "catch", "class", "const", "continue",
+ "def", "default", "do", "else", "enum", "extends", "false", "finally",
+ "for", "goto", "if", "implements", "import", "in", "instanceof",
+ "interface", "new", "non-sealed", "null", "package", "permits",
+ "record", "return", "sealed", "super", "switch", "this", "throw",
+ "throws", "trait", "true", "try", "var", "void", "while", "yield"
+ );
+
+ public static final List<String> MODIFIERS = List.of(
+ "abstract", "final", "native", "private", "protected", "public",
+ "static", "strictfp", "synchronized", "transient", "volatile"
+ );
+
+ private GroovyKeywords() {
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/Uris.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/Uris.java
new file mode 100644
index 0000000000..e58c8c5a64
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/util/Uris.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.lsp.internal.util;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * Normalizes {@code file:} URIs so clients that encode drive-letter colons
+ * and those that do not still identify the same document.
+ */
+public final class Uris {
+
+ private Uris() {
+ }
+
+ /**
+ * Parses and normalizes a document URI string.
+ *
+ * @param uriString the URI from the protocol
+ * @return a comparable URI
+ */
+ public static URI parse(final String uriString) {
+ return normalize(URI.create(Objects.requireNonNull(uriString, "uriString")));
+ }
+
+ /**
+ * Returns a canonical {@code file:} URI when {@code uri} is a file URI.
+ *
+ * @param uri the URI to normalize
+ * @return the normalized URI
+ */
+ public static URI normalize(final URI uri) {
+ Objects.requireNonNull(uri, "uri");
+ if (!"file".equalsIgnoreCase(uri.getScheme())) {
+ return uri;
+ }
+ Path path = Paths.get(uri);
+ URI normalized = path.toUri();
+ String scheme = normalized.getScheme();
+ String rawPath = normalized.getRawPath();
+ if (rawPath != null && rawPath.length() >= 3 && rawPath.charAt(0) == '/'
+ && Character.isLetter(rawPath.charAt(1)) && rawPath.charAt(2) == ':') {
+ char drive = Character.toLowerCase(rawPath.charAt(1));
+ rawPath = "/" + drive + rawPath.substring(2);
+ return URI.create(scheme + "://" + (normalized.getRawAuthority() == null ? "" : normalized.getRawAuthority())
+ + rawPath);
+ }
+ return normalized;
+ }
+
+ /**
+ * Converts a file URI to a path.
+ *
+ * @param uri a file URI
+ * @return the path
+ */
+ public static Path toPath(final URI uri) {
+ return Paths.get(normalize(uri));
+ }
+
+ /**
+ * Returns whether {@code uri} looks like a Groovy source document.
+ *
+ * @param uri document URI
+ * @param languageId LSP language id, which may be {@code null}
+ * @return {@code true} when the document should be compiled as Groovy
+ */
+ public static boolean isGroovyDocument(final URI uri, final String languageId) {
+ if (languageId != null && "groovy".equalsIgnoreCase(languageId)) {
+ return true;
+ }
+ String path = uri.getPath();
+ if (path == null) {
+ return false;
+ }
+ String lower = path.toLowerCase(Locale.ROOT);
+ return lower.endsWith(".groovy") || lower.endsWith(".gvy") || lower.endsWith(".gy")
+ || lower.endsWith(".gsh");
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/DocumentStore.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/DocumentStore.java
new file mode 100644
index 0000000000..b66a33c36b
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/DocumentStore.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.lsp.internal.workspace;
+
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.util.Uris;
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent;
+import org.eclipse.lsp4j.TextDocumentItem;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Thread-safe store of open text documents. The client's buffer is the
+ * source of truth for open files.
+ */
+public final class DocumentStore {
+
+ private final ConcurrentMap<URI, TextDocument> documents = new ConcurrentHashMap<>();
+
+ /**
+ * Opens or replaces a document from {@code textDocument/didOpen}.
+ *
+ * @param item the opened document
+ * @return the stored snapshot
+ */
+ public TextDocument open(final TextDocumentItem item) {
+ URI uri = Uris.parse(item.getUri());
+ TextDocument document = new TextDocument(uri, item.getLanguageId(), item.getVersion(), item.getText());
+ documents.put(uri, document);
+ return document;
+ }
+
+ /**
+ * Applies {@code textDocument/didChange} events.
+ *
+ * @param uriString document URI
+ * @param version new version
+ * @param changes change events
+ * @param encoding negotiated encoding
+ * @return the updated snapshot, or {@code null} when the document is unknown
+ */
+ public TextDocument change(final String uriString, final int version,
+ final List<TextDocumentContentChangeEvent> changes,
+ final PositionEncoding encoding) {
+ URI uri = Uris.parse(uriString);
+ return documents.compute(uri, (key, current) -> {
+ if (current == null) {
+ return new TextDocument(uri, "groovy", version, applyUnknown(changes));
+ }
+ return current.apply(version, changes, encoding);
+ });
+ }
+
+ /**
+ * Removes a document after {@code textDocument/didClose}.
+ *
+ * @param uriString document URI
+ * @return the removed snapshot, or {@code null}
+ */
+ public TextDocument close(final String uriString) {
+ return documents.remove(Uris.parse(uriString));
+ }
+
+ /**
+ * Returns the open document, or {@code null}.
+ *
+ * @param uriString document URI
+ * @return the snapshot
+ */
+ public TextDocument get(final String uriString) {
+ return documents.get(Uris.parse(uriString));
+ }
+
+ /**
+ * Returns the open document, or {@code null}.
+ *
+ * @param uri document URI
+ * @return the snapshot
+ */
+ public TextDocument get(final URI uri) {
+ return documents.get(Uris.normalize(uri));
+ }
+
+ /**
+ * Returns a snapshot of every open document.
+ *
+ * @return open documents
+ */
+ public Collection<TextDocument> snapshot() {
+ return List.copyOf(documents.values());
+ }
+
+ /**
+ * @return the number of open documents
+ */
+ public int size() {
+ return documents.size();
+ }
+
+ /**
+ * Drops every open document.
+ */
+ public void clear() {
+ documents.clear();
+ }
+
+ private static String applyUnknown(final List<TextDocumentContentChangeEvent> changes) {
+ if (changes == null || changes.isEmpty()) {
+ return "";
+ }
+ TextDocumentContentChangeEvent last = changes.get(changes.size() - 1);
+ return last.getText() == null ? "" : last.getText();
+ }
+}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
new file mode 100644
index 0000000000..b8c2749530
--- /dev/null
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/workspace/TextDocument.java
@@ -0,0 +1,191 @@
+/*
+ * 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.workspace;
+
+import org.apache.groovy.lsp.internal.position.PositionEncoding;
+import org.apache.groovy.lsp.internal.position.Positions;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * An in-memory text document with a version. Incremental LSP changes are
+ * applied against the negotiated position encoding.
+ */
+public final class TextDocument {
+
+ private final URI uri;
+ private final String languageId;
+ private final int version;
+ private final String text;
+
+ /**
+ * Creates a document snapshot.
+ *
+ * @param uri document URI
+ * @param languageId language id, possibly {@code null}
+ * @param version LSP version
+ * @param text full text
+ */
+ public TextDocument(final URI uri, final String languageId, final int version, final String text) {
+ this.uri = Objects.requireNonNull(uri, "uri");
+ this.languageId = languageId;
+ this.version = version;
+ this.text = text == null ? "" : text;
+ }
+
+ public URI getUri() {
+ return uri;
+ }
+
+ public String getLanguageId() {
+ return languageId;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ /**
+ * Applies a full or incremental change list, returning a new snapshot.
+ *
+ * @param newVersion the version after the change
+ * @param changes LSP change events
+ * @param encoding negotiated encoding
+ * @return the updated document
+ */
+ public TextDocument apply(final int newVersion, final List<TextDocumentContentChangeEvent> changes,
+ final PositionEncoding encoding) {
+ String next = text;
+ if (changes != null) {
+ for (TextDocumentContentChangeEvent change : changes) {
+ next = applyOne(next, change, encoding);
+ }
+ }
+ return new TextDocument(uri, languageId, newVersion, next);
+ }
+
+ static String applyOne(final String current, final TextDocumentContentChangeEvent change,
+ final PositionEncoding encoding) {
+ if (change.getRange() == null) {
+ return change.getText() == null ? "" : change.getText();
+ }
+ int start = offsetOf(current, change.getRange().getStart(), encoding);
+ int end = offsetOf(current, change.getRange().getEnd(), encoding);
+ if (start < 0) {
+ start = 0;
+ }
+ if (end < start) {
+ end = start;
+ }
+ if (end > current.length()) {
+ end = current.length();
+ }
+ String insert = change.getText() == null ? "" : change.getText();
+ return current.substring(0, start) + insert + current.substring(end);
+ }
+
+ /**
+ * Converts an LSP position to a UTF-16 offset in {@code document}.
+ *
+ * @param document full text
+ * @param position LSP position
+ * @param encoding negotiated encoding
+ * @return the offset, clamped to {@code [0, document.length()]}
+ */
+ public static int offsetOf(final String document, final Position position, final PositionEncoding encoding) {
+ if (document == null || document.isEmpty() || position == null) {
+ return 0;
+ }
+ int line = Math.max(position.getLine(), 0);
+ int currentLine = 0;
+ int i = 0;
+ while (i < document.length() && currentLine < line) {
+ char c = document.charAt(i);
+ if (c == '\n') {
+ currentLine += 1;
+ } else if (c == '\r') {
+ if (i + 1 < document.length() && document.charAt(i + 1) == '\n') {
+ i += 1;
+ }
+ currentLine += 1;
+ }
+ i += 1;
+ }
+ if (currentLine < line) {
+ return document.length();
+ }
+ String lineText = Positions.lineText(document, line + 1);
+ int column = Positions.characterToGroovyColumn(lineText, position.getCharacter(), encoding);
+ int codePoints = Math.max(column - 1, 0);
+ int available = lineText.codePointCount(0, lineText.length());
+ if (codePoints > available) {
+ codePoints = available;
+ }
+ return i + lineText.offsetByCodePoints(0, codePoints);
+ }
+
+ /**
+ * Returns the 1-based Groovy line/column of an LSP position.
+ *
+ * @param position LSP position
+ * @param encoding negotiated encoding
+ * @return {@code int[]{line, column}}
+ */
+ public int[] toGroovy(final Position position, final PositionEncoding encoding) {
+ int line = position.getLine() + 1;
+ int column = Positions.toGroovyColumn(position, Positions.lineText(text, line), encoding);
+ return new int[]{line, column};
+ }
+
+ /**
+ * Returns an LSP range covering the whole document.
+ *
+ * @param encoding negotiated encoding
+ * @return the full range
+ */
+ public Range fullRange(final PositionEncoding encoding) {
+ if (text.isEmpty()) {
+ return new Range(new Position(0, 0), new Position(0, 0));
+ }
+ int lastNl = 1;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '\n') {
+ lastNl += 1;
+ } else if (c == '\r' && (i + 1 >= text.length() || text.charAt(i + 1) != '\n')) {
+ lastNl += 1;
+ }
+ }
+ if (!text.isEmpty() && (text.charAt(text.length() - 1) == '\n' || text.charAt(text.length() - 1) == '\r')) {
+ return new Range(new Position(0, 0), new Position(lastNl - 1, 0));
+ }
+ String lastLine = Positions.lineText(text, lastNl);
+ return new Range(new Position(0, 0),
+ Positions.toLsp(lastNl, lastLine.codePointCount(0, lastLine.length()) + 1, lastLine, encoding));
+ }
+}
diff --git a/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc b/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
new file mode 100644
index 0000000000..904f66cbd9
--- /dev/null
+++ b/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
@@ -0,0 +1,105 @@
+//////////////////////////////////////////
+
+ 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 (`groovy-lsp`)
+:toc:
+
+The `groovy-lsp` module is Apache Groovy's implementation of the
+https://microsoft.github.io/language-server-protocol/[Language Server Protocol].
+It speaks LSP 3.18 over JSON-RPC (stdio by default) and ignores unknown
+3.19 capabilities so newer clients keep working.
+
+== Running
+
+From an installed Groovy distribution:
+
+[source,shell]
+----
+groovylsp
+----
+
+Editors start that process and communicate on stdin/stdout. Optional flags:
+
+* `--stdio` — default
+* `--socket <port>` — accept one TCP client
+* `--version` / `--help`
+
+Do not write to stdout from Groovy code running inside the server; the
+launcher redirects `System.out` to `System.err` so stray prints cannot
+break the protocol stream.
+
+== Features
+
+The server advertises and implements:
+
+* Incremental text synchronization
+* Diagnostics (push and pull)
+* Completion (keywords, snippets, members, workspace types with
+ auto-import, GDK when a prefix is typed) and `completionItem/resolve`
+* Hover, signature help (overloads)
+* Definition, type definition, implementation, declaration, references
+ (by declaration identity, not unqualified name)
+* Document highlight, document symbols, workspace symbols
+* Prepare-rename and rename
+* Code actions (organize imports, remove unused imports, add import,
+ implement abstract methods, insert inferred field types, add package
+ from the file path, create a missing class stub, generate getters
+ and setters / toString / equals and hashCode / constructor, add
+ `@Override`)
+* Code lenses showing reference counts
+* Rename a class also renames the file when the names match
+* Formatting, range formatting, and newline on-type indent
+* Folding ranges, selection ranges
+* Semantic tokens (full and range)
+* Inlay hints (parameter types)
+* Document links for imports (`file:` URIs)
+* Call hierarchy and type hierarchy (prepare, incoming/outgoing, super/sub)
+* Workspace folders, watched files, configuration
+* Command: `groovy.lsp.organizeImports` (preserves star, static and alias
+ imports; applies a workspace edit when the client supports it)
+
+Not advertised (they do not apply to Groovy source, or need a debugger):
+document color, notebooks, inline completion, inline values, monikers.
+
+== Configuration
+
+Client settings under `groovy` (or the root of `workspace/didChangeConfiguration`):
+
+* `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`)
+
+== Classpath
+
+Pass the project classpath from the editor. The server does not invoke
+Gradle or Maven itself; that keeps it free of a Tooling API dependency
+and of surprise downloads. If those settings are empty, it looks for
+conventional Groovy source roots (`src/main/groovy`, `src/test/groovy`,
+`src`) and jars under `lib/` or `libs/`. Unchanged buffers are not
+recompiled.
+
+== Positions
+
+Groovy AST columns are 1-based Unicode code points. LSP positions are
+0-based in the encoding negotiated at `initialize` (`utf-16` by default).
+The server converts in both directions, including supplementary-plane
+characters.
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
new file mode 100644
index 0000000000..4a2b336256
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/GroovyLanguageServerTest.groovy
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.lsp
+
+import org.apache.groovy.lsp.internal.LspFixture
+import org.apache.groovy.lsp.internal.compile.CompilerSettings
+import org.eclipse.lsp4j.CompletionParams
+import org.eclipse.lsp4j.DefinitionParams
+import org.eclipse.lsp4j.DidChangeTextDocumentParams
+import org.eclipse.lsp4j.DidCloseTextDocumentParams
+import org.eclipse.lsp4j.DocumentFormattingParams
+import org.eclipse.lsp4j.DocumentSymbolParams
+import org.eclipse.lsp4j.FormattingOptions
+import org.eclipse.lsp4j.HoverParams
+import org.eclipse.lsp4j.Position
+import org.eclipse.lsp4j.ReferenceContext
+import org.eclipse.lsp4j.ReferenceParams
+import org.eclipse.lsp4j.RenameParams
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent
+import org.eclipse.lsp4j.TextDocumentIdentifier
+import org.eclipse.lsp4j.VersionedTextDocumentIdentifier
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+
+final class GroovyLanguageServerTest {
+
+ private LspFixture fixture
+
+ @AfterEach
+ void tearDown() {
+ fixture?.close()
+ }
+
+ @Test
+ void initializeAdvertisesIncrementalSyncAndCompletion() {
+ fixture = new LspFixture()
+ def result = fixture.server.initialize(new org.eclipse.lsp4j.InitializeParams(
+ capabilities: new org.eclipse.lsp4j.ClientCapabilities()
+ )).get()
+ assert result.capabilities.completionProvider != null
+ assert result.capabilities.hoverProvider
+ assert result.capabilities.renameProvider
+ assert result.serverInfo.name == 'groovy-lsp'
+ }
+
+ @Test
+ void openCompilesAndPublishesDiagnostics() {
+ fixture = new LspFixture()
+ def uri = fixture.open('Hello.groovy', 'class Hello {\n def foo() { bar }\n}\n')
+ assert fixture.diagnostics.containsKey(uri) || fixture.server.context.snapshot.get(uri) != null
+ def compiled = fixture.server.context.snapshot.get(uri)
+ assert compiled != null
+ assert compiled.module != null
+ }
+
+ @Test
+ void completionIncludesKeywordsAndClassName() {
+ fixture = new LspFixture()
+ def uri = fixture.open('Hello.groovy', 'class Hello {\n def foo() { \n}\n}\n')
+ def params = new CompletionParams(new TextDocumentIdentifier(uri), new Position(1, 14))
+ def result = fixture.server.textDocumentService.completion(params).get()
+ def labels = result.getRight().items*.label
+ assert 'class' in labels || 'def' in labels
+ assert labels.any { it == 'Hello' || it == 'foo' }
+ }
+
+ @Test
+ void hoverOnClassName() {
+ fixture = new LspFixture()
+ def uri = fixture.open('Hello.groovy', 'class Hello {\n}\n')
+ def hover = fixture.server.textDocumentService.hover(
+ new HoverParams(new TextDocumentIdentifier(uri), new Position(0, 7))).get()
+ assert hover != null
+ def contents = hover.contents
+ String value = contents instanceof org.eclipse.lsp4j.MarkupContent
+ ? contents.value
+ : contents.getRight().value
+ assert value.contains('Hello')
+ }
+
+ @Test
+ void documentSymbolsIncludeClassAndMethod() {
+ fixture = new LspFixture()
+ def uri = fixture.open('Hello.groovy', 'class Hello {\n def foo() {}\n}\n')
+ def symbols = fixture.server.textDocumentService.documentSymbol(
+ new DocumentSymbolParams(new TextDocumentIdentifier(uri))).get()
+ assert symbols.any { it.getRight().name == 'Hello' }
+ }
+
+ @Test
+ void definitionAndReferencesFindMethod() {
+ fixture = new LspFixture()
+ def uri = fixture.open('Hello.groovy', '''\
+ class Hello {
+ def foo() { foo() }
+ }
+ '''.stripIndent())
+ def defs = fixture.server.textDocumentService.definition(
+ new DefinitionParams(new TextDocumentIdentifier(uri), new Position(1, 8))).get()
+ assert defs.getRight() != null
+ def refs = fixture.server.textDocumentService.references(
+ new ReferenceParams(new TextDocumentIdentifier(uri), new Position(1, 8), new ReferenceContext(true))).get()
+ assert refs != null
+ }
+
+ @Test
+ void renameRewritesIdentifier() {
... 1813 lines suppressed ...