Merge remote-tracking branch 'origin/Main' into memory-cleanup

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-08-08 17:18:05 +02:00
commit 91ebba1c8c
20 files changed
+997 -218

No files matched your search

+5 -1
View File
@@ -706,7 +706,11 @@ jobs:
id: cache-language-servers
uses: actions/cache@v3
with:
path: ~/.serena/language_servers/static
# JDTLS workspaces contain mutable Eclipse/Buildship runtime state.
# Cache the installed servers, but create each JDTLS workspace locally.
path: |
~/.serena/language_servers/static
!~/.serena/language_servers/static/EclipseJDTLS/workspaces/**
key: language-servers-${{ runner.os }}-${{ matrix.batch }}-v1
restore-keys: |
language-servers-${{ runner.os }}-${{ matrix.batch }}-
+14 -1
View File
@@ -3,7 +3,7 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* General:
- Fix: the README, the Language Support docs page and the project template omitted several already-supported language servers
- Fix: Race conditions in ProjectServer when used by multiple clients in parallel
- Fix: a tool call exceeding the timeout blocked the task executor indefinitely; the executor now
recovers without user-induced cancellation
- Add Grok Build support (context `grok`, setup CLI, hooks)
@@ -15,6 +15,8 @@ Status of the `main` branch. Changes prior to the next official version change w
failed `--project-from-cwd` auto-detection (#1773).
- ProjectServer: Configure trusted hosts (local hosts only) when listening on localhost
- SerenaDashboardTrayManager: Configure trusted hosts (local hosts only)
- Enclose sub-prompts in XML-like tags to make scopes explicit
- Allow initial project prompts and project-specific newly activated modes to use templating
* CLI:
- Fix: `start-mcp-server` help text for `--project-from-cwd` falsely promised a fallback to the CWD, which was
@@ -39,6 +41,10 @@ Status of the `main` branch. Changes prior to the next official version change w
- Allow language server priorities to be configured in `serena_config.yml` (for auto-detection during
project creation)
- Add `python_basedpyright` as an alternative Python language server
- Java/JDTLS: stop downloading and loading the unused IntelliCode completion-ranking bundle; the retired
`intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821
- Kotlin: update the managed Kotlin LSP from `261.13587.0` to `262.9593.0`, including support for the
new platform-specific archive layout and Windows ARM64 builds
- Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737
- Fix: Nix/nixd diagnostics now use published diagnostics instead of the unsupported
`textDocument/diagnostic` request, which terminated nixd #1802
@@ -83,6 +89,13 @@ Status of the `main` branch. Changes prior to the next official version change w
- Language servers and their dependency providers now go through the `subprocess_run` helper instead of
calling `subprocess.run` directly (e.g. for installation processes), so all such subprocesses get
`stdin=DEVNULL` and can no longer interfere with the stdio MCP connection #1748
- `scala`: Fix: Metals asks via `window/showMessageRequest` whether to import a workspace it has not
seen before, and Serena had no handler, so the request failed with `MethodNotFound` and Metals gave
up on the import ("Unexpected error initializing server"). No build server was ever connected and
every cross-file query fell back to the presentation compiler, which sees one file at a time, unless
the project happened to have been imported beforehand by another editor. The three prompts that lead
to a build server are now answered; anything else is dismissed, including the choice between several
build definitions in one workspace. `ls_specific_settings.scala.auto_import_build: false` opts out
* Dashboard:
- Fix: Serena PyPI version check triggered by callback on main thread could delay agent startup #1774
+1 -1
View File
@@ -147,7 +147,7 @@ Some languages require additional installations or setup steps, as noted.
* **Rust**
(requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
* **Scala**
(requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP)
(uses Metals LSP, which imports the build on first use — see the [setup guide](../03-special-guides/scala_setup_guide_for_serena))
* **SCSS / Sass / CSS**
(experimental; requires Node.js + npm; uses [some-sass-language-server](https://github.com/wkillerud/some-sass) to handle
`.scss`, `.sass`, and `.css`)
+15 -16
View File
@@ -666,8 +666,7 @@ Supported settings:
Java support has two installation modes:
1. **Default vscode-java VSIX mode** (no extra config required): Serena downloads the platform-specific
vscode-java VSIX (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution and
IntelliCode VSIX from public hosts on first use.
vscode-java VSIX (JDTLS + bundled JRE 21 + Lombok) and a Gradle distribution from public hosts on first use.
2. **Upstream JDTLS mode** (offline-friendly): Activated by setting both `jdtls_path` and `lombok_path`.
Uses an existing JDTLS installation (~100 MB) and the system JDK 21+. Nothing is downloaded.
Recommended for restricted-network/corporate environments.
@@ -677,9 +676,8 @@ Java support has two installation modes:
- **Default vscode-java VSIX mode** — recommended for most users. No setup required;
Serena downloads everything on first use.
- **Upstream JDTLS mode** — recommended when:
- you cannot reach `github.com`, `services.gradle.org` or `marketplace.visualstudio.com`
from the host (corporate proxy, air-gapped network);
- you want a smaller on-disk footprint (~100 MB vs ~500 MB);
- you cannot reach `github.com` or `services.gradle.org` from the host (corporate proxy, air-gapped network);
- you want a smaller on-disk footprint;
- you already maintain a JDTLS installation (e.g. for `nvim-jdtls` or another editor);
- your security policy prohibits per-project runtime downloads.
@@ -702,23 +700,19 @@ The following settings are supported for the Java language server:
| `runtimes` | `[]` | Extra JRE/JDK entries registered with JDT-LS via `java.configuration.runtimes`. Use this when a project's source/target level exceeds the JDK JDT-LS itself runs on (currently JDK 21 in default vscode-java VSIX mode). Each entry is a mapping with required `name` (e.g. `JavaSE-25`, matching the `JavaSE-NN` container the build tool requests) and `path` (JDK/JRE home directory; must exist), plus optional `default`, `sources`, and `javadoc` (passed through to JDT-LS). Entries extend rather than replace the bundled `JavaSE-21` runtime; an entry that reuses the `JavaSE-21` name overrides the bundled one. Changing this setting invalidates the JDTLS workspace hash so a fresh import is performed. |
| `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. |
| `vscode_java_version` | `1.54.0-923` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. |
| `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. |
| `lombok_show_generated` | `true` | Show Lombok-generated methods (`getX/setX`, `builder()`, `equals/hashCode/toString`, `withX`, fluent accessors) in `find_symbol`, `get_symbols_overview` and the symbol-edit tools. Set to `false` to restore the previous JDTLS default and hide the synthetic methods (e.g. when `@Data` classes pollute the outline with too many getters/setters). Requires JDTLS commit `b2d8952` / `vscode-java >= 1.53.0`; the bundled default already meets this. |
| `jdtls_xmx` | `3G` | Maximum heap size for the JDTLS server JVM. |
| `jdtls_xms` | `100m` | Initial heap size for the JDTLS server JVM. |
| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. |
| `intellicode_xms` | `100m` | (vscode-java mode only) Initial heap size for the IntelliCode embedded JVM. |
Notes:
- When overriding `vscode_java_version`, Serena still assumes that the downloaded runtime bundle keeps the same internal
directory layout and file names as the bundled default version.
- In upstream-jdtls mode, IntelliCode is not loaded (it's an ML completions ranker that is irrelevant to Serena's
symbol-tools workflow), and Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e.
- Serena does not download or load IntelliCode because its completion ranking is not used by Serena's tools. The retired
`intellicode_version`, `intellicode_xmx` and `intellicode_xms` keys remain accepted and ignored so existing
configurations continue to load; they can be removed.
- In upstream-jdtls mode Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e.
Gradle projects must have `./gradlew` in the project, or rely on a system-installed Gradle through Buildship's
default discovery rules.
- In upstream-jdtls mode the `gradle_version`, `vscode_java_version`, `intellicode_version`,
`intellicode_xmx`, `intellicode_xms` settings are silently ignored — they only apply to the
vscode-java VSIX mode.
default discovery rules. The `gradle_version` and `vscode_java_version` settings are silently ignored in this mode.
- Without `runtimes`, JDT-LS only knows about the bundled `JavaSE-21` JRE. Projects that request a newer
container (e.g. `sourceCompatibility = JavaVersion.VERSION_25`) then fail to resolve JDK types such as
`java.lang.Object`. Register the matching installed JDK via `runtimes` instead of symlinking over Serena's
@@ -765,15 +759,20 @@ Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | managed download | Override the Kotlin Language Server executable path. |
| `kotlin_lsp_version` | `261.13587.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. |
| `kotlin_lsp_version` | `262.9593.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. |
| `jvm_options` | `-Xmx2G` | Value assigned to `JAVA_TOOL_OPTIONS` for the Kotlin LS process. Set to `""` to disable JVM options entirely. |
The managed `262.9593.0` packages include a bundled JBR. For a custom `ls_path`, point directly to
`bin/intellij-server` (`bin/intellij-server.exe` on Windows). Serena also retains the legacy download
layout for custom Kotlin LSP versions older than `262.4739.0`. The pinned current and frozen initial
releases are checksum-verified; arbitrary custom versions are downloaded without checksum verification.
Example:
```yaml
ls_specific_settings:
kotlin:
kotlin_lsp_version: "261.13587.0"
kotlin_lsp_version: "262.9593.0"
jvm_options: "-Xmx4G -XX:+UseG1GC"
```
@@ -15,7 +15,16 @@ Install the following on your system and ensure they are available on `PATH`:
- Serena uses `cs` if available; if only `coursier` exists, it will attempt to install `cs`. If neither is present, install Coursier first.
---
## Quick Start (Recommended: VS Code + Metals auto‑import)
## Quick Start
Start Serena in your project root. Metals asks whether to import a workspace it has not seen before, and Serena answers that prompt for it — the build is imported (for sbt, by running `sbt bloopInstall`), `.bloop/` and `.metals/` are created, and cross-file navigation works from there. The first run therefore takes as long as your build takes to load.
Set `auto_import_build: false` under `ls_specific_settings.scala` to decline instead; you then need to import the build yourself by one of the routes below, or cross-file queries will be served by the fallback presentation compiler and see only one file at a time.
Serena answers three of Metals' prompts — “Import build”, “Import changes”, “Connect”. Anything else Metals asks is dismissed and logged, including “Multiple build definitions found. Which would you like to use?”, so a workspace holding more than one kind of build (say both an sbt and a Maven definition) still needs importing by one of the routes below.
---
## Importing the build yourself (VS Code)
1. Open your Scala project in VS Code.
2. When prompted by Metals, accept “Import build”. Wait until the import and initial compile/indexing finish.
@@ -25,7 +34,7 @@ Install the following on your system and ensure they are available on `PATH`:
This flow ensures the `.bloop/` and (if applicable) `.metals/` directories are created and your build is known to the build server that Metals uses.
---
## Manual Setup (No VS Code)
## Importing the build yourself (No VS Code)
Follow these steps if you prefer a manual setup or you are not using VS Code:
@@ -1,7 +1,9 @@
from sensai.util import logging
from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS
from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer
if __name__ == "__main__":
logging.configure()
EclipseJDTLS.DependencyProvider.update_dep_hashes()
KotlinLanguageServer.DependencyProvider.update_dep_hashes()
+26 -17
View File
@@ -956,14 +956,24 @@ class SerenaAgent:
result[tool_class.get_name_from_cls()] = new_tool_class.get_name_from_cls()
return result
def _format_prompt(self, prompt_template: str) -> str:
def _format_prompt_tag(self, text: str, tag: str, tag_name_attr: str | None = None) -> str:
open_tag = f"<{tag}" + (f' name="{tag_name_attr}"' if tag_name_attr is not None else "") + ">"
close_tag = f"</{tag}>"
return f"{open_tag}\n{text.strip()}\n{close_tag}"
def _format_prompt(self, prompt_template: str, tag: str | None = None, tag_name_attr: str | None = None) -> str:
template = JinjaTemplate(prompt_template)
return template.render(
text = template.render(
available_tools=self._exposed_tools.tool_names,
available_markers=self._exposed_tools.tool_marker_names,
tool_names=self._prompt_tool_names_mapping,
)
if tag is not None:
text = self._format_prompt_tag(text, tag=tag, tag_name_attr=tag_name_attr)
return text
def create_connection_prompt(self) -> str:
"""
Returns the bootstrap prompt to be sent at MCP connection time.
@@ -997,8 +1007,8 @@ class SerenaAgent:
self._project_prompt_status.mark_mode_prompts_as_provided(session_id)
system_prompt = self.prompt_factory.create_system_prompt(
context_system_prompt=self._format_prompt(self._context.prompt),
mode_system_prompts=[self._format_prompt(mode.prompt) for mode in relevant_modes],
context_system_prompt=self._format_prompt(self._context.prompt, tag="context"),
mode_system_prompts=[self._format_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) for mode in relevant_modes],
available_tools=available_tools.tool_names,
available_markers=available_markers,
global_memories_list=global_memories_str,
@@ -1007,11 +1017,11 @@ class SerenaAgent:
# provide the project activation message if it hasn't yet been provided
if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id):
system_prompt += "\n\n" + self.get_project_activation_message(session_id)
system_prompt += "\n\n" + self._format_prompt_tag(self.get_project_activation_message(session_id), tag="active-project")
elif self._project_activation_error:
system_prompt += f"\n\nNo project is active ({self._project_activation_error})."
return system_prompt
return self._format_prompt_tag(system_prompt, tag="serena")
def get_project_activation_message(self, session_id: str) -> str:
"""
@@ -1031,13 +1041,13 @@ class SerenaAgent:
# provide basic project information (name, location, languages, encoding)
if proj.is_newly_created:
msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}. "
msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}.\n"
else:
msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated."
msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated.\n"
if self._language_backend == LanguageBackend.LSP:
languages_str = ", ".join([lang.value for lang in proj.project_config.language_servers])
msg += f"\nProgramming languages: {languages_str}."
msg += f"File encoding: {proj.project_config.encoding}."
language_servers_str = ", ".join([ls.value for ls in proj.project_config.language_servers])
msg += f"Active language servers: {language_servers_str}.\n"
msg += f"File encoding: {proj.project_config.encoding}.\n"
# add list of memories (if memories are enabled)
include_memories = self._active_tools.contains_tool_class(ReadMemoryTool)
@@ -1045,23 +1055,22 @@ class SerenaAgent:
project_memories = proj.memory_manager.list_project_memories()
if project_memories:
msg += (
f"\n{json.dumps(project_memories.to_dict())}\n"
+ "Use the `read_memory` tool to read these memories later if they are relevant to the task."
f"{json.dumps(project_memories.to_dict())}\n"
+ f"Use the `{ReadMemoryTool.get_name_from_cls()}` tool to read these memories later if they are relevant to the task.\n"
)
elif self._active_tools.contains_tool_class(OnboardingTool):
msg += "Onboarding has not been performed yet, you should call Serena's `onboarding` tool now to set up project memories."
msg += f"Onboarding has not been performed yet. Ask the user whether to perform onboarding via the `{OnboardingTool.get_name_from_cls()}` tool.\n"
# add prompts for modes that were dynamically activated by the project
modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id)
if modes_with_prompts:
msg += "\nNewly applicable mode instructions:"
for mode in modes_with_prompts:
msg += f"\n{mode.prompt}"
msg += self._format_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) + "\n"
self._project_prompt_status.mark_mode_prompts_as_provided(session_id)
# add project-specific prompt
if proj.project_config.initial_prompt:
msg += f"\nProject-specific instructions:\n {proj.project_config.initial_prompt}"
msg += "\n" + self._format_prompt(proj.project_config.initial_prompt, tag="project-instructions")
self._project_prompt_status.mark_project_activation_message_as_provided(session_id)
+36 -12
View File
@@ -1,5 +1,6 @@
import json
import logging
import threading
from typing import TYPE_CHECKING
import requests as requests_lib
@@ -54,13 +55,14 @@ class ProjectServer:
if port is None:
port = self.PORT
serena_config = SerenaConfig.from_config_file()
serena_config.gui_log_window = False
serena_config.web_dashboard = False
serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides()
serena_config.language_backend = LanguageBackend.LSP
self._agent = SerenaAgent(serena_config=serena_config)
self._loaded_projects_by_root: dict[str, "Project"] = {}
self._project_load_locks_by_root: dict[str, threading.Lock] = {}
self._active_project_lock = threading.Lock()
self._loaded_projects_lock = threading.Lock()
self._port = port
self._host = host
@@ -91,19 +93,41 @@ class ProjectServer:
key = str(registered_project.project_root)
if key in self._loaded_projects_by_root:
return self._loaded_projects_by_root[key]
# find or publish the per-project load lock while holding the shared dictionaries
with self._loaded_projects_lock:
project = self._loaded_projects_by_root.get(key)
if project is not None:
return project
project_load_lock = self._project_load_locks_by_root.get(key)
if project_load_lock is None:
project_load_lock = threading.Lock()
self._project_load_locks_by_root[key] = project_load_lock
with LogTime(f"Loading project '{project_root_or_name}'"):
project = registered_project.get_project_instance(serena_config)
project.create_language_server_manager()
self._loaded_projects_by_root[key] = project
return project
# initialize only this project; another project's cached lookup or cold load can proceed
with project_load_lock:
with self._loaded_projects_lock:
project = self._loaded_projects_by_root.get(key)
if project is not None:
return project
with LogTime(f"Loading project '{project_root_or_name}'"):
project = registered_project.get_project_instance(serena_config)
project.create_language_server_manager()
with self._loaded_projects_lock:
self._loaded_projects_by_root[key] = project
return project
def _query_project(self, req: QueryProjectRequest) -> str:
"""Handle a /query_project request by invoking the agent on the specified project and tool."""
"""Handle a /query_project request by invoking the agent on the specified project and tool.
The active project is process-wide state, whereas ``apply_ex`` runs the tool on the
agent's task executor thread. Without the lock, a second request entering
``active_project_context`` while the first request's tool is still executing would
redirect that tool to the wrong project (and restore the wrong project afterwards).
"""
project = self._get_project(req.project_name)
with self._agent.active_project_context(project):
with self._active_project_lock, self._agent.active_project_context(project):
tool = self._agent.get_tool_by_name(req.tool_name)
if not tool.is_readonly():
raise ValueError(f"Tool '{req.tool_name}' is not read-only and cannot be executed via the query_project route")
@@ -49,10 +49,8 @@ prompts:
The context and modes of operation are described below. These determine how to interact with your user
and which kinds of interactions are expected of you.
Context description:
{{ context_system_prompt }}
Modes descriptions:
{% for prompt in mode_system_prompts %}
{{ prompt }}
{% endfor %}
+10 -106
View File
@@ -35,11 +35,6 @@ log = logging.getLogger(__name__)
GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
INTELLICODE_ALLOWED_HOSTS = (
"visualstudioexptteam.gallery.vsassets.io",
"marketplace.visualstudio.com",
"download.visualstudio.microsoft.com",
)
@dataclasses.dataclass(frozen=True)
@@ -77,10 +72,9 @@ class RuntimeDependencyPaths:
"""
Stores the paths to the runtime dependencies of EclipseJDTLS.
In the default mode (vscode-java VSIX), all paths are populated.
In the upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set),
fields that have no upstream equivalent (gradle distribution, IntelliCode bundle)
are set to None.
In the default mode (vscode-java VSIX), all paths are populated. In the
upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set), the
Gradle distribution path is ``None`` because Serena does not download it.
"""
jre_path: str
@@ -89,8 +83,6 @@ class RuntimeDependencyPaths:
jdtls_readonly_config_path: str
lombok_jar_path: str
gradle_path: str | None = None
intellicode_jar_path: str | None = None
intellisense_members_path: str | None = None
@dataclass
@@ -113,8 +105,8 @@ class EclipseJDTLS(SolidLanguageServer):
Two installation modes are supported:
1. **Default vscode-java VSIX mode** (no extra config required) — Serena downloads the platform-specific
vscode-java VSIX bundle (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution
and IntelliCode VSIX from public hosts. Suitable when public network access is available.
vscode-java VSIX bundle (JDTLS + bundled JRE 21 + Lombok) and Gradle distribution from public hosts.
Suitable when public network access is available.
2. **Upstream JDTLS mode** (activated by setting both ``jdtls_path`` and ``lombok_path``) — uses an
existing JDTLS installation and the system JDK. Nothing is downloaded. Suitable for restricted-network
@@ -143,8 +135,6 @@ class EclipseJDTLS(SolidLanguageServer):
gradle_java_home is unset, Gradle import (default: false)
- jdtls_xmx: Maximum heap size for the JDTLS server JVM (default: "3G")
- jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m")
- intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G")
- intellicode_xms: Initial heap size for the IntelliCode embedded JVM (default: "100m")
- lombok_show_generated: Show Lombok-generated methods (getX/setX/builder()/...) in document
symbols by sending java.symbols.includeGeneratedCode=true to JDTLS (default: true).
Set to false for @Data-heavy projects where the extra getters/setters are noise.
@@ -155,7 +145,6 @@ class EclipseJDTLS(SolidLanguageServer):
Pinned versions: "1.54.0-923" (default) and "1.42.0-561" (legacy / initial). Other versions
are not supported in default VSIX mode (the resource paths inside the archive change between
releases); use upstream-jdtls mode for arbitrary versions.
- intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena
- runtimes: Additional JRE/JDK entries to register with JDT-LS's ``java.configuration.runtimes``,
for projects whose source/target level exceeds the JDK JDT-LS itself runs on (currently
JDK 21 in default vscode-java VSIX mode). Each entry is a mapping with:
@@ -190,12 +179,9 @@ class EclipseJDTLS(SolidLanguageServer):
use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS and Gradle fallback
jdtls_xmx: "3G" # maximum heap size for the JDTLS server JVM
jdtls_xms: "100m" # initial heap size for the JDTLS server JVM
intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM
intellicode_xms: "100m" # initial heap size for the IntelliCode embedded JVM
lombok_show_generated: true # show Lombok-generated methods in document symbols (default true)
gradle_version: "8.14.2"
vscode_java_version: "1.54.0-923" # also accepts pinned legacy "1.42.0-561"
intellicode_version: "1.2.30"
runtimes: # register additional JDKs for projects targeting a newer Java version
- name: "JavaSE-25"
path: "/home/user/Java/jdk25"
@@ -220,7 +206,6 @@ class EclipseJDTLS(SolidLanguageServer):
self._service_ready_event = threading.Event()
self._project_ready_event = threading.Event()
self._intellicode_enable_command_available = threading.Event()
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings)
@@ -258,7 +243,6 @@ class EclipseJDTLS(SolidLanguageServer):
# versions used initially (which use default paths without version suffix)
INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561"
INITIAL_INTELLICODE_VERSION = "1.2.30"
INITIAL_VSCODE_JAVA_PATHS = VsixResourcePaths(
jre_version="21.0.7",
lombok_jar_basename="lombok-1.18.36.jar",
@@ -270,7 +254,6 @@ class EclipseJDTLS(SolidLanguageServer):
# scripts/update_downloaded_dependency_hashes.py and commit the resulting changes.
DEFAULT_GRADLE_VERSION = "8.14.2"
DEFAULT_VSCODE_JAVA_VERSION = "1.54.0-923"
DEFAULT_INTELLICODE_VERSION = "1.2.30"
DEFAULT_VSCODE_JAVA_PATHS = VsixResourcePaths(
jre_version="21.0.10",
lombok_jar_basename="lombok-1.18.39-4050.jar",
@@ -320,19 +303,9 @@ class EclipseJDTLS(SolidLanguageServer):
)
return deps
@classmethod
def _create_dep_intellicode(cls, intellicode_version: str | None = None) -> DownloadedDependency:
intellicode_version = intellicode_version or cls.DEFAULT_INTELLICODE_VERSION
return DownloadedDependency(
url=f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage",
archive_type="zip",
allowed_hosts=INTELLICODE_ALLOWED_HOSTS,
verified=intellicode_version == cls.DEFAULT_INTELLICODE_VERSION,
)
@classmethod
def update_dep_hashes(cls) -> None:
deps = [cls._create_dep_gradle(), cls._create_dep_intellicode()] + list(cls._create_deps_vscode_java().values())
deps = [cls._create_dep_gradle()] + list(cls._create_deps_vscode_java().values())
with DownloadedDependencyHashDatabase.get_instance().update_context() as db:
for dep in deps:
db.update(dep)
@@ -351,8 +324,8 @@ class EclipseJDTLS(SolidLanguageServer):
``brew install jdtls`` or extracted ``jdt-language-server-*.tar.gz``) and the system JDK.
Nothing is downloaded by Serena. Suitable for restricted-network/corporate environments.
* **Default vscode-java VSIX mode** (activated otherwise): downloads the platform-specific
vscode-java VSIX bundle (containing JDTLS, bundled JRE 21, Lombok, IntelliCode), Gradle
distribution and IntelliCode VSIX from public hosts. Original behaviour, unchanged.
vscode-java VSIX bundle (containing JDTLS, bundled JRE 21 and Lombok) and a Gradle
distribution from public hosts.
"""
jdtls_path = custom_settings.get("jdtls_path")
lombok_path = custom_settings.get("lombok_path")
@@ -369,16 +342,12 @@ class EclipseJDTLS(SolidLanguageServer):
platformId = PlatformUtils.get_platform_id()
gradle_version = custom_settings.get("gradle_version", cls.DEFAULT_GRADLE_VERSION)
vscode_java_version = custom_settings.get("vscode_java_version", cls.DEFAULT_VSCODE_JAVA_VERSION)
intellicode_version = custom_settings.get("intellicode_version", cls.DEFAULT_INTELLICODE_VERSION)
# install-dir name per the version-pinning convention (see module-level block):
# INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir
vscode_java_dirname = (
"vscode-java" if vscode_java_version == cls.INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}"
)
intellicode_dirname = (
"intellicode" if intellicode_version == cls.INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}"
)
# Resolve internal VSIX paths (JRE / Lombok / launcher filenames). For pinned versions
# these are known; for any other user-supplied version we bail out — guessing would
@@ -478,26 +447,6 @@ class EclipseJDTLS(SolidLanguageServer):
assert os.path.exists(jdtls_launcher_jar_path)
assert os.path.exists(jdtls_readonly_config_path)
intellicode_dep = cls._create_dep_intellicode(intellicode_version)
intellicode_directory_path = str(PurePath(ls_resources_dir, intellicode_dirname))
os.makedirs(intellicode_directory_path, exist_ok=True)
intellicode_jar_path = str(
PurePath(intellicode_directory_path, "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar")
)
intellisense_members_path = str(PurePath(intellicode_directory_path, "extension/dist/bundledModels/java_intellisense-members"))
if not all(
[
os.path.exists(intellicode_directory_path),
os.path.exists(intellicode_jar_path),
os.path.exists(intellisense_members_path),
]
):
intellicode_dep.download_to(intellicode_directory_path)
assert os.path.exists(intellicode_directory_path)
assert os.path.exists(intellicode_jar_path)
assert os.path.exists(intellisense_members_path)
return RuntimeDependencyPaths(
gradle_path=gradle_path,
lombok_jar_path=lombok_jar_path,
@@ -505,8 +454,6 @@ class EclipseJDTLS(SolidLanguageServer):
jre_home_path=jre_home_path,
jdtls_launcher_jar_path=jdtls_launcher_jar_path,
jdtls_readonly_config_path=jdtls_readonly_config_path,
intellicode_jar_path=intellicode_jar_path,
intellisense_members_path=intellisense_members_path,
)
@staticmethod
@@ -520,7 +467,7 @@ class EclipseJDTLS(SolidLanguageServer):
:param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_<platform>/``)
:param lombok_path: absolute path to the Lombok jar (mandatory; agent is always attached)
:param custom_settings: language-server-specific settings from ls_specific_settings.java
:return: populated RuntimeDependencyPaths with gradle/intellicode fields set to None
:return: populated RuntimeDependencyPaths with ``gradle_path`` set to ``None``
"""
# validate jdtls_path structure (root + plugins dir)
jdtls_root = Path(jdtls_path)
@@ -569,8 +516,6 @@ class EclipseJDTLS(SolidLanguageServer):
jdtls_readonly_config_path=str(config_dir),
lombok_jar_path=lombok_path,
gradle_path=None,
intellicode_jar_path=None,
intellisense_members_path=None,
)
@staticmethod
@@ -1006,10 +951,6 @@ class EclipseJDTLS(SolidLanguageServer):
gradle_user_home = None
log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults")
# IntelliCode JVM settings (used in vmargs for the embedded JVM)
intellicode_xmx = self._custom_settings.get("intellicode_xmx", "1G")
intellicode_xms = self._custom_settings.get("intellicode_xms", "100m")
# Lombok-generated symbols (getX/setX/builder()/equals/hashCode/toString/...): JDTLS filters
# these out of documentSymbol results by default. Without them, find_symbol/get_symbols_overview
# return only user-written sources, which breaks navigation around @Data/@Builder/@Getter/@Setter
@@ -1194,19 +1135,10 @@ class EclipseJDTLS(SolidLanguageServer):
"notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}},
},
"initializationOptions": {
"bundles": ["intellicode-core.jar"],
"bundles": [],
"settings": {
"java": {
"home": None,
"jdt": {
"ls": {
"java": {"home": None},
"vmargs": f"-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx{intellicode_xmx} -Xms{intellicode_xms} -Xlog:disable",
"lombokSupport": {"enabled": True},
"protobufSupport": {"enabled": True},
"androidSupport": {"enabled": True},
}
},
"errors": {"incompleteClasspath": {"severity": "error"}},
"configuration": {
"checkProjectSettingsExclusions": False,
@@ -1326,14 +1258,6 @@ class EclipseJDTLS(SolidLanguageServer):
initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri]
# IntelliCode bundle: only attached in default vscode-java VSIX mode.
# In upstream-jdtls mode (jdtls_path set) we don't ship IntelliCode — agentic Serena workflows
# don't use completion ranking, so the bundle would be inert dead weight.
if self.runtime_dependency_paths.intellicode_jar_path is not None:
initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path]
else:
initialize_params["initializationOptions"]["bundles"] = []
# merge the bundled JRE with any additional runtimes configured via ls_specific_settings.java.runtimes
# (e.g. so projects targeting a newer Java version than the bundled JRE resolve their JRE container)...
default_runtime = {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True}
@@ -1378,9 +1302,6 @@ class EclipseJDTLS(SolidLanguageServer):
"*",
" ",
]
if registration["method"] == "workspace/executeCommand":
if "java.intellicode.enable" in registration["registerOptions"]["commands"]:
self._intellicode_enable_command_available.set()
return
def lang_status_handler(params: dict) -> None:
@@ -1424,23 +1345,6 @@ class EclipseJDTLS(SolidLanguageServer):
self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore
# IntelliCode enablement is only relevant in the default vscode-java VSIX mode where the
# IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the
# 'java.intellicode.enable' command will never be registered, so we skip the wait/call.
if self.runtime_dependency_paths.intellicode_jar_path is not None:
self._intellicode_enable_command_available.wait()
java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path
assert java_intellisense_members_path is not None
assert os.path.exists(java_intellisense_members_path)
intellicode_enable_result = self.server.send.execute_command(
{
"command": "java.intellicode.enable",
"arguments": [True, java_intellisense_members_path],
}
)
assert intellicode_enable_result
if not self._service_ready_event.is_set():
log.info("Waiting for service to be ready ...")
self._service_ready_event.wait()
@@ -5,8 +5,8 @@ You can configure the following options in ls_specific_settings (in serena_confi
ls_specific_settings:
kotlin:
ls_path: '/path/to/kotlin-lsp.sh' # Custom path to Kotlin Language Server executable
kotlin_lsp_version: '261.13587.0' # Kotlin Language Server version (default: current bundled version)
ls_path: '/path/to/bin/intellij-server' # Custom path to Kotlin Language Server executable
kotlin_lsp_version: '262.9593.0' # Kotlin Language Server version (default: current bundled version)
jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G)
Example configuration for large projects:
@@ -21,16 +21,18 @@ import os
import pathlib
import stat
import threading
from dataclasses import dataclass
from overrides import override
from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase
from solidlsp.ls import (
LanguageServerDependencyProvider,
LanguageServerDependencyProviderSinglePath,
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
@@ -44,34 +46,21 @@ KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
# NOTE: After changing either pinned version, run scripts/update_downloaded_dependency_hashes.py.
INITIAL_KOTLIN_LSP_VERSION = "261.13587.0"
INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = {
"win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad",
"linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec",
"linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651",
"mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3",
"mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c",
}
DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0"
DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = {
"win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad",
"linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec",
"linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651",
"mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3",
"mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c",
}
DEFAULT_KOTLIN_LSP_VERSION = "262.9593.0"
# Versions before this one use kotlin-lsp-{version}-{platform}.zip and a kotlin-lsp script.
# Starting with 262.4739.0, JetBrains publishes kotlin-server archives with platform-specific
# formats, a bundled JBR, and bin/intellij-server as the launcher.
KOTLIN_SERVER_PACKAGING_MIN_VERSION = (262, 4739, 0)
def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None:
if version == INITIAL_KOTLIN_LSP_VERSION:
return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix)
if version == DEFAULT_KOTLIN_LSP_VERSION:
return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix)
return None
# The first modern archives remained under the legacy /kotlin-lsp CDN path.
# Starting with 262.8190.0, JetBrains moved them to /language-server/kotlin-server.
KOTLIN_SERVER_CDN_PATH_MIN_VERSION = (262, 8190, 0)
# Platform-specific Kotlin LSP download suffixes
PLATFORM_KOTLIN_SUFFIX = {
# Platform-specific suffixes used by legacy Kotlin LSP ZIP archives.
LEGACY_PLATFORM_KOTLIN_SUFFIX = {
"win-x64": "win-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-aarch64",
@@ -79,6 +68,22 @@ PLATFORM_KOTLIN_SUFFIX = {
"osx-arm64": "mac-aarch64",
}
# Modern archive filename suffix, FileUtils archive type, and launcher path within the archive.
KOTLIN_SERVER_ARTIFACT_BY_PLATFORM: dict[str, tuple[str, FileUtils.ArchiveType, tuple[str, ...]]] = {
"win-x64": (".win.zip", "zip", ("bin", "intellij-server.exe")),
"win-arm64": ("-aarch64.win.zip", "zip", ("bin", "intellij-server.exe")),
"linux-x64": (".tar.gz", "gztar", ("kotlin-server-{version}", "bin", "intellij-server")),
"linux-arm64": ("-aarch64.tar.gz", "gztar", ("kotlin-server-{version}", "bin", "intellij-server")),
"osx-x64": (".sit", "zip", ("kotlin-server-{version}", "bin", "intellij-server")),
"osx-arm64": ("-aarch64.sit", "zip", ("kotlin-server-{version}", "bin", "intellij-server")),
}
@dataclass(frozen=True)
class KotlinLSPArtifact:
dependency: DownloadedDependency
launcher_parts: tuple[str, ...]
class KotlinLanguageServer(SolidLanguageServer):
"""
@@ -102,15 +107,78 @@ class KotlinLanguageServer(SolidLanguageServer):
# set again once all progress tokens have ended.
self._indexing_complete = threading.Event()
self._indexing_complete.set()
self._intellij_server_ready = threading.Event()
self._active_progress_tokens: set[str] = set()
self._progress_lock = threading.Lock()
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, str(self.cache_dir))
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str):
def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, project_cache_dir: str):
super().__init__(custom_settings, ls_resources_dir)
self._project_cache_dir = project_cache_dir
@classmethod
def _create_artifact(cls, version: str, platform_id: PlatformId) -> KotlinLSPArtifact:
"""Build download and launcher metadata for one Kotlin LSP release.
JetBrains has three publishing layouts: legacy ZIPs before 262.4739.0,
modern platform archives under ``/kotlin-lsp`` through 262.7569.0,
and modern archives under ``/language-server/kotlin-server`` from
262.8190.0 onward. Serena verifies the frozen initial and current default
releases; arbitrary user-selected versions are unverified by design.
"""
try:
version_parts = tuple(int(part) for part in version.split("."))
except ValueError as exc:
raise ValueError(f"Kotlin LSP version must contain only dot-separated integers: {version!r}") from exc
verified = version in {INITIAL_KOTLIN_LSP_VERSION, DEFAULT_KOTLIN_LSP_VERSION}
if version_parts >= KOTLIN_SERVER_PACKAGING_MIN_VERSION:
artifact_config = KOTLIN_SERVER_ARTIFACT_BY_PLATFORM.get(platform_id.value)
if artifact_config is None:
raise ValueError(f"Unsupported platform for Kotlin LSP {version}: {platform_id.value}")
asset_suffix, archive_type, launcher_parts = artifact_config
asset_name = f"kotlin-server-{version}{asset_suffix}"
cdn_path = "language-server/kotlin-server" if version_parts >= KOTLIN_SERVER_CDN_PATH_MIN_VERSION else "kotlin-lsp"
return KotlinLSPArtifact(
dependency=DownloadedDependency(
url=f"https://download-cdn.jetbrains.com/{cdn_path}/{version}/{asset_name}",
archive_type=archive_type,
allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS,
verified=verified,
),
launcher_parts=tuple(part.format(version=version) for part in launcher_parts),
)
kotlin_suffix = LEGACY_PLATFORM_KOTLIN_SUFFIX.get(platform_id.value)
if kotlin_suffix is None:
raise ValueError(f"Unsupported platform for Kotlin LSP {version}: {platform_id.value}")
return KotlinLSPArtifact(
dependency=DownloadedDependency(
url=f"https://download-cdn.jetbrains.com/kotlin-lsp/{version}/kotlin-lsp-{version}-{kotlin_suffix}.zip",
archive_type="zip",
allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS,
verified=verified,
),
launcher_parts=("kotlin-lsp.cmd",) if platform_id.is_windows() else ("kotlin-lsp.sh",),
)
@classmethod
def update_dep_hashes(cls) -> None:
pinned_artifacts = [
*(cls._create_artifact(INITIAL_KOTLIN_LSP_VERSION, PlatformId(platform)) for platform in LEGACY_PLATFORM_KOTLIN_SUFFIX),
*(
cls._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, PlatformId(platform))
for platform in KOTLIN_SERVER_ARTIFACT_BY_PLATFORM
),
]
with DownloadedDependencyHashDatabase.get_instance().update_context() as database:
for artifact in pinned_artifacts:
database.update(artifact.dependency)
def _get_or_install_core_dependency(self) -> str:
"""
@@ -118,16 +186,9 @@ class KotlinLanguageServer(SolidLanguageServer):
"""
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 Kotlin in multilspy at the moment"
)
kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value)
assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}"
# Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only
kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION)
artifact = self._create_artifact(kotlin_lsp_version, platform_id)
ls_dirname = (
"kotlin_language_server"
if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION
@@ -137,22 +198,13 @@ class KotlinLanguageServer(SolidLanguageServer):
os.makedirs(static_dir, exist_ok=True)
# Setup Kotlin Language Server
kotlin_script_name = "kotlin-lsp.cmd" if platform_id.value.startswith("win-") else "kotlin-lsp.sh"
kotlin_script = os.path.join(static_dir, kotlin_script_name)
kotlin_script = os.path.join(static_dir, *artifact.launcher_parts)
if not os.path.exists(kotlin_script):
kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip"
expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix)
log.info("Downloading Kotlin Language Server...")
FileUtils.download_and_extract_archive_verified(
kotlin_url,
static_dir,
"zip",
expected_sha256=expected_sha256,
allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS,
)
artifact.dependency.download_to(static_dir)
if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"):
if os.path.exists(kotlin_script) and not platform_id.is_windows():
os.chmod(
kotlin_script,
stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
@@ -165,10 +217,18 @@ class KotlinLanguageServer(SolidLanguageServer):
return kotlin_script
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
command = [core_path, "--stdio"]
# A custom ls_path is independent of Serena's managed version. Select
# arguments from the actual launcher so existing kotlin-lsp.sh/.cmd
# configurations do not inherit IntelliJ-server-only options.
platform_id = PlatformUtils.get_platform_id()
intellij_launcher_name = "intellij-server.exe" if platform_id.is_windows() else "intellij-server"
if os.path.basename(core_path).lower() == intellij_launcher_name:
command.extend(["--system-path", os.path.join(self._project_cache_dir, "kotlin-lsp-system")])
return command
def create_launch_command_env(self) -> dict[str, str]:
"""Provides JAVA_HOME and JVM options for the Kotlin Language Server process."""
"""Provides JVM options for the Kotlin Language Server process."""
env: dict[str, str] = {}
# Get JVM options from settings or use default
@@ -427,6 +487,7 @@ class KotlinLanguageServer(SolidLanguageServer):
"""
Starts the Kotlin Language Server
"""
self._intellij_server_ready.clear()
def execute_client_command_handler(params: dict) -> list:
return []
@@ -437,6 +498,11 @@ class KotlinLanguageServer(SolidLanguageServer):
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def intellij_server_ready(_params: dict | None) -> None:
"""Mark the modern IntelliJ-based server ready after its startup/import phase."""
log.info("Kotlin IntelliJ language server reported that it is ready")
self._intellij_server_ready.set()
def work_done_progress_create(params: dict) -> dict:
"""Handle window/workDoneProgress/create: the server is about to report async progress.
Clear the indexing-complete event so _start_server waits until all tokens finish.
@@ -487,9 +553,12 @@ class KotlinLanguageServer(SolidLanguageServer):
self.server.on_request("workspace/codeLens/refresh", do_nothing)
self.server.on_notification("language/status", do_nothing)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("window/showMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_request("workspace/diagnostic/refresh", do_nothing)
self.server.on_request("window/workDoneProgress/create", work_done_progress_create)
self.server.on_notification("$/progress", progress_handler)
self.server.on_notification("intellij/ready-for-test", intellij_server_ready)
self.server.on_notification("$/logTrace", do_nothing)
self.server.on_notification("$/cancelRequest", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
@@ -503,6 +572,8 @@ class KotlinLanguageServer(SolidLanguageServer):
init_response = self.server.send.initialize(initialize_params)
capabilities = init_response["capabilities"]
server_info = init_response.get("serverInfo", {})
wait_for_intellij_ready = isinstance(server_info, dict) and server_info.get("name") == "IntelliJ Language Server by JetBrains"
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"
@@ -515,14 +586,17 @@ class KotlinLanguageServer(SolidLanguageServer):
self.server.notify.initialized({})
# Wait for any async indexing to complete.
# Wait for workspace import and async indexing to complete.
# - Older KLS (0.253.x): indexing is synchronous inside `initialize`, no $/progress is sent,
# _indexing_complete stays SET -> wait() returns immediately.
# - Newer KLS (261+): server sends window/workDoneProgress/create after initialized,
# which clears the event; wait() blocks until all progress tokens end.
# - IntelliJ-based KLS (262.4739+): server sends intellij/ready-for-test after
# its workspace import and indexing phase completes.
_INDEXING_TIMEOUT = 120.0
log.info("Waiting for Kotlin LSP indexing to complete (if async)...")
if self._indexing_complete.wait(timeout=_INDEXING_TIMEOUT):
ready_event = self._intellij_server_ready if wait_for_intellij_ready else self._indexing_complete
if ready_event.wait(timeout=_INDEXING_TIMEOUT):
log.info("Kotlin LSP ready")
else:
log.warning("Kotlin LSP did not signal indexing completion within %.0fs; proceeding anyway", _INDEXING_TIMEOUT)
@@ -28,6 +28,22 @@ DEFAULT_METALS_VERSION = "1.6.4"
DEFAULT_CLIENT_NAME = "Serena"
DEFAULT_ON_STALE_LOCK = "auto-clean"
DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True
DEFAULT_AUTO_IMPORT_BUILD = True
# The `window/showMessageRequest` actions Serena answers affirmatively: the ones standing between
# an un-imported workspace and a build server. Everything else is dismissed, since a prompt we do
# not recognise is one whose consequences we cannot judge — `Messages.OldBloopVersionRunning`
# offers to kill a process, `Messages.NewScalaProject` to open a window. "Don't show again" is
# never chosen: Metals persists that dismissal in the project's own state.
# (scala/meta/internal/metals/Messages.scala, at `ImportBuild`, `ImportBuildChanges`,
# `GenerateBspAndConnect`.)
#
# Not answered, deliberately: `Messages.ChooseBuildTool` ("Multiple build definitions found. Which
# would you like to use?"), whose actions are the build tools' own executable names. It precedes
# the import prompt in a workspace holding more than one kind of build, so such a workspace is
# still not imported — but choosing a build tool for the user is a guess of a different order, and
# Metals offers no way to say "whichever you would have picked".
BUILD_IMPORT_PROMPT_ACTIONS = ("Import build", "Import changes", "Connect")
class StaleLockMode(Enum):
@@ -43,6 +59,28 @@ class StaleLockMode(Enum):
"""Raise an error and refuse to start."""
def choose_show_message_request_action(params: dict, auto_import_build: bool = DEFAULT_AUTO_IMPORT_BUILD) -> dict | None:
"""
Choose Serena's answer to a `window/showMessageRequest`, which Metals uses to ask whether to
import the build.
:param params: the request's `ShowMessageRequestParams`
:param auto_import_build: whether to answer the build-import prompts affirmatively
:return: the action to select, or None to select none — which is what the LSP spec provides
for, and what leaving the request unanswered fails to say
"""
message = params.get("message", "")
actions = params.get("actions") or []
if auto_import_build:
for action in actions:
if isinstance(action, dict) and action.get("title") in BUILD_IMPORT_PROMPT_ACTIONS:
log.info(f"Metals asked: {message!r}; answering {action['title']!r}")
return action
offered = [action.get("title") for action in actions if isinstance(action, dict)]
log.info(f"Metals asked: {message!r}; dismissing (offered: {offered})")
return None
def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]:
"""
Extract Scala-specific settings with defaults applied.
@@ -52,6 +90,7 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object
- client_name: str
- on_stale_lock: StaleLockMode
- log_multi_instance_notice: bool
- auto_import_build: bool
"""
from solidlsp.ls_config import LanguageServerId
@@ -60,6 +99,7 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object
"client_name": DEFAULT_CLIENT_NAME,
"on_stale_lock": StaleLockMode.AUTO_CLEAN,
"log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE,
"auto_import_build": DEFAULT_AUTO_IMPORT_BUILD,
}
if not solidlsp_settings.ls_specific_settings:
@@ -80,6 +120,7 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object
"client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME),
"on_stale_lock": on_stale_lock,
"log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE),
"auto_import_build": scala_settings.get("auto_import_build", DEFAULT_AUTO_IMPORT_BUILD),
}
@@ -100,6 +141,14 @@ class ScalaLanguageServer(SolidLanguageServer):
metals_version: '1.6.4'
# Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME)
client_name: 'Serena'
# Answer Metals' build-import prompts affirmatively, which lets it run the project's
# build tool (e.g. sbt bloopInstall). Set false to leave the build un-imported.
auto_import_build: true
Build import:
Metals asks, via `window/showMessageRequest`, whether to import a workspace it has not
seen before; until that is answered it has no build server and so no build target, and
every cross-file query is served by the fallback presentation compiler.
Multi-instance support:
Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple
@@ -116,6 +165,8 @@ class ScalaLanguageServer(SolidLanguageServer):
# Check for stale locks before setting up dependencies (fail-fast)
self._check_metals_db_status(repository_root_path, solidlsp_settings)
self._auto_import_build: bool = _get_scala_settings(solidlsp_settings)["auto_import_build"] # type: ignore[assignment]
scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
super().__init__(
config,
@@ -300,10 +351,15 @@ class ScalaLanguageServer(SolidLanguageServer):
}
return initialize_params
def _answer_show_message_request(self, params: dict) -> dict | None:
return choose_show_message_request_action(params, auto_import_build=self._auto_import_build)
def _start_server(self) -> None:
"""
Starts the Scala Language Server
"""
self.server.on_request("window/showMessageRequest", self._answer_show_message_request)
log.info("Starting Scala server process")
self.server.start()
@@ -1,9 +1,19 @@
{
"https://services.gradle.org/distributions/gradle-8.14.2-bin.zip": "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999",
"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage": "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251",
"https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-arm64-1.54.0-923.vsix": "c54c45cb0d2579d8e0a4ddeb24d4a9dd0b460d07d9366adea2b38a1da22a463c",
"https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-x64-1.54.0-923.vsix": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6",
"https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-arm64-1.54.0-923.vsix": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c",
"https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-x64-1.54.0-923.vsix": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b",
"https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4"
}
"https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4",
"https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-win-x64.zip": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad",
"https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-x64.zip": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec",
"https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-aarch64.zip": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651",
"https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-mac-x64.zip": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3",
"https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-mac-aarch64.zip": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.win.zip": "f2daaa476f26d99301b406f76de6d87c437d04dc72f06845154619d8f991c51f",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.win.zip": "73a552a6a420158622e5ad8d96b53da8aa8ced3f88a24fded01575927a2fd8e7",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.tar.gz": "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.tar.gz": "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.sit": "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745",
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.sit": "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f"
}
+199
View File
@@ -0,0 +1,199 @@
import threading
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeoutError
from contextlib import contextmanager
from pathlib import Path
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from serena.project_server import ProjectServer, QueryProjectRequest
@pytest.fixture
def project_server() -> ProjectServer:
server = ProjectServer.__new__(ProjectServer)
server._agent = MagicMock()
server._loaded_projects_by_root = {}
server._project_load_locks_by_root = {}
server._active_project_lock = threading.Lock()
server._loaded_projects_lock = threading.Lock()
return server
def test_cached_project_lookup_is_not_blocked_by_unrelated_cold_load(project_server: ProjectServer) -> None:
cached_root = Path("/cached")
cold_root = Path("/cold")
cached_project = MagicMock()
cold_project = MagicMock()
cold_load_started = threading.Event()
allow_cold_load_to_finish = threading.Event()
cached_registration = MagicMock(project_root=cached_root)
cold_registration = MagicMock(project_root=cold_root)
def block_cold_load() -> None:
cold_load_started.set()
assert allow_cold_load_to_finish.wait(timeout=5)
cold_registration.get_project_instance.return_value = cold_project
cold_project.create_language_server_manager.side_effect = block_cold_load
project_server._loaded_projects_by_root[str(cached_root)] = cached_project
serena_config = cast(Any, project_server._agent.serena_config)
serena_config.get_registered_project.side_effect = {
"cached": cached_registration,
"cold": cold_registration,
}.get
with ThreadPoolExecutor(max_workers=2) as executor:
cold_future = executor.submit(project_server._get_project, "cold")
assert cold_load_started.wait(timeout=1)
cached_future = executor.submit(project_server._get_project, "cached")
try:
assert cached_future.result(timeout=1) is cached_project
finally:
allow_cold_load_to_finish.set()
assert cold_future.result(timeout=1) is cold_project
def test_cold_loads_for_different_projects_can_run_concurrently(project_server: ProjectServer) -> None:
first_root = Path("/first")
second_root = Path("/second")
first_project = MagicMock()
second_project = MagicMock()
first_load_started = threading.Event()
allow_first_load_to_finish = threading.Event()
first_registration = MagicMock(project_root=first_root)
second_registration = MagicMock(project_root=second_root)
def block_first_load() -> None:
first_load_started.set()
assert allow_first_load_to_finish.wait(timeout=5)
first_registration.get_project_instance.return_value = first_project
first_project.create_language_server_manager.side_effect = block_first_load
second_registration.get_project_instance.return_value = second_project
serena_config = cast(Any, project_server._agent.serena_config)
serena_config.get_registered_project.side_effect = {
"first": first_registration,
"second": second_registration,
}.get
with ThreadPoolExecutor(max_workers=2) as executor:
first_future = executor.submit(project_server._get_project, "first")
assert first_load_started.wait(timeout=1)
second_future = executor.submit(project_server._get_project, "second")
try:
assert second_future.result(timeout=1) is second_project
finally:
allow_first_load_to_finish.set()
assert first_future.result(timeout=1) is first_project
def test_concurrent_lookups_load_each_project_only_once(project_server: ProjectServer) -> None:
project_root = Path("/project")
project = MagicMock()
load_started = threading.Event()
second_lookup_started = threading.Event()
allow_load_to_finish = threading.Event()
registration = MagicMock(project_root=project_root)
lookup_count = 0
lookup_count_lock = threading.Lock()
def block_load() -> None:
load_started.set()
assert allow_load_to_finish.wait(timeout=5)
registration.get_project_instance.return_value = project
project.create_language_server_manager.side_effect = block_load
serena_config = cast(Any, project_server._agent.serena_config)
def get_registration(project_name: str) -> MagicMock:
nonlocal lookup_count
assert project_name == "project"
with lookup_count_lock:
lookup_count += 1
if lookup_count == 2:
second_lookup_started.set()
return registration
serena_config.get_registered_project.side_effect = get_registration
with ThreadPoolExecutor(max_workers=2) as executor:
first_future = executor.submit(project_server._get_project, "project")
assert load_started.wait(timeout=1)
second_future = executor.submit(project_server._get_project, "project")
assert second_lookup_started.wait(timeout=1)
allow_load_to_finish.set()
assert first_future.result(timeout=1) is project
assert second_future.result(timeout=1) is project
registration.get_project_instance.assert_called_once_with(serena_config)
project.create_language_server_manager.assert_called_once_with()
def test_concurrent_queries_serialize_active_project_context(project_server: ProjectServer) -> None:
first_query_started = threading.Event()
second_lookup_finished = threading.Event()
allow_first_query_to_finish = threading.Event()
state_lock = threading.Lock()
state: dict[str, str | None] = {"active_project": None}
def get_project(project_name: str) -> str:
if project_name == "second":
second_lookup_finished.set()
return project_name
@contextmanager
def active_project_context(project: str) -> Iterator[None]:
with state_lock:
assert state["active_project"] is None
state["active_project"] = project
try:
yield
finally:
with state_lock:
state["active_project"] = None
def apply_tool(hold: bool = False) -> str:
if hold:
first_query_started.set()
assert allow_first_query_to_finish.wait(timeout=5)
with state_lock:
active_project = state["active_project"]
assert active_project is not None
return active_project
tool = MagicMock()
tool.is_readonly.return_value = True
tool.apply_ex.side_effect = apply_tool
server = cast(Any, project_server)
server._get_project = MagicMock(side_effect=get_project)
agent = cast(Any, project_server._agent)
agent.active_project_context.side_effect = active_project_context
agent.get_tool_by_name.return_value = tool
first_request = QueryProjectRequest(project_name="first", tool_name="read_file", tool_params_json='{"hold": true}')
second_request = QueryProjectRequest(project_name="second", tool_name="read_file", tool_params_json="{}")
with ThreadPoolExecutor(max_workers=2) as executor:
first_future = executor.submit(project_server._query_project, first_request)
assert first_query_started.wait(timeout=1)
second_future = executor.submit(project_server._query_project, second_request)
assert second_lookup_finished.wait(timeout=1)
try:
with pytest.raises(FutureTimeoutError):
second_future.result(timeout=0.1)
finally:
allow_first_query_to_finish.set()
assert first_future.result(timeout=1) == "first"
assert second_future.result(timeout=1) == "second"
@@ -455,7 +455,7 @@ class TestSetupFromExistingInstall:
(jdk / "bin" / "java").touch()
return jdk
def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode(
def test_happy_path_returns_runtime_paths_with_no_gradle(
self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings
) -> None:
jdk = self._fake_jdk(tmp_path)
@@ -465,8 +465,6 @@ class TestSetupFromExistingInstall:
result = EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_root), str(lombok_jar), custom_settings)
assert result.gradle_path is None
assert result.intellicode_jar_path is None
assert result.intellisense_members_path is None
assert result.lombok_jar_path == str(lombok_jar)
assert result.jre_home_path == str(jdk)
assert Path(result.jdtls_launcher_jar_path).name.startswith("org.eclipse.equinox.launcher_")
+1 -1
View File
@@ -10,7 +10,7 @@ from test.conftest import language_server_tests_enabled
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
# Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints
# Kotlin LSP (IntelliJ-based, pre-alpha v262) crashes on JVM restart under CI resource constraints
# (2 CPUs, 7GB RAM). First start succeeds but subsequent starts fail with cancelled (-32800).
# Tests pass reliably on developer machines. See PR #1061 for investigation details.
# (The CI quarantine lives centrally in test/conftest.py::_determine_disabled_languages.)
@@ -0,0 +1,275 @@
"""Tests for Kotlin Language Server dependency resolution and installation."""
from contextlib import nullcontext
from pathlib import Path
from unittest.mock import patch
import pytest
from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase
from solidlsp.language_servers.kotlin_language_server import (
DEFAULT_KOTLIN_LSP_VERSION,
INITIAL_KOTLIN_LSP_VERSION,
KOTLIN_LSP_ALLOWED_HOSTS,
KotlinLanguageServer,
)
from solidlsp.ls_utils import PlatformId
from solidlsp.settings import SolidLSPSettings
def _make_provider(
tmp_path: Path,
custom_settings: dict[str, str] | None = None,
) -> KotlinLanguageServer.DependencyProvider:
return KotlinLanguageServer.DependencyProvider(
custom_settings=SolidLSPSettings.CustomLSSettings(custom_settings or {}),
ls_resources_dir=str(tmp_path),
project_cache_dir=str(tmp_path / "project-cache"),
)
@pytest.mark.kotlin
class TestKotlinDependencyProvider:
@pytest.mark.parametrize(
("platform_id", "asset_suffix", "archive_type", "launcher_parts", "sha256"),
[
(
PlatformId.WIN_x64,
".win.zip",
"zip",
("bin", "intellij-server.exe"),
"f2daaa476f26d99301b406f76de6d87c437d04dc72f06845154619d8f991c51f",
),
(
PlatformId.WIN_arm64,
"-aarch64.win.zip",
"zip",
("bin", "intellij-server.exe"),
"73a552a6a420158622e5ad8d96b53da8aa8ced3f88a24fded01575927a2fd8e7",
),
(
PlatformId.LINUX_x64,
".tar.gz",
"gztar",
(f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"),
"2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e",
),
(
PlatformId.LINUX_arm64,
"-aarch64.tar.gz",
"gztar",
(f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"),
"2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e",
),
(
PlatformId.OSX_x64,
".sit",
"zip",
(f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"),
"17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745",
),
(
PlatformId.OSX_arm64,
"-aarch64.sit",
"zip",
(f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"),
"6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f",
),
],
)
def test_default_artifacts_match_jetbrains_release_matrix(
self,
tmp_path: Path,
platform_id: PlatformId,
asset_suffix: str,
archive_type: str,
launcher_parts: tuple[str, ...],
sha256: str,
) -> None:
artifact = KotlinLanguageServer.DependencyProvider._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, platform_id)
url = (
f"https://download-cdn.jetbrains.com/language-server/kotlin-server/{DEFAULT_KOTLIN_LSP_VERSION}/"
f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}{asset_suffix}"
)
with patch("solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified") as download:
artifact.dependency.download_to(tmp_path)
download.assert_called_once_with(
url,
str(tmp_path),
archive_type=archive_type,
expected_sha256=sha256,
allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS,
)
assert artifact.launcher_parts == launcher_parts
@pytest.mark.parametrize(
("version", "platform_id", "url", "launcher_parts"),
[
(
"262.2310.0",
PlatformId.LINUX_x64,
"https://download-cdn.jetbrains.com/kotlin-lsp/262.2310.0/kotlin-lsp-262.2310.0-linux-x64.zip",
("kotlin-lsp.sh",),
),
(
"262.4739.0",
PlatformId.LINUX_x64,
"https://download-cdn.jetbrains.com/kotlin-lsp/262.4739.0/kotlin-server-262.4739.0.tar.gz",
("kotlin-server-262.4739.0", "bin", "intellij-server"),
),
(
"262.8190.0",
PlatformId.OSX_arm64,
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.8190.0/kotlin-server-262.8190.0-aarch64.sit",
("kotlin-server-262.8190.0", "bin", "intellij-server"),
),
],
)
def test_version_boundaries_select_the_published_layout(
self,
version: str,
platform_id: PlatformId,
url: str,
launcher_parts: tuple[str, ...],
) -> None:
artifact = KotlinLanguageServer.DependencyProvider._create_artifact(version, platform_id)
assert artifact.dependency.get_url() == url
assert artifact.launcher_parts == launcher_parts
def test_custom_versions_skip_hash_lookup_by_design(self, tmp_path: Path) -> None:
artifact = KotlinLanguageServer.DependencyProvider._create_artifact("262.8190.1", PlatformId.LINUX_x64)
with (
patch(
"solidlsp.dependency_provider.DownloadedDependencyHashDatabase.get_instance",
side_effect=AssertionError("custom versions must not consult the pinned hash database"),
),
patch("solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified") as download,
):
artifact.dependency.download_to(tmp_path)
download.assert_called_once_with(
"https://download-cdn.jetbrains.com/language-server/kotlin-server/262.8190.1/kotlin-server-262.8190.1.tar.gz",
str(tmp_path),
archive_type="gztar",
expected_sha256=None,
allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS,
)
def test_hash_updater_registers_frozen_and_default_artifacts(self) -> None:
updated_asset_names: list[str] = []
class RecordingUpdater:
def update(self, dependency: DownloadedDependency) -> None:
updated_asset_names.append(dependency.get_url().rsplit("/", 1)[-1])
with patch.object(DownloadedDependencyHashDatabase, "get_instance") as get_hash_database:
get_hash_database.return_value.update_context.return_value = nullcontext(RecordingUpdater())
KotlinLanguageServer.DependencyProvider.update_dep_hashes()
assert set(updated_asset_names) == {
"kotlin-lsp-261.13587.0-win-x64.zip",
"kotlin-lsp-261.13587.0-linux-x64.zip",
"kotlin-lsp-261.13587.0-linux-aarch64.zip",
"kotlin-lsp-261.13587.0-mac-x64.zip",
"kotlin-lsp-261.13587.0-mac-aarch64.zip",
"kotlin-server-262.9593.0.win.zip",
"kotlin-server-262.9593.0-aarch64.win.zip",
"kotlin-server-262.9593.0.tar.gz",
"kotlin-server-262.9593.0-aarch64.tar.gz",
"kotlin-server-262.9593.0.sit",
"kotlin-server-262.9593.0-aarch64.sit",
}
@pytest.mark.parametrize(
("settings", "platform_id", "relative_launcher", "expected_directory"),
[
(
{"kotlin_lsp_version": INITIAL_KOTLIN_LSP_VERSION},
PlatformId.LINUX_x64,
("kotlin-lsp.sh",),
"kotlin_language_server",
),
(
{},
PlatformId.LINUX_arm64,
(f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"),
f"kotlin_language_server-{DEFAULT_KOTLIN_LSP_VERSION}",
),
],
)
def test_installs_pinned_versions_in_compatible_directories(
self,
tmp_path: Path,
settings: dict[str, str],
platform_id: PlatformId,
relative_launcher: tuple[str, ...],
expected_directory: str,
) -> None:
provider = _make_provider(tmp_path, settings)
def fake_download(
_url: str,
target_path: str,
archive_type: str,
expected_sha256: str | None = None,
allowed_hosts: tuple[str, ...] | list[str] | None = None,
) -> None:
del archive_type, expected_sha256, allowed_hosts
launcher = Path(target_path).joinpath(*relative_launcher)
launcher.parent.mkdir(parents=True, exist_ok=True)
launcher.write_text("#!/bin/sh\n", encoding="utf-8")
with (
patch(
"solidlsp.language_servers.kotlin_language_server.PlatformUtils.get_platform_id",
return_value=platform_id,
),
patch(
"solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified",
side_effect=fake_download,
),
):
launcher_path = provider._get_or_install_core_dependency()
assert launcher_path == str((tmp_path / expected_directory).joinpath(*relative_launcher))
@pytest.mark.parametrize(
("platform_id", "modern_launcher", "other_os_launcher"),
[
(PlatformId.LINUX_x64, "/path/to/intellij-server", "/path/to/intellij-server.exe"),
(PlatformId.WIN_x64, "/path/to/intellij-server.exe", "/path/to/intellij-server"),
],
)
def test_custom_launcher_arguments_use_the_active_os_name(
self,
tmp_path: Path,
platform_id: PlatformId,
modern_launcher: str,
other_os_launcher: str,
) -> None:
modern_provider = _make_provider(tmp_path, {"ls_path": modern_launcher})
other_os_provider = _make_provider(tmp_path, {"ls_path": other_os_launcher})
with patch(
"solidlsp.language_servers.kotlin_language_server.PlatformUtils.get_platform_id",
return_value=platform_id,
):
assert modern_provider.create_launch_command() == [
modern_launcher,
"--stdio",
"--system-path",
str(tmp_path / "project-cache" / "kotlin-lsp-system"),
]
assert other_os_provider.create_launch_command() == [other_os_launcher, "--stdio"]
def test_invalid_version_is_rejected_before_download(self) -> None:
with pytest.raises(ValueError, match="dot-separated integers"):
KotlinLanguageServer.DependencyProvider._create_artifact("latest", PlatformId.OSX_arm64)
def test_unsupported_modern_platform_is_rejected(self) -> None:
with pytest.raises(ValueError, match="Unsupported platform"):
KotlinLanguageServer.DependencyProvider._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, PlatformId.LINUX_x86)
@@ -2,9 +2,11 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import LanguageServerId
from test.conftest import language_server_tests_enabled
from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.KOTLIN), reason="Kotlin tests are disabled")
@pytest.mark.kotlin
class TestKotlinDiagnostics:
@pytest.mark.parametrize("language_server", [LanguageServerId.KOTLIN], indirect=True)
+109
View File
@@ -0,0 +1,109 @@
"""Regression tests for Kotlin LSP startup readiness handling."""
import logging
from pathlib import Path
from typing import Any, cast
from unittest.mock import Mock, patch
import pytest
from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer
from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.settings import SolidLSPSettings
pytestmark = pytest.mark.kotlin
_REQUIRED_CAPABILITIES = {
"textDocumentSync": 1,
"hoverProvider": True,
"completionProvider": {},
"signatureHelpProvider": {},
"definitionProvider": True,
"referencesProvider": True,
"documentSymbolProvider": True,
"workspaceSymbolProvider": True,
"semanticTokensProvider": {},
}
def _make_server(tmp_path: Path) -> tuple[KotlinLanguageServer, Mock, Mock]:
settings = SolidLSPSettings(
solidlsp_dir=str(tmp_path / "global"),
project_data_path=str(tmp_path / "project"),
ls_specific_settings={LanguageServerId.KOTLIN: {}},
)
server_interface = Mock()
with patch.object(KotlinLanguageServer, "_create_language_server_interface", return_value=server_interface):
server = KotlinLanguageServer(
LanguageServerConfig(ls_id=LanguageServerId.KOTLIN),
str(tmp_path),
settings,
)
indexing_complete = Mock()
indexing_complete.wait.return_value = True
intellij_server_ready = Mock()
intellij_server_ready.wait.return_value = True
server_state = cast(Any, server)
server_state._indexing_complete = indexing_complete
server_state._intellij_server_ready = intellij_server_ready
return server, indexing_complete, intellij_server_ready
def _configure_initialize(server: KotlinLanguageServer, server_name: str | None) -> dict[str, Any]:
response: dict[str, Any] = {"capabilities": _REQUIRED_CAPABILITIES}
if server_name is not None:
response["serverInfo"] = {"name": server_name}
language_server_interface = cast(Any, server.server)
language_server_interface.send.initialize.return_value = response
return response
def test_modern_server_registers_and_waits_for_explicit_ready_notification(tmp_path: Path) -> None:
server, indexing_complete, intellij_server_ready = _make_server(tmp_path)
_configure_initialize(server, "IntelliJ Language Server by JetBrains")
events: list[str] = []
notification_handlers: dict[str, Any] = {}
language_server_interface = cast(Any, server.server)
def on_notification(method: str, handler: Any) -> None:
events.append(f"notification:{method}")
notification_handlers[method] = handler
language_server_interface.on_notification.side_effect = on_notification
language_server_interface.start.side_effect = lambda: events.append("start")
language_server_interface.send.initialize.side_effect = lambda _params: (
events.append("initialize")
or {"capabilities": _REQUIRED_CAPABILITIES, "serverInfo": {"name": "IntelliJ Language Server by JetBrains"}}
)
server._start_server()
assert events.index("notification:intellij/ready-for-test") < events.index("start") < events.index("initialize")
intellij_server_ready.clear.assert_called_once_with()
intellij_server_ready.wait.assert_called_once_with(timeout=120.0)
indexing_complete.wait.assert_not_called()
notification_handlers["intellij/ready-for-test"](None)
intellij_server_ready.set.assert_called_once_with()
def test_legacy_server_keeps_progress_event_wait(tmp_path: Path) -> None:
server, indexing_complete, intellij_server_ready = _make_server(tmp_path)
_configure_initialize(server, None)
server._start_server()
indexing_complete.wait.assert_called_once_with(timeout=120.0)
intellij_server_ready.wait.assert_not_called()
def test_modern_ready_timeout_warns_and_continues(tmp_path: Path, caplog: Any) -> None:
server, _indexing_complete, intellij_server_ready = _make_server(tmp_path)
_configure_initialize(server, "IntelliJ Language Server by JetBrains")
intellij_server_ready.wait.return_value = False
with caplog.at_level(logging.WARNING):
server._start_server()
assert "Kotlin LSP did not signal indexing completion within 120s; proceeding anyway" in caplog.text
@@ -0,0 +1,94 @@
"""
Unit tests for Serena's answer to Metals' `window/showMessageRequest` prompts.
"""
import pytest
from solidlsp.language_servers.scala_language_server import (
_get_scala_settings,
choose_show_message_request_action,
)
from solidlsp.ls_config import LanguageServerId
from solidlsp.settings import SolidLSPSettings
# Metals' own prompts, with the message and action titles as its `Messages` object builds them
# (scala/meta/internal/metals/Messages.scala) and `type` as lsp4j serialises `MessageType.Info`.
IMPORT_BUILD = {
"message": "New sbt workspace detected, would you like to import the build?",
"type": 3,
"actions": [{"title": "Import build"}, {"title": "Not now"}, {"title": "Don't show again"}],
}
IMPORT_CHANGES = {
"message": "sbt build needs to be re-imported",
"type": 3,
"actions": [{"title": "Import changes"}, {"title": "Not now"}, {"title": "Don't show again"}],
}
GENERATE_BSP_AND_CONNECT = {
"message": "New sbt workspace detected, would you like connect to the Bloop build server?",
"type": 3,
"actions": [{"title": "Connect"}, {"title": "Not now"}, {"title": "Don't show again"}],
}
OLD_BLOOP_VERSION_RUNNING = {
"message": "Deprecated Bloop server is still running and is taking up resources, do you want to kill the process?",
"type": 3,
"actions": [{"title": "Yes"}, {"title": "Not now"}],
}
CHOOSE_BUILD_TOOL = {
"message": "Multiple build definitions found. Which would you like to use?",
"type": 3,
"actions": [{"title": "sbt"}, {"title": "mill"}],
}
@pytest.mark.scala
class TestChooseShowMessageRequestAction:
@pytest.mark.parametrize("params", [IMPORT_BUILD, IMPORT_CHANGES, GENERATE_BSP_AND_CONNECT])
def test_build_import_prompts_are_answered_affirmatively(self, params: dict) -> None:
chosen = choose_show_message_request_action(params)
assert chosen is not None
assert chosen["title"] in ("Import build", "Import changes", "Connect")
@pytest.mark.parametrize("params", [IMPORT_BUILD, IMPORT_CHANGES, GENERATE_BSP_AND_CONNECT])
def test_auto_import_build_can_be_turned_off(self, params: dict) -> None:
assert choose_show_message_request_action(params, auto_import_build=False) is None
def test_dont_show_again_is_never_chosen(self) -> None:
"""Metals persists that dismissal in the project's own state."""
params = {"message": "…", "actions": [{"title": "Don't show again"}, {"title": "Not now"}]}
assert choose_show_message_request_action(params) is None
def test_an_unrecognised_prompt_is_dismissed(self) -> None:
"""Answering "Yes" here would kill a process on the user's machine."""
assert choose_show_message_request_action(OLD_BLOOP_VERSION_RUNNING) is None
def test_a_prompt_with_no_actions_is_dismissed(self) -> None:
assert choose_show_message_request_action({"message": "just so you know"}) is None
assert choose_show_message_request_action({"message": "just so you know", "actions": None}) is None
def test_malformed_actions_do_not_raise(self) -> None:
params = {"message": "…", "actions": ["Import build", None, {"title": "Import build"}]}
assert choose_show_message_request_action(params) == {"title": "Import build"}
def test_choosing_between_build_tools_is_left_alone(self) -> None:
"""Naming the build tool for the user is a guess of a different order; see the comment
on BUILD_IMPORT_PROMPT_ACTIONS. A workspace with several build definitions is therefore
still not imported.
"""
assert choose_show_message_request_action(CHOOSE_BUILD_TOOL) is None
@pytest.mark.scala
class TestAutoImportBuildSetting:
"""`auto_import_build` has to reach the handler from the project configuration."""
@staticmethod
def setting(**scala_settings) -> object:
settings = SolidLSPSettings(ls_specific_settings={LanguageServerId.SCALA: scala_settings})
return _get_scala_settings(settings)["auto_import_build"]
def test_defaults_to_true(self) -> None:
assert self.setting() is True
assert _get_scala_settings(SolidLSPSettings())["auto_import_build"] is True
def test_can_be_disabled(self) -> None:
assert self.setting(auto_import_build=False) is False