diff --git a/CHANGELOG.md b/CHANGELOG.md index aea6203e5..73693b08d 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 — 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/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 37fb55c0e..d0b4c3428 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -504,28 +504,72 @@ 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. + +**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 | |---|---|---| +| `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. +- 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): + +```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..eb6fbf02a 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,255 @@ 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 + + @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 so the workspace - # (and its cached index) can be reused across restarts. - project_hash = hashlib.md5(self._repository_root_path.encode()).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, @@ -820,8 +1140,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 +1159,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 +1229,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 ...") 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..2ce735d05 --- /dev/null +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -0,0 +1,443 @@ +""" +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 + +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, + 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_EXE_NAME) -> 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 == _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") + 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_EXE_NAME + 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_EXE_NAME + 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_EXE_NAME + + +# ---------------------------------------------------------------------------- +# _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) + + +# ---------------------------------------------------------------------------- +# _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