Java (JDT-LS): allow registering additional JRE/JDK runtimes via config (#1715)

* Java (JDT-LS): allow registering additional JRE/JDK runtimes via config

JDT-LS resolves each imported module's JRE_CONTAINER/.../JavaSE-NN/
classpath entry against its own "Installed JREs" list, which Serena
previously hardcoded to a single JavaSE-21 entry pointing at the
bundled JRE. Projects whose source/target level exceeds that bundled
JDK (e.g. sourceCompatibility = VERSION_25) get a container that never
resolves, so every JDK type -- java.lang.Object, java.util.*, etc. --
comes back "cannot be resolved", silently breaking diagnostics and
cross-module resolution. There was no config key to tell JDT-LS about
an installed newer JDK.

Add an optional `runtimes` list under ls_specific_settings.java,
mirroring VS Code's java.configuration.runtimes shape (name, path,
optional default/sources/javadoc). Entries are validated (name+path
required, path must exist) via the new _resolve_configured_runtimes(),
then merged with the bundled JavaSE-21 default: configured runtimes
extend the list; an entry reusing the "JavaSE-21" name overrides the
bundled one; if a configured entry claims `default`, the bundled
runtime's own default flag is cleared so JDT-LS doesn't see two
defaults. The `runtimes` key is also added to the JDTLS workspace-hash
inputs so changing it lands in a fresh workspace instead of reusing a
stale Buildship/Maven import.

Testing: added 14 unit tests in
test/solidlsp/java/test_jdtls_path_resolution.py covering
_resolve_configured_runtimes validation (missing keys, wrong types,
nonexistent paths) and the merge behavior in
_create_base_initialize_params (extension, name-collision override,
default hand-off). Ran `poe format`, `poe type-check`, and
`pytest test/solidlsp/java -m "not java"` (69 passed, 8 deselected
real-JDTLS tests that require a JDK) locally.

Fixes #1478

* docs: document java.runtimes in configuration guide

Address review feedback on #1715 by documenting ls_specific_settings.java.runtimes
in 050_configuration.md (settings table, note, and example).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-07-19 16:03:25 +02:00
1 parent a9067050f2
commit 18d4cc007b
4 files changed
+231 -4

No files matched your search

