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

@@ -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()