From a3585a66fb20b07bbc53d67b645819a28f79de22 Mon Sep 17 00:00:00 2001 From: Artem Simeshin Date: Sat, 25 Apr 2026 20:47:24 +0300 Subject: [PATCH 1/5] feat(jdtls): support upstream JDTLS via jdtls_path/lombok_path settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an offline-friendly Java mode for restricted-network/corporate environments. When both ls_specific_settings.java.jdtls_path and lombok_path are set, Serena uses an existing upstream JDTLS install (e.g. brew install jdtls or extracted jdt-language-server-*.tar.gz from download.eclipse.org) and the system JDK 21+ instead of downloading the ~500 MB platform-specific vscode-java VSIX bundle. The default vscode-java VSIX path is unchanged when neither setting is provided. Resolution details: - jdtls_path expects upstream layout (plugins/, config_/, features/) - The main equinox.launcher jar is located via regex glob, excluding native fragments - Platform-specific config_/ is auto-selected by PlatformUtils - JDK home is discovered by querying the JVM via 'java -XshowSettings:properties -version' (single source of truth — works around macOS /usr/bin/java stub which can't be resolved via path traversal) - JDK major version is validated >= 21 from the same JVM output - Lombok agent is always attached (lombok_path is mandatory in this mode) - IntelliCode bundle and Gradle distribution are skipped (irrelevant for Serena's symbol-tools workflow; Maven works via JDTLS-bundled m2e, Gradle via ./gradlew or system install through Buildship default discovery) Workspace hash now incorporates the launcher jar path so plan A and the default mode use distinct workspace directories — prevents stale OSGi configs from a prior JDTLS version blocking startup. Refs: oraios/serena#1414 --- docs/02-usage/050_configuration.md | 40 +- .../language_servers/eclipse_jdtls.py | 361 ++++++++++++++++-- 2 files changed, 371 insertions(+), 30 deletions(-) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 37fb55c0e..a8abf440f 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -504,28 +504,54 @@ ls_specific_settings: #### Java (`eclipse.jdt.ls`) +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. +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. + The following settings are supported for the Java language server: | Setting | Default | Description | |---|---|---| +| `jdtls_path` | `null` | Activates upstream JDTLS mode. Path to upstream JDTLS root (containing `plugins/` and `config_/`). Get via `brew install jdtls` or extract `jdt-language-server-*.tar.gz` from . Must be set together with `lombok_path`. | +| `lombok_path` | `null` | Path to the Lombok jar. Activates upstream JDTLS mode together with `jdtls_path`. Get from `~/.m2/repository/org/projectlombok/lombok//lombok-.jar` or download from . | +| `java_home` | `null` | (upstream-jdtls mode only) Path to JDK 21+ home directory used to launch JDTLS. Falls back to `JAVA_HOME` env var, then `which java`. | | `maven_user_settings` | `~/.m2/settings.xml` | Path to Maven `settings.xml` | | `gradle_user_home` | `~/.gradle` | Path to Gradle user home directory | | `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 the bundled JRE. | | `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | -| `gradle_version` | `8.14.2` | Override the Gradle distribution version Serena downloads by default. | -| `vscode_java_version` | `1.42.0-561` | Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | -| `intellicode_version` | `1.2.30` | Override the IntelliCode VSIX version Serena downloads by default. | +| `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | +| `vscode_java_version` | `1.42.0-561` | (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. | | `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. | +| `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. | -Note: +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. + Gradle projects must have `./gradlew` in the project, or rely on a system-installed Gradle through Buildship's + default discovery rules. + +Example: upstream-jdtls mode (offline / corporate network): + +```yaml +ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional +``` -Example for a project with custom Gradle plugins and JDK requirements: +Example: default vscode-java VSIX mode for a project with custom Gradle plugins: ```yaml ls_specific_settings: diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index d90afd6dd..9332a28f1 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -7,9 +7,12 @@ import logging import os import pathlib +import platform +import re import shutil +import subprocess import threading -from pathlib import PurePath +from pathlib import Path, PurePath from time import sleep from typing import cast @@ -18,6 +21,7 @@ from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation @@ -42,28 +46,69 @@ ) INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +# Mapping from Serena's platform identifiers to upstream JDTLS config_ directory names +JDTLS_CONFIG_DIR_BY_PLATFORM = { + "osx-arm64": "config_mac_arm", + "darwin-arm64": "config_mac_arm", + "osx-x64": "config_mac", + "linux-arm64": "config_linux_arm", + "linux-x64": "config_linux", + "win-x64": "config_win", +} + +# Minimum supported JDK version for running JDTLS itself +JDTLS_MIN_JDK_VERSION = 21 + @dataclasses.dataclass class RuntimeDependencyPaths: """ - Stores the paths to the runtime dependencies of EclipseJDTLS + 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. """ - gradle_path: str - lombok_jar_path: str jre_path: str jre_home_path: str jdtls_launcher_jar_path: str jdtls_readonly_config_path: str - intellicode_jar_path: str - intellisense_members_path: str + lombok_jar_path: str + gradle_path: str | None = None + intellicode_jar_path: str | None = None + intellisense_members_path: str | None = None class EclipseJDTLS(SolidLanguageServer): r""" The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class + 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. + + 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 + environments. Requires JDK 21+ available via ``java_home`` setting / ``JAVA_HOME`` env / PATH. + Maven projects work out of the box (m2e bundled in JDTLS uses Maven Embedder); Gradle projects + need ``./gradlew`` in the project or a system-installed Gradle (Buildship default discovery). + You can configure the following options in ls_specific_settings (in serena_config.yml): + Upstream JDTLS mode (mutually exclusive group — set both to activate): + - jdtls_path: Path to upstream JDTLS root (containing plugins/ and config_/). + Get via 'brew install jdtls' or extract jdt-language-server-*.tar.gz from + https://download.eclipse.org/jdtls/snapshots/. + - lombok_path: Path to lombok jar (e.g. ~/.m2/repository/org/projectlombok/lombok//lombok-.jar + or download from https://projectlombok.org/downloads/). + + Optional in upstream-jdtls mode: + - java_home: Path to JDK 21+ home directory. Falls back to JAVA_HOME env, then 'which java'. + + General settings (apply in both modes): - maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml) - gradle_user_home: Path to Gradle user home directory (default: ~/.gradle) - gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false) @@ -77,7 +122,16 @@ class EclipseJDTLS(SolidLanguageServer): - vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena - intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena - Example configuration in ~/.serena/serena_config.yml: + Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate): + ```yaml + ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" # or extracted tar.gz path + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional, JAVA_HOME env / PATH used otherwise + ``` + + Example configuration for default vscode-java VSIX mode (auto-download): ```yaml ls_specific_settings: java: @@ -154,7 +208,29 @@ def _setup_runtime_dependencies( ) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. + + Two modes are supported: + + * **Upstream JDTLS mode** (activated when both ``jdtls_path`` and ``lombok_path`` are set + in ``ls_specific_settings.java``): uses an existing JDTLS installation (e.g. via + ``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. """ + jdtls_path = custom_settings.get("jdtls_path") + lombok_path = custom_settings.get("lombok_path") + if jdtls_path or lombok_path: + # both must be set together to activate upstream mode + if not (jdtls_path and lombok_path): + raise SolidLSPException( + "Both 'jdtls_path' and 'lombok_path' must be set together in " + "ls_specific_settings.java to use the upstream JDTLS mode. " + "Set both, or remove both to use the default vscode-java VSIX mode." + ) + return EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_path), str(lombok_path), custom_settings) + platformId = PlatformUtils.get_platform_id() gradle_version = custom_settings.get("gradle_version", "8.14.2") vscode_java_version = custom_settings.get("vscode_java_version", "1.42.0-561") @@ -346,11 +422,235 @@ def _setup_runtime_dependencies( intellisense_members_path=intellisense_members_path, ) + @staticmethod + def _setup_from_existing_install( + jdtls_path: str, lombok_path: str, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> RuntimeDependencyPaths: + """ + Builds RuntimeDependencyPaths from an already-installed upstream JDTLS distribution + and the system-installed JDK. No downloads are performed. + + :param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_/``) + :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 + """ + # validate jdtls_path structure (root + plugins dir) + jdtls_root = Path(jdtls_path) + if not jdtls_root.is_dir(): + raise SolidLSPException( + f"Provided jdtls_path '{jdtls_path}' is not an existing directory.\n" + f"Fix: extract jdt-language-server-*.tar.gz from " + f"https://download.eclipse.org/jdtls/snapshots/ or run 'brew install jdtls', " + f"then set ls_specific_settings.java.jdtls_path to the extracted directory." + ) + plugins_dir = jdtls_root / "plugins" + if not plugins_dir.is_dir(): + raise SolidLSPException( + f"Invalid jdtls_path '{jdtls_path}': 'plugins/' directory not found.\n" + f"Expected upstream JDTLS layout (plugins/, config_/, features/) at the root. " + f"If you pointed at a vscode-java extension, use '/server' instead." + ) + + # resolve the main equinox launcher jar (excluding platform-specific native fragments) + launcher_jar = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins_dir) + + # resolve the platform-specific config directory under jdtls_root + config_dir = EclipseJDTLS.DependencyProvider._resolve_config_dir(jdtls_root) + + # validate lombok jar exists + if not Path(lombok_path).is_file(): + raise SolidLSPException( + f"Provided lombok_path '{lombok_path}' does not exist or is not a file.\n" + f"Fix: download lombok jar from https://projectlombok.org/downloads/ or use the one " + f"from your local Maven cache (~/.m2/repository/org/projectlombok/lombok//lombok-.jar)." + ) + + # resolve system JDK (priority: java_home setting -> JAVA_HOME env -> which java), + # interrogate the JVM for its real java.home and validate version >= 21 + jre_home_path, jre_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + log.info( + f"Using upstream JDTLS at '{jdtls_path}' with system JDK '{jre_home_path}'. " + f"Launcher: {launcher_jar.name}; config: {config_dir.name}; lombok: {lombok_path}" + ) + + return RuntimeDependencyPaths( + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=str(launcher_jar), + jdtls_readonly_config_path=str(config_dir), + lombok_jar_path=lombok_path, + gradle_path=None, + intellicode_jar_path=None, + intellisense_members_path=None, + ) + + @staticmethod + def _resolve_launcher_jar(plugins_dir: Path) -> Path: + """ + Locates the main Equinox launcher jar in JDTLS's ``plugins/`` directory, excluding + platform-specific native fragments like ``org.eclipse.equinox.launcher.cocoa.macosx.*``. + + :return: path to the main launcher jar (e.g. ``org.eclipse.equinox.launcher_1.7.0.v....jar``) + """ + # main launcher matches: org.eclipse.equinox.launcher_...jar (single underscore, + # immediately followed by version digits — fragments have additional dotted segments). + pattern = re.compile(r"^org\.eclipse\.equinox\.launcher_\d.*\.jar$") + matches = sorted(p for p in plugins_dir.glob("org.eclipse.equinox.launcher_*.jar") if pattern.match(p.name)) + if not matches: + raise SolidLSPException( + f"No main Equinox launcher jar found in '{plugins_dir}'. " + f"Expected file like 'org.eclipse.equinox.launcher_.jar'. " + f"Verify the JDTLS extraction is complete and not corrupted." + ) + # if multiple versions are present (rare), pick the highest by name + return matches[-1] + + @staticmethod + def _resolve_config_dir(jdtls_root: Path) -> Path: + """ + Locates the platform-specific OSGi configuration directory inside the JDTLS root. + + :return: path to ``config_/`` directory matching the current OS/arch + """ + platform_id = PlatformUtils.get_platform_id().value + config_dir_name = JDTLS_CONFIG_DIR_BY_PLATFORM.get(platform_id) + if config_dir_name is None: + raise SolidLSPException( + f"Unsupported platform '{platform_id}' for upstream JDTLS mode. " + f"Supported platforms: {sorted(set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()))}." + ) + config_dir = jdtls_root / config_dir_name + if not config_dir.is_dir(): + raise SolidLSPException( + f"Config directory '{config_dir}' not found. " + f"This JDTLS distribution does not support platform '{platform_id}'. " + f"Verify you downloaded the correct tar.gz for your OS/architecture." + ) + return config_dir + + @staticmethod + def _resolve_system_jdk(custom_settings: SolidLSPSettings.CustomLSSettings) -> tuple[str, str]: + """ + Resolves the system-installed JDK home and ``java`` executable, validates the version. + + The ``java`` executable is located by priority: ``java_home`` setting -> + ``JAVA_HOME`` env var -> ``java`` in PATH. The actual JDK home directory and + major version are then discovered by querying the JVM itself via + ``java -XshowSettings:properties -version`` — this is the single source of truth + and works correctly even when the locator is a system stub (e.g. ``/usr/bin/java`` + on macOS, which delegates to ``/usr/libexec/java_home`` under the hood and does not + resolve to the real JDK home via simple path traversal). + + :return: (jdk_home_directory, java_executable_path) + """ + # locate a java executable to interrogate + java_exe_name = "java.exe" if platform.system() == "Windows" else "java" + java_exe: str | None = None + source: str + + if explicit_home := custom_settings.get("java_home"): + candidate = str(Path(explicit_home) / "bin" / java_exe_name) + if not os.path.exists(candidate): + raise SolidLSPException( + f"java_home='{explicit_home}' is invalid: '{candidate}' does not exist. " + f"Set ls_specific_settings.java.java_home to a JDK home that contains bin/{java_exe_name}." + ) + java_exe = candidate + source = f"java_home setting ({explicit_home})" + elif env_home := os.environ.get("JAVA_HOME"): + candidate = str(Path(env_home) / "bin" / java_exe_name) + if os.path.exists(candidate): + java_exe = candidate + source = f"JAVA_HOME env ({env_home})" + else: + log.warning(f"JAVA_HOME='{env_home}' invalid (no '{candidate}'), falling back to PATH.") + + if java_exe is None: + java_in_path = shutil.which("java") + if java_in_path is None: + raise SolidLSPException( + "Could not locate a Java installation for JDTLS. " + "Set ls_specific_settings.java.java_home, set JAVA_HOME environment variable, " + f"or ensure 'java' is on PATH. Required: JDK {JDTLS_MIN_JDK_VERSION}+." + ) + java_exe = java_in_path + source = f"PATH ({java_in_path})" + + # interrogate the JVM for its real java.home and version (single source of truth) + real_jdk_home, major_version = EclipseJDTLS.DependencyProvider._inspect_java(java_exe) + + # validate version + if major_version < JDTLS_MIN_JDK_VERSION: + raise SolidLSPException( + f"JDTLS requires JDK {JDTLS_MIN_JDK_VERSION}+ but '{java_exe}' is JDK {major_version} " + f"(located via {source}, java.home={real_jdk_home}). " + f"Install a newer JDK and update ls_specific_settings.java.java_home or JAVA_HOME." + ) + log.info(f"Resolved JDK {major_version} via {source}; java.home={real_jdk_home}; java_exe={java_exe}.") + + # prefer to use bin/java from the *real* JDK home (so JDTLS subprocesses that read JAVA_HOME + # find a consistent layout); only fall back to the original locator if the real-home variant + # is missing for some reason. + real_java = str(Path(real_jdk_home) / "bin" / java_exe_name) + if os.path.exists(real_java): + java_exe = real_java + + return real_jdk_home, java_exe + + @staticmethod + def _inspect_java(java_exe: str) -> tuple[str, int]: + """ + Runs ``java -XshowSettings:properties -version`` and parses ``java.home`` and the + major version from the output. This is the most reliable cross-platform way to + discover the JDK home (works around macOS ``/usr/bin/java`` stub issue). + + :return: (java_home_directory_reported_by_jvm, major_version) + """ + try: + # both -XshowSettings:properties and -version write to stderr by convention + result = subprocess.run( + [java_exe, "-XshowSettings:properties", "-version"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SolidLSPException(f"Failed to run '{java_exe} -XshowSettings:properties -version': {exc}") from exc + + output = (result.stderr or "") + "\n" + (result.stdout or "") + + # parse java.home from " java.home = /path/to/jdk" + home_match = re.search(r"java\.home\s*=\s*(.+)", output) + if not home_match: + raise SolidLSPException( + f"Could not parse java.home from '{java_exe}' output. " + f"This usually means '{java_exe}' is not a valid JDK installation. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + real_home = home_match.group(1).strip() + + # parse major version from version line: 'openjdk version "21.0.2"' or 'java version "21.0.2"' + version_match = re.search(r'version "(\d+)(?:\.\d+)*"', output) + if not version_match: + raise SolidLSPException( + f"Could not parse Java version from '{java_exe}' output. " + f"Required: JDK {JDTLS_MIN_JDK_VERSION}+. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + major = int(version_match.group(1)) + return real_home, major + def create_launch_command(self) -> list[str]: # ws_dir is the workspace directory for the EclipseJDTLS server. - # Use a deterministic hash of the project path so the workspace - # (and its cached index) can be reused across restarts. - project_hash = hashlib.md5(self._repository_root_path.encode()).hexdigest() + # Use a deterministic hash of the project path *and* the JDTLS launcher path so the + # workspace (and its cached index) can be reused across restarts but not across + # JDTLS-version or installation-mode switches. Different launcher path → different ws_dir, + # which prevents stale OSGi configs from a previous JDTLS version blocking startup. + ws_hash_input = (self._repository_root_path + "|" + self.runtime_dependency_paths.jdtls_launcher_jar_path).encode() + project_hash = hashlib.md5(ws_hash_input).hexdigest() ws_dir = str( PurePath( self._solidlsp_settings.ls_resources_dir, @@ -820,8 +1120,15 @@ def _get_initialize_params(self, repository_absolute_path: str) -> InitializePar } initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore - bundles = [self.runtime_dependency_paths.intellicode_jar_path] - initialize_params["initializationOptions"]["bundles"] = bundles # type: ignore + + # 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] # type: ignore + else: + initialize_params["initializationOptions"]["bundles"] = [] # type: ignore + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} ] @@ -832,7 +1139,10 @@ def _get_initialize_params(self, repository_absolute_path: str) -> InitializePar assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # type: ignore - gradle_settings["home"] = self.runtime_dependency_paths.gradle_path + # In upstream-jdtls mode we don't ship a Gradle distribution — Buildship will use the project's + # ./gradlew wrapper or a system-installed Gradle via its standard discovery rules. + if self.runtime_dependency_paths.gradle_path is not None: + gradle_settings["home"] = self.runtime_dependency_paths.gradle_path gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} return cast(InitializeParams, initialize_params) @@ -899,17 +1209,22 @@ def do_nothing(params: dict) -> None: self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore - self._intellicode_enable_command_available.wait() + # 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 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 + 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 ...") From 28edc1e59a0b29f07f841ed8917b51c378af3513 Mon Sep 17 00:00:00 2001 From: Artem Simeshin Date: Sat, 25 Apr 2026 21:22:23 +0300 Subject: [PATCH 2/5] test(jdtls): unit tests for upstream JDTLS resolution helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the offline-mode helpers added in the previous commit without requiring a real JDTLS install or Java runtime — subprocess interactions and platform detection are mocked, JDTLS layouts are synthesised in tmp_path. 30 tests across: - _resolve_launcher_jar — picks main launcher, excludes native fragments, selects highest version, errors on empty plugins - _resolve_config_dir — parameterised by platform, errors on missing / unsupported - _inspect_java — parses Temurin 21 and OpenJDK 17 outputs, errors on missing java.home / version / subprocess failure - _resolve_system_jdk — priority chain (java_home setting > JAVA_HOME > which java), version validation, the macOS /usr/bin/java stub case where the JVM reports a different real java.home - _setup_from_existing_install — happy path (gradle/intellicode None as expected) and validation errors - _setup_runtime_dependencies switch — both/one/neither path-pair set Refs: oraios/serena#1414 --- .../java/test_jdtls_path_resolution.py | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 test/solidlsp/java/test_jdtls_path_resolution.py diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py new file mode 100644 index 000000000..be419a114 --- /dev/null +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -0,0 +1,379 @@ +""" +Unit tests for the offline (upstream) JDTLS resolution helpers in +``solidlsp.language_servers.eclipse_jdtls``. + +These tests cover only the path/version/validation logic; they do not start +JDTLS and do not require Java to be installed. Subprocess interactions are +mocked. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solidlsp.language_servers.eclipse_jdtls import ( + JDTLS_CONFIG_DIR_BY_PLATFORM, + JDTLS_MIN_JDK_VERSION, + EclipseJDTLS, +) +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.settings import SolidLSPSettings + + +@pytest.fixture +def custom_settings() -> SolidLSPSettings.CustomLSSettings: + """Empty CustomLSSettings instance for tests that don't need any keys set.""" + return SolidLSPSettings.CustomLSSettings({}) + + +def _make_fake_jdtls_install( + root: Path, *, with_launcher: bool = True, with_native_fragments: bool = True, with_configs: bool = True +) -> Path: + """ + Builds a minimal fake upstream JDTLS layout under ``root``: ``plugins/`` with + a main equinox launcher jar (and optionally native fragments) and + ``config_/`` directories. Returns ``root``. + """ + plugins = root / "plugins" + plugins.mkdir(parents=True, exist_ok=True) + if with_launcher: + (plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar").touch() + if with_native_fragments: + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + if with_configs: + for config_name in set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()): + (root / config_name).mkdir(exist_ok=True) + return root + + +# ---------------------------------------------------------------------------- +# _resolve_launcher_jar +# ---------------------------------------------------------------------------- + + +class TestResolveLauncherJar: + def test_picks_main_launcher_excluding_native_fragments(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + main_jar = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + main_jar.touch() + # native fragments should NOT be picked + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == main_jar + + def test_picks_highest_version_when_multiple_main_launchers(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + (plugins / "org.eclipse.equinox.launcher_1.6.500.v20240916-1115.jar").touch() + newer = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + newer.touch() + (plugins / "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == newer, "Expected the lexicographically highest launcher version to be selected" + + def test_raises_when_no_launcher_present(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + # only native fragments, no main launcher + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + + with pytest.raises(SolidLSPException, match="No main Equinox launcher jar found"): + EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + + +# ---------------------------------------------------------------------------- +# _resolve_config_dir +# ---------------------------------------------------------------------------- + + +class TestResolveConfigDir: + @pytest.mark.parametrize( + "platform_id,expected_dir", + [ + ("osx-arm64", "config_mac_arm"), + ("darwin-arm64", "config_mac_arm"), + ("osx-x64", "config_mac"), + ("linux-arm64", "config_linux_arm"), + ("linux-x64", "config_linux"), + ("win-x64", "config_win"), + ], + ) + def test_maps_platform_to_correct_config_dir(self, tmp_path: Path, platform_id: str, expected_dir: str) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = platform_id + result = EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + assert result.name == expected_dir + assert result.is_dir() + + def test_raises_when_config_dir_missing(self, tmp_path: Path) -> None: + # plugins/ exists but no config_/ for current OS + (tmp_path / "plugins").mkdir() + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "linux-x64" + with pytest.raises(SolidLSPException, match="Config directory .* not found"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + def test_raises_for_unsupported_platform(self, tmp_path: Path) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "freebsd-riscv64" + with pytest.raises(SolidLSPException, match="Unsupported platform"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + +# ---------------------------------------------------------------------------- +# _inspect_java +# ---------------------------------------------------------------------------- + + +class TestInspectJava: + @staticmethod + def _fake_subprocess_result(stderr: str, stdout: str = "", returncode: int = 0): + """Build a minimal CompletedProcess-like object.""" + + class _Result: + def __init__(self) -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + return _Result() + + def test_parses_temurin_21_output(self) -> None: + # Real Temurin 21.0.10 output (java -XshowSettings:properties -version writes to stderr) + stderr = ( + "Property settings:\n" + " java.home = /Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home\n" + " java.version = 21.0.10\n" + 'openjdk version "21.0.10" 2026-01-20 LTS\n' + "OpenJDK Runtime Environment Temurin-21.0.10+7 (build 21.0.10+7-LTS)\n" + ) + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home" + assert major == 21 + + def test_parses_openjdk_17_output(self) -> None: + stderr = 'Property settings:\n java.home = /usr/lib/jvm/java-17-openjdk-amd64\nopenjdk version "17.0.5" 2023-10-17\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/usr/lib/jvm/java-17-openjdk-amd64" + assert major == 17 + + def test_raises_when_java_home_property_missing(self) -> None: + stderr = 'java version "21.0.0"\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse java.home"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_when_version_string_missing(self) -> None: + stderr = " java.home = /opt/jdk\n" + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse Java version"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_on_subprocess_error(self) -> None: + with patch("subprocess.run", side_effect=OSError("permission denied")): + with pytest.raises(SolidLSPException, match="Failed to run"): + EclipseJDTLS.DependencyProvider._inspect_java("/nonexistent/java") + + +# ---------------------------------------------------------------------------- +# _resolve_system_jdk +# ---------------------------------------------------------------------------- + + +class TestResolveSystemJdk: + """Verifies the priority chain: java_home setting > JAVA_HOME env > PATH.""" + + @staticmethod + def _make_jdk_layout(root: Path, java_exe_name: str = "java") -> Path: + bin_dir = root / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / java_exe_name).touch() + return root + + def _patch_inspect_java(self, real_home: str, major: int): + return patch.object(EclipseJDTLS.DependencyProvider, "_inspect_java", return_value=(real_home, major)) + + def test_uses_explicit_java_home_setting(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + explicit_jdk = self._make_jdk_layout(tmp_path / "explicit-jdk") + monkeypatch.delenv("JAVA_HOME", raising=False) + + with self._patch_inspect_java(str(explicit_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(explicit_jdk)}) + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == explicit_jdk + assert Path(java_path).name in {"java", "java.exe"} + + def test_falls_back_to_java_home_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + env_jdk = self._make_jdk_layout(tmp_path / "env-jdk") + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + with self._patch_inspect_java(str(env_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({}) + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == env_jdk + + def test_falls_back_to_which_java( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + path_jdk = self._make_jdk_layout(tmp_path / "path-jdk") + java_path = path_jdk / "bin" / "java" + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(java_path)): + with self._patch_inspect_java(str(path_jdk), 21): + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + assert Path(home) == path_jdk + + def test_raises_when_no_java_anywhere( + self, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + monkeypatch.delenv("JAVA_HOME", raising=False) + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=None): + with pytest.raises(SolidLSPException, match="Could not locate a Java installation"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + def test_raises_for_invalid_explicit_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # path exists but no bin/java + broken = tmp_path / "broken-jdk" + broken.mkdir() + monkeypatch.delenv("JAVA_HOME", raising=False) + + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(broken)}) + with pytest.raises(SolidLSPException, match=r"java_home=.*invalid"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_raises_for_too_old_jdk(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_jdk = self._make_jdk_layout(tmp_path / "jdk-17") + monkeypatch.setenv("JAVA_HOME", str(old_jdk)) + + with self._patch_inspect_java(str(old_jdk), 17): + settings = SolidLSPSettings.CustomLSSettings({}) + with pytest.raises(SolidLSPException, match=f"requires JDK {JDTLS_MIN_JDK_VERSION}"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_uses_real_jdk_home_when_locator_is_macos_stub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + """ + Simulates the macOS /usr/bin/java stub: the locator points to /usr/bin/java but the JVM's + java.home is the actual JDK. The resolver should report the real JDK home. + """ + real_jdk = self._make_jdk_layout(tmp_path / "real-jdk-21") + macos_stub = tmp_path / "fake-usr" / "bin" / "java" + macos_stub.parent.mkdir(parents=True) + macos_stub.touch() + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(macos_stub)): + with self._patch_inspect_java(str(real_jdk), 21): + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + # the resolver must trust the JVM's reported java.home, not parent.parent of the stub + assert Path(home) == real_jdk + # and prefer the real-home java executable over the stub + assert Path(java_path) == real_jdk / "bin" / "java" + + +# ---------------------------------------------------------------------------- +# _setup_from_existing_install +# ---------------------------------------------------------------------------- + + +class TestSetupFromExistingInstall: + @pytest.fixture + def lombok_jar(self, tmp_path: Path) -> Path: + jar = tmp_path / "lombok-1.18.44.jar" + jar.touch() + return jar + + @pytest.fixture + def jdtls_root(self, tmp_path: Path) -> Path: + return _make_fake_jdtls_install(tmp_path / "jdtls") + + def _fake_jdk(self, tmp_path: Path) -> Path: + jdk = tmp_path / "jdk-21" + (jdk / "bin").mkdir(parents=True) + (jdk / "bin" / "java").touch() + return jdk + + def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode( + self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + jdk = self._fake_jdk(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_pid: + mock_pid.return_value.value = "linux-x64" + with patch.object(EclipseJDTLS.DependencyProvider, "_resolve_system_jdk", return_value=(str(jdk), str(jdk / "bin" / "java"))): + 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_") + assert Path(result.jdtls_readonly_config_path).name == "config_linux" + + def test_raises_for_nonexistent_jdtls_path( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="not an existing directory"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(tmp_path / "does-not-exist"), str(lombok_jar), custom_settings) + + def test_raises_when_plugins_dir_missing( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + empty_root = tmp_path / "no-plugins" + empty_root.mkdir() + with pytest.raises(SolidLSPException, match="'plugins/' directory not found"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(empty_root), str(lombok_jar), custom_settings) + + def test_raises_when_lombok_jar_missing( + self, jdtls_root: Path, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="lombok_path .* does not exist"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install( + str(jdtls_root), str(tmp_path / "no-such-lombok.jar"), custom_settings + ) + + +# ---------------------------------------------------------------------------- +# _setup_runtime_dependencies (mode-switch logic) +# ---------------------------------------------------------------------------- + + +class TestSetupRuntimeDependenciesModeSwitch: + """Verifies the activation trigger: both jdtls_path and lombok_path => upstream mode.""" + + def test_both_set_invokes_upstream_mode(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x", "lombok_path": "/y"}) + with patch.object(EclipseJDTLS.DependencyProvider, "_setup_from_existing_install", return_value="upstream-result") as mock_upstream: + result = EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + mock_upstream.assert_called_once_with("/x", "/y", settings) + assert result == "upstream-result" + + def test_only_jdtls_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + def test_only_lombok_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"lombok_path": "/y"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) From 9aa799c5091e89df4cee916274b3062e18574ac8 Mon Sep 17 00:00:00 2001 From: Artem Simeshin Date: Sun, 26 Apr 2026 11:39:43 +0300 Subject: [PATCH 3/5] test(jdtls): make path resolution tests platform-aware for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows resolver looks for bin/java.exe, but the helper created bin/java unconditionally — two tests failed on windows-latest. Switch the helper and assertions to a _JAVA_EXE_NAME constant derived from platform.system(). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/solidlsp/java/test_jdtls_path_resolution.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index be419a114..352f91a8a 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -9,11 +9,14 @@ from __future__ import annotations +import platform from pathlib import Path from unittest.mock import patch import pytest +_JAVA_EXE_NAME = "java.exe" if platform.system() == "Windows" else "java" + from solidlsp.language_servers.eclipse_jdtls import ( JDTLS_CONFIG_DIR_BY_PLATFORM, JDTLS_MIN_JDK_VERSION, @@ -198,7 +201,7 @@ class TestResolveSystemJdk: """Verifies the priority chain: java_home setting > JAVA_HOME env > PATH.""" @staticmethod - def _make_jdk_layout(root: Path, java_exe_name: str = "java") -> Path: + def _make_jdk_layout(root: Path, java_exe_name: str = _JAVA_EXE_NAME) -> Path: bin_dir = root / "bin" bin_dir.mkdir(parents=True, exist_ok=True) (bin_dir / java_exe_name).touch() @@ -216,7 +219,7 @@ def test_uses_explicit_java_home_setting(self, tmp_path: Path, monkeypatch: pyte home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) assert Path(home) == explicit_jdk - assert Path(java_path).name in {"java", "java.exe"} + assert Path(java_path).name == _JAVA_EXE_NAME def test_falls_back_to_java_home_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: env_jdk = self._make_jdk_layout(tmp_path / "env-jdk") @@ -232,7 +235,7 @@ def test_falls_back_to_which_java( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings ) -> None: path_jdk = self._make_jdk_layout(tmp_path / "path-jdk") - java_path = path_jdk / "bin" / "java" + java_path = path_jdk / "bin" / _JAVA_EXE_NAME monkeypatch.delenv("JAVA_HOME", raising=False) with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(java_path)): @@ -276,7 +279,7 @@ def test_uses_real_jdk_home_when_locator_is_macos_stub( java.home is the actual JDK. The resolver should report the real JDK home. """ real_jdk = self._make_jdk_layout(tmp_path / "real-jdk-21") - macos_stub = tmp_path / "fake-usr" / "bin" / "java" + macos_stub = tmp_path / "fake-usr" / "bin" / _JAVA_EXE_NAME macos_stub.parent.mkdir(parents=True) macos_stub.touch() monkeypatch.delenv("JAVA_HOME", raising=False) @@ -288,7 +291,7 @@ def test_uses_real_jdk_home_when_locator_is_macos_stub( # the resolver must trust the JVM's reported java.home, not parent.parent of the stub assert Path(home) == real_jdk # and prefer the real-home java executable over the stub - assert Path(java_path) == real_jdk / "bin" / "java" + assert Path(java_path) == real_jdk / "bin" / _JAVA_EXE_NAME # ---------------------------------------------------------------------------- From 6107da04350b63fba5369357a2ef24f826ba52ec Mon Sep 17 00:00:00 2001 From: Artem Simeshin Date: Sun, 26 Apr 2026 20:04:10 +0300 Subject: [PATCH 4/5] docs(jdtls): document upstream JDTLS mode and add changelog entry - CHANGELOG.md: add entry under "Language Servers" describing the new upstream JDTLS mode (jdtls_path/lombok_path/java_home settings). - docs/02-usage/050_configuration.md (Java section): - Add "When to use which mode" decision helper before the settings table, listing concrete reasons to pick the upstream mode (restricted networks, on-disk footprint, existing JDTLS install, security policy). - Promote the JDK 21+ requirement to a dedicated paragraph and document the exact JDK resolution order (java_home setting -> JAVA_HOME env -> first java on PATH). - Note that vscode-java-only settings (gradle_version, vscode_java_version, intellicode_*) are silently ignored when upstream mode is active, so users don't expect them to take effect. --- CHANGELOG.md | 1 + docs/02-usage/050_configuration.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea6203e5..68b1eaa4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 + - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged. #1415 Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index a8abf440f..d0b4c3428 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -513,6 +513,21 @@ Java support has two installation modes: Uses an existing JDTLS installation (~100 MB) and the system JDK 21+. Nothing is downloaded. Recommended for restricted-network/corporate environments. +**When to use which mode:** + +- **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 already maintain a JDTLS installation (e.g. for `nvim-jdtls` or another editor); + - your security policy prohibits per-project runtime downloads. + +**JDK 21+ is required** in upstream mode. Serena resolves the JDK in this order: +`ls_specific_settings.java.java_home` → `JAVA_HOME` env var → first `java` on `PATH`. +The resolved JVM is interrogated and rejected if its `java.specification.version` is below 21. + The following settings are supported for the Java language server: | Setting | Default | Description | @@ -540,6 +555,9 @@ Notes: symbol-tools workflow), and 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. Example: upstream-jdtls mode (offline / corporate network): From 01d52a849053f7dd5a7bd52831aac53b6777684a Mon Sep 17 00:00:00 2001 From: Artem Simeshin Date: Mon, 27 Apr 2026 16:31:30 +0300 Subject: [PATCH 5/5] fix(jdtls): preserve workspace hash for default-route users The previous change unconditionally mixed the JDTLS launcher jar path into the workspace md5, which would have invalidated every existing JDTLS workspace on Serena upgrade and forced a one-time cold reindex for users who never opted into upstream-JDTLS mode. Make the launcher path part of the hash only when `jdtls_path` is explicitly set, so default-route users get the exact pre-upstream format (`md5(repository_root_path)`) and reuse their caches as before. Upstream mode keeps the launcher path in the hash to isolate it from the default workspace and from other upstream installations. Extract the hash computation into a small static helper and add unit tests covering both branches, the backwards-compatibility contract, and default/upstream isolation. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- .../language_servers/eclipse_jdtls.py | 32 ++++++++-- .../java/test_jdtls_path_resolution.py | 61 +++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b1eaa4f..73693b08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 - - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged. #1415 + - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 9332a28f1..eb6fbf02a 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -643,14 +643,34 @@ def _inspect_java(java_exe: str) -> tuple[str, int]: major = int(version_match.group(1)) return real_home, major + @staticmethod + def _compute_workspace_hash( + repository_root_path: str, + jdtls_launcher_jar_path: str, + custom_settings: SolidLSPSettings.CustomLSSettings, + ) -> str: + """ + Compute the JDTLS workspace directory name. + + Default mode hashes the project path only — preserves backwards compatibility with + workspaces created before upstream-JDTLS support. Upstream mode (when ``jdtls_path`` + is set) mixes in the launcher path so switching between default and upstream + installations (or between different upstream JDTLS versions) lands in a separate + ws_dir and avoids stale OSGi configs blocking startup. + """ + if custom_settings.get("jdtls_path"): + ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path).encode() + else: + ws_hash_input = repository_root_path.encode() + return hashlib.md5(ws_hash_input).hexdigest() + def create_launch_command(self) -> list[str]: # ws_dir is the workspace directory for the EclipseJDTLS server. - # Use a deterministic hash of the project path *and* the JDTLS launcher path so the - # workspace (and its cached index) can be reused across restarts but not across - # JDTLS-version or installation-mode switches. Different launcher path → different ws_dir, - # which prevents stale OSGi configs from a previous JDTLS version blocking startup. - ws_hash_input = (self._repository_root_path + "|" + self.runtime_dependency_paths.jdtls_launcher_jar_path).encode() - project_hash = hashlib.md5(ws_hash_input).hexdigest() + project_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self._repository_root_path, + self.runtime_dependency_paths.jdtls_launcher_jar_path, + self._custom_settings, + ) ws_dir = str( PurePath( self._solidlsp_settings.ls_resources_dir, diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 352f91a8a..2ce735d05 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -380,3 +380,64 @@ def test_only_lombok_path_set_raises(self) -> None: settings = SolidLSPSettings.CustomLSSettings({"lombok_path": "/y"}) with pytest.raises(SolidLSPException, match="must be set together"): EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + +# ---------------------------------------------------------------------------- +# _compute_workspace_hash +# ---------------------------------------------------------------------------- + + +class TestComputeWorkspaceHash: + """ + Backwards-compatibility contract: users on the default route (no ``jdtls_path``) + must receive the *exact* same hash format that existed before upstream-JDTLS support + (i.e. ``md5(repository_root_path)``), so existing JDTLS workspaces and project caches + are reused without a one-time reindex after upgrading Serena. Upstream mode mixes the + launcher path into the hash to isolate it from the default workspace. + """ + + REPO = "/home/me/projects/widgets" + DEFAULT_LAUNCHER = "/srv/serena/static/eclipse-jdtls-1.49.0/plugins/org.eclipse.equinox.launcher_1.7.100.jar" + UPSTREAM_LAUNCHER = "/opt/homebrew/Cellar/jdtls/1.50.0/libexec/plugins/org.eclipse.equinox.launcher_1.7.0.jar" + + def test_default_mode_matches_pre_upstream_format(self) -> None: + """The hash MUST equal md5(repository_root_path) — the format produced by PR #1214.""" + import hashlib + + expected = hashlib.md5(self.REPO.encode()).hexdigest() + result = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, self.DEFAULT_LAUNCHER, SolidLSPSettings.CustomLSSettings({}) + ) + assert result == expected + + def test_default_mode_ignores_launcher_path(self) -> None: + """Default-mode hash must not depend on the launcher jar path (so default users keep cache).""" + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, empty_settings) + assert h1 == h2 + + def test_upstream_mode_includes_launcher_path(self) -> None: + """When jdtls_path is set, different launcher paths must produce different hashes.""" + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, settings) + assert h1 != h2 + + def test_upstream_and_default_produce_different_hashes(self) -> None: + """Same repo + same launcher path but different mode → different ws_dir (isolation).""" + default_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, self.UPSTREAM_LAUNCHER, SolidLSPSettings.CustomLSSettings({}) + ) + upstream_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, + self.UPSTREAM_LAUNCHER, + SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}), + ) + assert default_h != upstream_h + + def test_different_repo_paths_produce_different_hashes(self) -> None: + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/a/repo", self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/b/repo", self.DEFAULT_LAUNCHER, empty_settings) + assert h1 != h2