+8
View File
@@ -15,6 +15,14 @@ Status of the `main` branch. Changes prior to the next official version change w
line; any other out-of-range end position now raises `InvalidTextLocationError` instead,
rather than guessing at a body that could be wrong #1498
* Language Servers:
- Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries
(`name`, `path`, optional `default`/`sources`/`javadoc`) passed through to JDT-LS's
`java.configuration.runtimes`. Fixes silently broken JDK type resolution (`java.lang.Object`
and other JDK types reported as "cannot be resolved") for projects whose source/target level
exceeds the bundled JDK 21 JRE JDT-LS registers by default; configured runtimes extend rather
than replace that bundled default. #1478
* Tools:
- Fix: `search_for_pattern` marked one line too many as matched whenever a match ended with a line
break, because the match's exclusive end index was mapped to a line number directly and therefore
+18
View File
@@ -697,6 +697,7 @@ The following settings are supported for the Java language server:
| `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. |
| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses `JAVA_HOME` if `use_system_java_home` is enabled and `JAVA_HOME` is set; otherwise it falls back to Serena's bundled JRE. |
| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself and, when `gradle_java_home` is unset, Gradle import. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. |
| `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. |
@@ -716,6 +717,10 @@ Notes:
- 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.
- 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
bundled JRE directory.
Example: upstream-jdtls mode (offline / corporate network):
@@ -736,6 +741,19 @@ ls_specific_settings:
use_system_java_home: true
```
Example: register an additional JDK for a project targeting a newer Java version:
```yaml
ls_specific_settings:
java:
runtimes:
- name: JavaSE-21
path: /usr/lib/jvm/java-21-openjdk
- name: JavaSE-25
path: /home/user/Java/jdk25
default: true
```
#### Kotlin
Serena uses [JetBrains' Kotlin Language Server](https://github.com/Kotlin/kotlin-lsp) for Kotlin support.
+76 -4
View File
@@ -15,7 +15,7 @@ import subprocess
import threading
from pathlib import Path, PurePath
from time import sleep
from typing import cast
from typing import Any, cast
from overrides import override
@@ -199,6 +199,17 @@ class EclipseJDTLS(SolidLanguageServer):
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:
- name (required): the JRE container name JDT-LS should register the entry under,
e.g. "JavaSE-25" (must match the ``JavaSE-NN`` the build tool requests).
- path (required): filesystem path to the JDK/JRE home directory; must exist.
- default (optional): whether this runtime is JDT-LS's default when no container name matches.
- sources / javadoc (optional): passed through unchanged to JDT-LS.
These entries extend rather than replace the bundled JRE, which is still registered as
"JavaSE-21" unless a configured entry reuses that same name (in which case it is overridden).
See serena #1478.
Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate):
```yaml
@@ -228,6 +239,10 @@ class EclipseJDTLS(SolidLanguageServer):
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"
default: true
```
"""
@@ -779,6 +794,7 @@ class EclipseJDTLS(SolidLanguageServer):
"java_home",
"use_system_java_home",
"maven_offline",
"runtimes",
)
workspace_settings = {key: custom_settings.settings[key] for key in workspace_setting_keys if key in custom_settings.settings}
workspace_settings_json = json.dumps(workspace_settings, sort_keys=True, separators=(",", ":"))
@@ -922,6 +938,52 @@ class EclipseJDTLS(SolidLanguageServer):
log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}")
return self.runtime_dependency_paths.jre_path
def _resolve_configured_runtimes(self) -> list[dict[str, Any]]:
"""
Validate and normalize the optional extra JRE/JDK runtimes to register with JDT-LS, as configured
via ``ls_specific_settings.java.runtimes``. Each entry mirrors the shape VS Code's Java extension
sends via ``java.configuration.runtimes`` (``name``, ``path``, optional ``default``/``sources``/
``javadoc``); see serena #1478.
:return: the validated list of runtime dicts (empty if the setting is unset)
:raises ValueError: if the setting or one of its entries is malformed
:raises FileNotFoundError: if an entry's ``path`` does not exist
"""
configured_runtimes = self._custom_settings.get("runtimes", [])
if not isinstance(configured_runtimes, list):
raise ValueError(
f"ls_specific_settings.java.runtimes must be a list of {{name, path}} entries, "
f"got {type(configured_runtimes).__name__}: {configured_runtimes!r}"
)
# validate each entry and normalize it to the shape JDT-LS expects...
validated_runtimes: list[dict[str, Any]] = []
for entry in configured_runtimes:
if not isinstance(entry, dict) or "name" not in entry or "path" not in entry:
raise ValueError(
f"Invalid ls_specific_settings.java.runtimes entry {entry!r}: each entry requires at least a 'name' and a 'path' key."
)
path = entry["path"]
if not os.path.exists(path):
error_msg = (
f"ls_specific_settings.java.runtimes entry '{entry['name']}' points to a path that "
f"does not exist: {path}. Fix: update the path in ~/.serena/serena_config.yml "
f"(ls_specific_settings -> java -> runtimes), or remove the entry."
)
log.error(error_msg)
raise FileNotFoundError(error_msg)
runtime: dict[str, Any] = {"name": entry["name"], "path": path}
if "default" in entry:
runtime["default"] = bool(entry["default"])
for optional_key in ("sources", "javadoc"):
if optional_key in entry:
runtime[optional_key] = entry[optional_key]
validated_runtimes.append(runtime)
log.info(f"Registering additional JDT-LS runtime '{runtime['name']}' from custom settings: {path}")
return validated_runtimes
def _create_base_initialize_params(self) -> dict:
"""
Returns the initialize parameters for the EclipseJDTLS server.
@@ -1304,9 +1366,19 @@ class EclipseJDTLS(SolidLanguageServer):
else:
initialize_params["initializationOptions"]["bundles"] = []
initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [
{"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True}
]
# 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}
configured_runtimes = self._resolve_configured_runtimes()
runtimes_by_name = {default_runtime["name"]: default_runtime}
for runtime in configured_runtimes:
runtimes_by_name[runtime["name"]] = runtime
if runtimes_by_name["JavaSE-21"] is default_runtime and any(runtime.get("default") for runtime in configured_runtimes):
# a configured runtime claims the JDT-LS default; the bundled runtime must not also claim it,
# since JDT-LS expects at most one default runtime
default_runtime["default"] = False
initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = list(runtimes_by_name.values())
for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]:
assert "name" in runtime
@@ -644,3 +644,132 @@ class TestIsIgnoredDirname:
@pytest.mark.parametrize("dirname", [".git", ".venv", ".idea", ".serena", ".mypy_cache"])
def test_always_ignored_dirs_are_still_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None:
assert jdtls.is_ignored_dirname(dirname) is True
# ----------------------------------------------------------------------------
# _resolve_configured_runtimes / configured `runtimes` initialize settings (#1478)
# ----------------------------------------------------------------------------
def _runtimes(initialize_params: dict) -> list[dict]:
"""
Return the JDT-LS ``java.configuration.runtimes`` list from initialize parameters.
:param initialize_params: JDTLS initialize-parameter payload.
:return: The configured runtimes list.
"""
return initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]
class TestResolveConfiguredRuntimes:
def test_empty_when_unset(self, custom_settings: SolidLSPSettings.CustomLSSettings) -> None:
server = object.__new__(EclipseJDTLS)
server._custom_settings = custom_settings
assert server._resolve_configured_runtimes() == []
def test_valid_entry_is_normalized(self, tmp_path: Path) -> None:
jdk = tmp_path / "jdk-25"
jdk.mkdir()
server = object.__new__(EclipseJDTLS)
server._custom_settings = SolidLSPSettings.CustomLSSettings(
{"runtimes": [{"name": "JavaSE-25", "path": str(jdk), "default": True}]}
)
assert server._resolve_configured_runtimes() == [{"name": "JavaSE-25", "path": str(jdk), "default": True}]
def test_optional_sources_and_javadoc_pass_through(self, tmp_path: Path) -> None:
jdk = tmp_path / "jdk-25"
jdk.mkdir()
server = object.__new__(EclipseJDTLS)
server._custom_settings = SolidLSPSettings.CustomLSSettings(
{"runtimes": [{"name": "JavaSE-25", "path": str(jdk), "sources": "/src", "javadoc": "/doc"}]}
)
assert server._resolve_configured_runtimes() == [{"name": "JavaSE-25", "path": str(jdk), "sources": "/src", "javadoc": "/doc"}]
def test_raises_when_runtimes_is_not_a_list(self) -> None:
server = object.__new__(EclipseJDTLS)
server._custom_settings = SolidLSPSettings.CustomLSSettings({"runtimes": {"name": "JavaSE-25", "path": "/x"}})
with pytest.raises(ValueError, match="must be a list"):
server._resolve_configured_runtimes()
@pytest.mark.parametrize(
"entry", [{"path": "/x"}, {"name": "JavaSE-25"}, "not-a-dict", 42], ids=["missing-name", "missing-path", "string", "int"]
)
def test_raises_for_malformed_entry(self, entry: object) -> None:
server = object.__new__(EclipseJDTLS)
server._custom_settings = SolidLSPSettings.CustomLSSettings({"runtimes": [entry]})
with pytest.raises(ValueError, match="requires at least a 'name' and a 'path' key"):
server._resolve_configured_runtimes()
def test_raises_for_nonexistent_path(self, tmp_path: Path) -> None:
server = object.__new__(EclipseJDTLS)
server._custom_settings = SolidLSPSettings.CustomLSSettings(
{"runtimes": [{"name": "JavaSE-25", "path": str(tmp_path / "missing-jdk")}]}
)
with pytest.raises(FileNotFoundError, match="does not exist"):
server._resolve_configured_runtimes()
class TestConfiguredRuntimesInitializeSettings:
def test_bundled_runtime_is_sole_default_when_unconfigured(
self, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings
) -> None:
repo = tmp_path / "repo"
repo.mkdir()
runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime")
server = _make_uninitialized_jdtls(repo, {}, runtime_paths)
assert _runtimes(server._create_base_initialize_params()) == [
{"name": "JavaSE-21", "path": runtime_paths.jre_home_path, "default": True}
]
def test_configured_runtime_extends_bundled_runtime(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime")
jdk25 = tmp_path / "jdk-25"
jdk25.mkdir()
server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-25", "path": str(jdk25)}]}, runtime_paths)
runtimes = _runtimes(server._create_base_initialize_params())
assert {"name": "JavaSE-21", "path": runtime_paths.jre_home_path, "default": True} in runtimes
assert {"name": "JavaSE-25", "path": str(jdk25)} in runtimes
assert len(runtimes) == 2
def test_configured_runtime_with_same_name_overrides_bundled_runtime(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime")
override_jdk = tmp_path / "override-jdk-21"
override_jdk.mkdir()
server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-21", "path": str(override_jdk)}]}, runtime_paths)
runtimes = _runtimes(server._create_base_initialize_params())
assert runtimes == [{"name": "JavaSE-21", "path": str(override_jdk)}]
def test_configured_default_unsets_bundled_default(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime")
jdk25 = tmp_path / "jdk-25"
jdk25.mkdir()
server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-25", "path": str(jdk25), "default": True}]}, runtime_paths)
runtimes = _runtimes(server._create_base_initialize_params())
bundled = next(r for r in runtimes if r["name"] == "JavaSE-21")
configured = next(r for r in runtimes if r["name"] == "JavaSE-25")
assert bundled["default"] is False
assert configured["default"] is True
def test_invalid_configured_runtime_raises_during_initialize_params(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime")
server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-25"}]}, runtime_paths)
with pytest.raises(ValueError, match="requires at least a 'name' and a 'path' key"):
server._create_base_initialize_params()