diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f86e376..91b97747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 21813640..469ef566 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -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. diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 1539002b..b4b86e96 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -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 diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 7e7cd71c..fa05866e 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -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()