Merge pull request #1415 from a-simeshin/feat/jdtls-path-override

Allows setting paths for JDTLS and Lombok, thus allowing using Serena with JDTLS without forcing downloads
This commit is contained in:
a-simeshin authored and GitHub committed 2026-04-28 09:31:53 +02:00
1 parent 3a8393bcaf
commit a3565ef60e
4 files changed
+853 -30

No files matched your search

+1
View File
@@ -57,6 +57,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`):
+51 -7
View File
@@ -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_<platform>/`). Get via `brew install jdtls` or extract `jdt-language-server-*.tar.gz` from <https://download.eclipse.org/jdtls/snapshots/>. 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/<ver>/lombok-<ver>.jar` or download from <https://projectlombok.org/downloads/>. |
| `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 for a project with custom Gradle plugins and JDK requirements:
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: default vscode-java VSIX mode for a project with custom Gradle plugins:
```yaml
ls_specific_settings:
+358 -23
View File
@@ -7,9 +7,12 @@ import hashlib
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 overrides import override
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_ALLOWED_HOSTS = (
)
INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251"
# Mapping from Serena's platform identifiers to upstream JDTLS config_<platform> 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_<platform>/).
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/<ver>/lombok-<ver>.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 @@ class EclipseJDTLS(SolidLanguageServer):
) -> 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 @@ class EclipseJDTLS(SolidLanguageServer):
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_<platform>/``)
: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_<platform>/, features/) at the root. "
f"If you pointed at a vscode-java extension, use '<extension>/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/<version>/lombok-<version>.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_<digit>...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_<version>.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_<platform>/`` 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 @@ class EclipseJDTLS(SolidLanguageServer):
}
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 @@ class EclipseJDTLS(SolidLanguageServer):
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 @@ class EclipseJDTLS(SolidLanguageServer):
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 ...")
@@ -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_<platform>/`` 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_<platform>/ 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