Added partial Groovy language support (#833)

This commit is contained in:
eez-eh authored and GitHub committed 2025-12-16 15:11:46 +01:00
1 parent b193e50a72
commit 47bc4e0b32
14 files changed
+516 -1

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* Language support:
* **Add support for Fortran** via fortls language server (requires `pip install fortls`)
* **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md))
* **Add support for Julia** via LanguageServer.jl
* **Add support for Haskell** via Haskell Language Server (HLS) with automatic discovery via ghcup, stack, or system PATH; supports both Stack and Cabal projects
* **Add support for Scala** via Metals language server (requires some [manual setup](docs/03-special-guides/scala_setup_guide_for_serena.md))
+1 -1
View File
@@ -68,7 +68,7 @@ than existing solutions that charge a premium.
Language servers provide support for a wide range of programming languages.
With Serena's LSP library, we provide **support for over 30 programming languages**, including
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig.
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig.
> [!IMPORTANT]
> Some languages require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
@@ -31,6 +31,8 @@ With Serena, we provide direct, out-of-the-box support for:
(requires installation of fortls: `pip install fortls`)
* **Go**
(requires installation of `gopls`)
* **Groovy**
(requires local groovy-language-server.jar setup via GROOVY_LS_JAR_PATH or configuration)
* **Haskell**
(automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects)
* **Java**
@@ -0,0 +1,88 @@
# Groovy Setup Guide for Serena
The Groovy support in Serena is incomplete and requires the user to provide a functioning,
JVM-based Groovy language server as a jar. This intermediate state allows the contributors
of Groovy support to use Serena internally and hopefully to accelerate their open-source
release of a Groovy language server in the future.
If you happen to have a Groovy language server JAR file, you can configure Serena to use it
by following the instructions below.
---
## Prerequisites
- Groovy Language Server JAR file
- Can be any open-source Groovy language server or your custom implementation
- The JAR must be compatible with standard LSP protocol
---
## Configuration
Configure Groovy Language Server by adding settings to your `~/.serena/serena_config.yml`:
### Basic Configuration
```yaml
ls_specific_settings:
groovy:
ls_jar_path: '/path/to/groovy-language-server.jar'
ls_jar_options: '-Xmx2G -Xms512m'
```
### Custom Java Paths
If you have specific Java installations:
```yaml
ls_specific_settings:
groovy:
ls_jar_path: '/path/to/groovy-language-server.jar'
ls_java_home_path: '/usr/lib/jvm/java-21-openjdk' # Custom JAVA_HOME directory
ls_jar_options: '-Xmx2G -Xms512m' # Optional JVM options
```
### Configuration Options
- `ls_jar_path`: Absolute path to your Groovy Language Server JAR file (required)
- `ls_java_home_path`: Custom JAVA_HOME directory for Java installation (optional)
- When specified, Serena will use this Java installation instead of downloading bundled Java
- Java executable path is automatically determined based on platform:
- Windows: `{ls_java_home_path}/bin/java.exe`
- Linux/macOS: `{ls_java_home_path}/bin/java`
- Validates that Java executable exists at the expected location
- `ls_jar_options`: JVM options for language server (optional)
- Common options:
- `-Xmx<size>`: Maximum heap size (e.g., `-Xmx2G` for 2GB)
- `-Xms<size>`: Initial heap size (e.g., `-Xms512m` for 512MB)
---
## Project Structure Requirements
For optimal Groovy Language Server performance, ensure your project follows standard Groovy/Gradle structure:
```
project-root/
├── src/
│ ├── main/
│ │ ├── groovy/
│ │ └── resources/
│ └── test/
│ ├── groovy/
│ └── resources/
├── build.gradle or build.gradle.kts
├── settings.gradle or settings.gradle.kts
└── gradle/
└── wrapper/
```
---
## Using Serena with Groovy
- Serena automatically detects Groovy files (`*.groovy`, `*.gvy`) and will start a Groovy Language Server JAR process per project when needed.
- Optimal results require that your project compiles successfully via Gradle or Maven. If compilation fails, fix build errors in your build tool first.
## Reference
- **Groovy Documentation**: [https://groovy-lang.org/documentation.html](https://groovy-lang.org/documentation.html)
- **Gradle Documentation**: [https://docs.gradle.org](https://docs.gradle.org)
- **Serena Configuration**: [../02-usage/050_configuration.md](../02-usage/050_configuration.md)
+1
View File
@@ -273,6 +273,7 @@ markers = [
"go: language server running for Go",
"java: language server running for Java",
"kotlin: language server running for kotlin",
"groovy: language server running for Groovy",
"rust: language server running for Rust",
"typescript: language server running for TypeScript",
"vue: language server running for Vue (uses TypeScript LSP)",
@@ -0,0 +1,274 @@
"""
Provides Groovy specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Groovy.
"""
import dataclasses
import logging
import os
import pathlib
import shlex
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
@dataclasses.dataclass
class GroovyRuntimeDependencyPaths:
"""
Stores the paths to the runtime dependencies of Groovy Language Server
"""
java_path: str
java_home_path: str
ls_jar_path: str
groovy_home_path: str | None = None
class GroovyLanguageServer(SolidLanguageServer):
"""
Provides Groovy specific instantiation of the LanguageServer class.
Contains various configurations and settings specific to Groovy.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a Groovy Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
runtime_dependency_paths = self._setup_runtime_dependencies(solidlsp_settings)
self.runtime_dependency_paths = runtime_dependency_paths
# Get jar options from configuration
ls_jar_options = []
if solidlsp_settings.ls_specific_settings:
groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY)
jar_options_str = groovy_settings.get("ls_jar_options", "")
if jar_options_str:
ls_jar_options = shlex.split(jar_options_str)
log.info(f"Using Groovy LS JAR options from configuration: {jar_options_str}")
# Create command to execute the Groovy Language Server
cmd = [self.runtime_dependency_paths.java_path, "-jar", self.runtime_dependency_paths.ls_jar_path]
cmd.extend(ls_jar_options)
# Set environment variables including JAVA_HOME
proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path}
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path),
"groovy",
solidlsp_settings,
)
log.info(f"Starting Groovy Language Server with jar options: {ls_jar_options}")
@classmethod
def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> GroovyRuntimeDependencyPaths:
"""
Setup runtime dependencies for Groovy Language Server and return paths.
"""
platform_id = PlatformUtils.get_platform_id()
# Verify platform support
assert (
platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-")
), "Only Windows, Linux and macOS platforms are supported for Groovy in multilspy at the moment"
# Check if user specified custom Java home path
java_home_path = None
java_path = None
if solidlsp_settings and solidlsp_settings.ls_specific_settings:
groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY)
custom_java_home = groovy_settings.get("ls_java_home_path")
if custom_java_home:
log.info(f"Using custom Java home path from configuration: {custom_java_home}")
java_home_path = custom_java_home
# Determine java executable path based on platform
if platform_id.value.startswith("win-"):
java_path = os.path.join(java_home_path, "bin", "java.exe")
else:
java_path = os.path.join(java_home_path, "bin", "java")
# If no custom Java home path, download and use bundled Java
if java_home_path is None:
# Runtime dependency information
runtime_dependencies = {
"java": {
"win-x64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-win32-x86_64",
"java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe",
},
"linux-x64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-linux-x86_64",
"java_path": "extension/jre/21.0.7-linux-x86_64/bin/java",
},
"linux-arm64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-linux-aarch64",
"java_path": "extension/jre/21.0.7-linux-aarch64/bin/java",
},
"osx-x64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-macosx-x86_64",
"java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java",
},
"osx-arm64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-macosx-aarch64",
"java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java",
},
},
}
java_dependency = runtime_dependencies["java"][platform_id.value]
static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "groovy_language_server")
os.makedirs(static_dir, exist_ok=True)
java_dir = os.path.join(static_dir, "java")
os.makedirs(java_dir, exist_ok=True)
java_home_path = os.path.join(java_dir, java_dependency["java_home_path"])
java_path = os.path.join(java_dir, java_dependency["java_path"])
if not os.path.exists(java_path):
log.info(f"Downloading Java for {platform_id.value}...")
FileUtils.download_and_extract_archive(java_dependency["url"], java_dir, java_dependency["archiveType"])
if not platform_id.value.startswith("win-"):
os.chmod(java_path, 0o755)
assert java_path and os.path.exists(java_path), f"Java executable not found at {java_path}"
ls_jar_path = cls._find_groovy_ls_jar(solidlsp_settings)
return GroovyRuntimeDependencyPaths(java_path=java_path, java_home_path=java_home_path, ls_jar_path=ls_jar_path)
@classmethod
def _find_groovy_ls_jar(cls, solidlsp_settings: SolidLSPSettings) -> str:
"""
Find Groovy Language Server JAR file
"""
if solidlsp_settings and solidlsp_settings.ls_specific_settings:
groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY)
config_jar_path = groovy_settings.get("ls_jar_path")
if config_jar_path and os.path.exists(config_jar_path):
log.info(f"Using Groovy LS JAR from configuration: {config_jar_path}")
return config_jar_path
# if JAR not found
raise RuntimeError(
"Groovy Language Server JAR not found. To use Groovy language support:\n"
"Set 'ls_jar_path' in groovy settings in serena_config.yml:\n"
" ls_specific_settings:\n"
" groovy:\n"
" ls_jar_path: '/path/to/groovy-language-server.jar'\n"
" Ensure the JAR file is available at the configured path\n"
)
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Groovy Language Server.
"""
if not os.path.isabs(repository_absolute_path):
repository_absolute_path = os.path.abspath(repository_absolute_path)
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"clientInfo": {"name": "Serena Groovy Client", "version": "1.0.0"},
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"textDocument": {
"synchronization": {"dynamicRegistration": True, "didSave": True},
"completion": {"dynamicRegistration": True},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {"dynamicRegistration": True},
"workspaceSymbol": {"dynamicRegistration": True},
"signatureHelp": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True},
},
"workspace": {
"workspaceFolders": True,
},
},
"initializationOptions": {
"settings": {
"groovy": {
"classpath": [],
"diagnostics": {"enabled": True},
"completion": {"enabled": True},
}
},
},
"processId": os.getpid(),
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore
def _start_server(self) -> None:
"""
Starts the Groovy Language Server
"""
def execute_client_command_handler(params: dict) -> list:
return []
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
self.server.on_request("client/registerCapability", do_nothing)
self.server.on_notification("language/status", do_nothing)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("language/actionableNotification", do_nothing)
log.info("Starting Groovy server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
capabilities = init_response["capabilities"]
assert "textDocumentSync" in capabilities, "Server must support textDocumentSync"
assert "hoverProvider" in capabilities, "Server must support hover"
assert "completionProvider" in capabilities, "Server must support code completion"
assert "signatureHelpProvider" in capabilities, "Server must support signature help"
assert "definitionProvider" in capabilities, "Server must support go to definition"
assert "referencesProvider" in capabilities, "Server must support find references"
assert "documentSymbolProvider" in capabilities, "Server must support document symbols"
assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols"
self.server.notify.initialized({})
self.completions_available.set()
+8
View File
@@ -61,6 +61,7 @@ class Language(str, Enum):
JULIA = "julia"
FORTRAN = "fortran"
HASKELL = "haskell"
GROOVY = "groovy"
VUE = "vue"
POWERSHELL = "powershell"
# Experimental or deprecated Language Servers
@@ -112,6 +113,7 @@ class Language(str, Enum):
self.MARKDOWN,
self.YAML,
self.TOML,
self.GROOVY,
}
def __str__(self) -> str:
@@ -221,6 +223,8 @@ class Language(str, Enum):
return FilenameMatcher(*path_patterns)
case self.POWERSHELL:
return FilenameMatcher("*.ps1", "*.psm1", "*.psd1")
case self.GROOVY:
return FilenameMatcher("*.groovy", "*.gvy")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -382,6 +386,10 @@ class Language(str, Enum):
from solidlsp.language_servers.powershell_language_server import PowerShellLanguageServer
return PowerShellLanguageServer
case self.GROOVY:
from solidlsp.language_servers.groovy_language_server import GroovyLanguageServer
return GroovyLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1 @@
.gradle/
@@ -0,0 +1,7 @@
plugins {
id 'groovy'
}
repositories {
mavenCentral()
}
@@ -0,0 +1,9 @@
package com.example
class Main {
static void main(String[] args) {
Utils.printHello()
Model model = new Model("Cascade")
println(model.name)
}
}
@@ -0,0 +1,9 @@
package com.example
class Model {
String name
Model(String name) {
this.name = name
}
}
@@ -0,0 +1,8 @@
package com.example
class ModelUser {
static void main(String[] args) {
Model model = new Model("Cascade")
println(model.name)
}
}
@@ -0,0 +1,7 @@
package com.example
class Utils {
static void printHello() {
println("Hello from Utils!")
}
}
+100
View File
@@ -0,0 +1,100 @@
import os
from pathlib import Path
import pytest
from serena.constants import SERENA_MANAGED_DIR_NAME
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_utils import SymbolUtils
from solidlsp.settings import SolidLSPSettings
@pytest.mark.groovy
class TestGroovyLanguageServer:
@classmethod
def setup_class(cls):
"""
Set up test class with Groovy test repository.
"""
cls.test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo"
if not cls.test_repo_path.exists():
pytest.skip("Groovy test repository not found")
# Use JAR path from environment variable
ls_jar_path = os.environ.get("GROOVY_LS_JAR_PATH")
if not ls_jar_path or not os.path.exists(ls_jar_path):
pytest.skip(
"Groovy Language Server JAR not found. Set GROOVY_LS_JAR_PATH environment variable to run tests.",
allow_module_level=True,
)
# Get JAR options from environment variable
ls_jar_options = os.environ.get("GROOVY_LS_JAR_OPTIONS", "")
ls_java_home_path = os.environ.get("GROOVY_LS_JAVA_HOME_PATH")
groovy_settings = {"ls_jar_path": ls_jar_path, "ls_jar_options": ls_jar_options}
if ls_java_home_path:
groovy_settings["ls_java_home_path"] = ls_java_home_path
# Create language server directly with Groovy-specific settings
repo_path = str(cls.test_repo_path)
config = LanguageServerConfig(code_language=Language.GROOVY, ignored_paths=[], trace_lsp_communication=False)
solidlsp_settings = SolidLSPSettings(
solidlsp_dir=str(Path.home() / ".serena"),
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
ls_specific_settings={Language.GROOVY: groovy_settings},
)
cls.language_server = SolidLanguageServer.create(config, repo_path, solidlsp_settings=solidlsp_settings)
cls.language_server.start()
@classmethod
def teardown_class(cls):
"""
Clean up language server.
"""
if hasattr(cls, "language_server"):
cls.language_server.stop()
def test_find_symbol(self) -> None:
symbols = self.language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser class not found in symbol tree"
def test_find_referencing_class_symbols(self) -> None:
file_path = os.path.join("src", "main", "groovy", "com", "example", "Utils.groovy")
refs = self.language_server.request_references(file_path, 3, 6)
assert any("Main.groovy" in ref.get("relativePath", "") for ref in refs), "Utils should be referenced from Main.groovy"
file_path = os.path.join("src", "main", "groovy", "com", "example", "Model.groovy")
symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
model_symbol = None
for sym in symbols[0]:
if sym.get("name") == "com.example.Model" and sym.get("kind") == 5:
model_symbol = sym
break
assert model_symbol is not None, "Could not find 'Model' class symbol in Model.groovy"
if "selectionRange" in model_symbol:
sel_start = model_symbol["selectionRange"]["start"]
else:
sel_start = model_symbol["range"]["start"]
refs = self.language_server.request_references(file_path, sel_start["line"], sel_start["character"])
main_refs = [ref for ref in refs if "Main.groovy" in ref.get("relativePath", "")]
assert len(main_refs) >= 2, f"Model should be referenced from Main.groovy at least 2 times, found {len(main_refs)}"
model_user_refs = [ref for ref in refs if "ModelUser.groovy" in ref.get("relativePath", "")]
assert len(model_user_refs) >= 1, f"Model should be referenced from ModelUser.groovy at least 1 time, found {len(model_user_refs)}"
def test_overview_methods(self) -> None:
symbols = self.language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview"
assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser missing from overview"