mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-04 14:13:24 +00:00
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:
1 parent
3a8393bcaf
commit
a3565ef60e
4 files changed
+853
-30
No files matched your search
@@ -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`):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user