diff --git a/CHANGELOG.md b/CHANGELOG.md index 312d9de5..316009cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Add `python_basedpyright` as an alternative Python language server - Java/JDTLS: stop downloading and loading the unused IntelliCode completion-ranking bundle; the retired `intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821 + - Kotlin: update the managed Kotlin LSP from `261.13587.0` to `262.9593.0`, including support for the + new platform-specific archive layout and Windows ARM64 builds - Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737 - Fix: Nix/nixd diagnostics now use published diagnostics instead of the unsupported `textDocument/diagnostic` request, which terminated nixd #1802 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 84facd85..659f73b8 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -759,15 +759,20 @@ Supported settings: | Setting | Default | Description | |---|---|---| | `ls_path` | managed download | Override the Kotlin Language Server executable path. | -| `kotlin_lsp_version` | `261.13587.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. | +| `kotlin_lsp_version` | `262.9593.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. | | `jvm_options` | `-Xmx2G` | Value assigned to `JAVA_TOOL_OPTIONS` for the Kotlin LS process. Set to `""` to disable JVM options entirely. | +The managed `262.9593.0` packages include a bundled JBR. For a custom `ls_path`, point directly to +`bin/intellij-server` (`bin/intellij-server.exe` on Windows). Serena also retains the legacy download +layout for custom Kotlin LSP versions older than `262.4739.0`. The pinned current and frozen initial +releases are checksum-verified; arbitrary custom versions are downloaded without checksum verification. + Example: ```yaml ls_specific_settings: kotlin: - kotlin_lsp_version: "261.13587.0" + kotlin_lsp_version: "262.9593.0" jvm_options: "-Xmx4G -XX:+UseG1GC" ``` diff --git a/scripts/update_downloaded_dependency_hashes.py b/scripts/update_downloaded_dependency_hashes.py index b82eab1d..02e56bda 100644 --- a/scripts/update_downloaded_dependency_hashes.py +++ b/scripts/update_downloaded_dependency_hashes.py @@ -1,7 +1,9 @@ from sensai.util import logging from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS +from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer if __name__ == "__main__": logging.configure() EclipseJDTLS.DependencyProvider.update_dep_hashes() + KotlinLanguageServer.DependencyProvider.update_dep_hashes() diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index 940758f6..df51dfd1 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -5,8 +5,8 @@ You can configure the following options in ls_specific_settings (in serena_confi ls_specific_settings: kotlin: - ls_path: '/path/to/kotlin-lsp.sh' # Custom path to Kotlin Language Server executable - kotlin_lsp_version: '261.13587.0' # Kotlin Language Server version (default: current bundled version) + ls_path: '/path/to/bin/intellij-server' # Custom path to Kotlin Language Server executable + kotlin_lsp_version: '262.9593.0' # Kotlin Language Server version (default: current bundled version) jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G) Example configuration for large projects: @@ -21,16 +21,18 @@ import os import pathlib import stat import threading +from dataclasses import dataclass from overrides import override +from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase from solidlsp.ls import ( LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -44,34 +46,21 @@ KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",) # Version pinning convention (see eclipse_jdtls.py for the full spec): # INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. # DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +# NOTE: After changing either pinned version, run scripts/update_downloaded_dependency_hashes.py. INITIAL_KOTLIN_LSP_VERSION = "261.13587.0" -INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = { - "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", - "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", - "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", - "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", - "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", -} -DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" -DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = { - "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", - "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", - "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", - "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", - "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", -} +DEFAULT_KOTLIN_LSP_VERSION = "262.9593.0" +# Versions before this one use kotlin-lsp-{version}-{platform}.zip and a kotlin-lsp script. +# Starting with 262.4739.0, JetBrains publishes kotlin-server archives with platform-specific +# formats, a bundled JBR, and bin/intellij-server as the launcher. +KOTLIN_SERVER_PACKAGING_MIN_VERSION = (262, 4739, 0) -def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None: - if version == INITIAL_KOTLIN_LSP_VERSION: - return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) - if version == DEFAULT_KOTLIN_LSP_VERSION: - return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) - return None +# The first modern archives remained under the legacy /kotlin-lsp CDN path. +# Starting with 262.8190.0, JetBrains moved them to /language-server/kotlin-server. +KOTLIN_SERVER_CDN_PATH_MIN_VERSION = (262, 8190, 0) - -# Platform-specific Kotlin LSP download suffixes -PLATFORM_KOTLIN_SUFFIX = { +# Platform-specific suffixes used by legacy Kotlin LSP ZIP archives. +LEGACY_PLATFORM_KOTLIN_SUFFIX = { "win-x64": "win-x64", "linux-x64": "linux-x64", "linux-arm64": "linux-aarch64", @@ -79,6 +68,22 @@ PLATFORM_KOTLIN_SUFFIX = { "osx-arm64": "mac-aarch64", } +# Modern archive filename suffix, FileUtils archive type, and launcher path within the archive. +KOTLIN_SERVER_ARTIFACT_BY_PLATFORM: dict[str, tuple[str, FileUtils.ArchiveType, tuple[str, ...]]] = { + "win-x64": (".win.zip", "zip", ("bin", "intellij-server.exe")), + "win-arm64": ("-aarch64.win.zip", "zip", ("bin", "intellij-server.exe")), + "linux-x64": (".tar.gz", "gztar", ("kotlin-server-{version}", "bin", "intellij-server")), + "linux-arm64": ("-aarch64.tar.gz", "gztar", ("kotlin-server-{version}", "bin", "intellij-server")), + "osx-x64": (".sit", "zip", ("kotlin-server-{version}", "bin", "intellij-server")), + "osx-arm64": ("-aarch64.sit", "zip", ("kotlin-server-{version}", "bin", "intellij-server")), +} + + +@dataclass(frozen=True) +class KotlinLSPArtifact: + dependency: DownloadedDependency + launcher_parts: tuple[str, ...] + class KotlinLanguageServer(SolidLanguageServer): """ @@ -102,15 +107,78 @@ class KotlinLanguageServer(SolidLanguageServer): # set again once all progress tokens have ended. self._indexing_complete = threading.Event() self._indexing_complete.set() + self._intellij_server_ready = threading.Event() self._active_progress_tokens: set[str] = set() self._progress_lock = threading.Lock() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, str(self.cache_dir)) class DependencyProvider(LanguageServerDependencyProviderSinglePath): - def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, project_cache_dir: str): super().__init__(custom_settings, ls_resources_dir) + self._project_cache_dir = project_cache_dir + + @classmethod + def _create_artifact(cls, version: str, platform_id: PlatformId) -> KotlinLSPArtifact: + """Build download and launcher metadata for one Kotlin LSP release. + + JetBrains has three publishing layouts: legacy ZIPs before 262.4739.0, + modern platform archives under ``/kotlin-lsp`` through 262.7569.0, + and modern archives under ``/language-server/kotlin-server`` from + 262.8190.0 onward. Serena verifies the frozen initial and current default + releases; arbitrary user-selected versions are unverified by design. + """ + try: + version_parts = tuple(int(part) for part in version.split(".")) + except ValueError as exc: + raise ValueError(f"Kotlin LSP version must contain only dot-separated integers: {version!r}") from exc + + verified = version in {INITIAL_KOTLIN_LSP_VERSION, DEFAULT_KOTLIN_LSP_VERSION} + if version_parts >= KOTLIN_SERVER_PACKAGING_MIN_VERSION: + artifact_config = KOTLIN_SERVER_ARTIFACT_BY_PLATFORM.get(platform_id.value) + if artifact_config is None: + raise ValueError(f"Unsupported platform for Kotlin LSP {version}: {platform_id.value}") + + asset_suffix, archive_type, launcher_parts = artifact_config + asset_name = f"kotlin-server-{version}{asset_suffix}" + cdn_path = "language-server/kotlin-server" if version_parts >= KOTLIN_SERVER_CDN_PATH_MIN_VERSION else "kotlin-lsp" + return KotlinLSPArtifact( + dependency=DownloadedDependency( + url=f"https://download-cdn.jetbrains.com/{cdn_path}/{version}/{asset_name}", + archive_type=archive_type, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + verified=verified, + ), + launcher_parts=tuple(part.format(version=version) for part in launcher_parts), + ) + + kotlin_suffix = LEGACY_PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) + if kotlin_suffix is None: + raise ValueError(f"Unsupported platform for Kotlin LSP {version}: {platform_id.value}") + + return KotlinLSPArtifact( + dependency=DownloadedDependency( + url=f"https://download-cdn.jetbrains.com/kotlin-lsp/{version}/kotlin-lsp-{version}-{kotlin_suffix}.zip", + archive_type="zip", + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + verified=verified, + ), + launcher_parts=("kotlin-lsp.cmd",) if platform_id.is_windows() else ("kotlin-lsp.sh",), + ) + + @classmethod + def update_dep_hashes(cls) -> None: + pinned_artifacts = [ + *(cls._create_artifact(INITIAL_KOTLIN_LSP_VERSION, PlatformId(platform)) for platform in LEGACY_PLATFORM_KOTLIN_SUFFIX), + *( + cls._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, PlatformId(platform)) + for platform in KOTLIN_SERVER_ARTIFACT_BY_PLATFORM + ), + ] + with DownloadedDependencyHashDatabase.get_instance().update_context() as database: + for artifact in pinned_artifacts: + database.update(artifact.dependency) def _get_or_install_core_dependency(self) -> str: """ @@ -118,16 +186,9 @@ class KotlinLanguageServer(SolidLanguageServer): """ platform_id = PlatformUtils.get_platform_id() - # Verify platform support - assert platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-"), ( - "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" - ) - - kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) - assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}" - # Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) + artifact = self._create_artifact(kotlin_lsp_version, platform_id) ls_dirname = ( "kotlin_language_server" if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION @@ -137,22 +198,13 @@ class KotlinLanguageServer(SolidLanguageServer): os.makedirs(static_dir, exist_ok=True) # Setup Kotlin Language Server - kotlin_script_name = "kotlin-lsp.cmd" if platform_id.value.startswith("win-") else "kotlin-lsp.sh" - kotlin_script = os.path.join(static_dir, kotlin_script_name) + kotlin_script = os.path.join(static_dir, *artifact.launcher_parts) if not os.path.exists(kotlin_script): - kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip" - expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix) log.info("Downloading Kotlin Language Server...") - FileUtils.download_and_extract_archive_verified( - kotlin_url, - static_dir, - "zip", - expected_sha256=expected_sha256, - allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, - ) + artifact.dependency.download_to(static_dir) - if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): + if os.path.exists(kotlin_script) and not platform_id.is_windows(): os.chmod( kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, @@ -165,10 +217,18 @@ class KotlinLanguageServer(SolidLanguageServer): return kotlin_script def _create_launch_command(self, core_path: str) -> list[str]: - return [core_path, "--stdio"] + command = [core_path, "--stdio"] + # A custom ls_path is independent of Serena's managed version. Select + # arguments from the actual launcher so existing kotlin-lsp.sh/.cmd + # configurations do not inherit IntelliJ-server-only options. + platform_id = PlatformUtils.get_platform_id() + intellij_launcher_name = "intellij-server.exe" if platform_id.is_windows() else "intellij-server" + if os.path.basename(core_path).lower() == intellij_launcher_name: + command.extend(["--system-path", os.path.join(self._project_cache_dir, "kotlin-lsp-system")]) + return command def create_launch_command_env(self) -> dict[str, str]: - """Provides JAVA_HOME and JVM options for the Kotlin Language Server process.""" + """Provides JVM options for the Kotlin Language Server process.""" env: dict[str, str] = {} # Get JVM options from settings or use default @@ -427,6 +487,7 @@ class KotlinLanguageServer(SolidLanguageServer): """ Starts the Kotlin Language Server """ + self._intellij_server_ready.clear() def execute_client_command_handler(params: dict) -> list: return [] @@ -437,6 +498,11 @@ class KotlinLanguageServer(SolidLanguageServer): def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") + def intellij_server_ready(_params: dict | None) -> None: + """Mark the modern IntelliJ-based server ready after its startup/import phase.""" + log.info("Kotlin IntelliJ language server reported that it is ready") + self._intellij_server_ready.set() + def work_done_progress_create(params: dict) -> dict: """Handle window/workDoneProgress/create: the server is about to report async progress. Clear the indexing-complete event so _start_server waits until all tokens finish. @@ -487,9 +553,12 @@ class KotlinLanguageServer(SolidLanguageServer): self.server.on_request("workspace/codeLens/refresh", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("workspace/diagnostic/refresh", do_nothing) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) self.server.on_notification("$/progress", progress_handler) + self.server.on_notification("intellij/ready-for-test", intellij_server_ready) self.server.on_notification("$/logTrace", do_nothing) self.server.on_notification("$/cancelRequest", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) @@ -503,6 +572,8 @@ class KotlinLanguageServer(SolidLanguageServer): init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] + server_info = init_response.get("serverInfo", {}) + wait_for_intellij_ready = isinstance(server_info, dict) and server_info.get("name") == "IntelliJ Language Server by JetBrains" assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" assert "hoverProvider" in capabilities, "Server must support hover" assert "completionProvider" in capabilities, "Server must support code completion" @@ -515,14 +586,17 @@ class KotlinLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) - # Wait for any async indexing to complete. + # Wait for workspace import and async indexing to complete. # - Older KLS (0.253.x): indexing is synchronous inside `initialize`, no $/progress is sent, # _indexing_complete stays SET -> wait() returns immediately. # - Newer KLS (261+): server sends window/workDoneProgress/create after initialized, # which clears the event; wait() blocks until all progress tokens end. + # - IntelliJ-based KLS (262.4739+): server sends intellij/ready-for-test after + # its workspace import and indexing phase completes. _INDEXING_TIMEOUT = 120.0 log.info("Waiting for Kotlin LSP indexing to complete (if async)...") - if self._indexing_complete.wait(timeout=_INDEXING_TIMEOUT): + ready_event = self._intellij_server_ready if wait_for_intellij_ready else self._indexing_complete + if ready_event.wait(timeout=_INDEXING_TIMEOUT): log.info("Kotlin LSP ready") else: log.warning("Kotlin LSP did not signal indexing completion within %.0fs; proceeding anyway", _INDEXING_TIMEOUT) diff --git a/src/solidlsp/resources/downloaded_dependency_hashes.json b/src/solidlsp/resources/downloaded_dependency_hashes.json index a02ce776..c6602add 100644 --- a/src/solidlsp/resources/downloaded_dependency_hashes.json +++ b/src/solidlsp/resources/downloaded_dependency_hashes.json @@ -4,5 +4,16 @@ "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-x64-1.54.0-923.vsix": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-arm64-1.54.0-923.vsix": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-x64-1.54.0-923.vsix": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b", - "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4" + "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-win-x64.zip": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-x64.zip": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-aarch64.zip": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-mac-x64.zip": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-mac-aarch64.zip": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.win.zip": "f2daaa476f26d99301b406f76de6d87c437d04dc72f06845154619d8f991c51f", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.win.zip": "73a552a6a420158622e5ad8d96b53da8aa8ced3f88a24fded01575927a2fd8e7", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.tar.gz": "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.tar.gz": "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.sit": "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.sit": "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f" } diff --git a/test/solidlsp/kotlin/test_kotlin_basic.py b/test/solidlsp/kotlin/test_kotlin_basic.py index d903a06d..7530a545 100644 --- a/test/solidlsp/kotlin/test_kotlin_basic.py +++ b/test/solidlsp/kotlin/test_kotlin_basic.py @@ -10,7 +10,7 @@ from test.conftest import language_server_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -# Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints +# Kotlin LSP (IntelliJ-based, pre-alpha v262) crashes on JVM restart under CI resource constraints # (2 CPUs, 7GB RAM). First start succeeds but subsequent starts fail with cancelled (-32800). # Tests pass reliably on developer machines. See PR #1061 for investigation details. # (The CI quarantine lives centrally in test/conftest.py::_determine_disabled_languages.) diff --git a/test/solidlsp/kotlin/test_kotlin_dependency_provider.py b/test/solidlsp/kotlin/test_kotlin_dependency_provider.py new file mode 100644 index 00000000..707a0633 --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_dependency_provider.py @@ -0,0 +1,275 @@ +"""Tests for Kotlin Language Server dependency resolution and installation.""" + +from contextlib import nullcontext +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase +from solidlsp.language_servers.kotlin_language_server import ( + DEFAULT_KOTLIN_LSP_VERSION, + INITIAL_KOTLIN_LSP_VERSION, + KOTLIN_LSP_ALLOWED_HOSTS, + KotlinLanguageServer, +) +from solidlsp.ls_utils import PlatformId +from solidlsp.settings import SolidLSPSettings + + +def _make_provider( + tmp_path: Path, + custom_settings: dict[str, str] | None = None, +) -> KotlinLanguageServer.DependencyProvider: + return KotlinLanguageServer.DependencyProvider( + custom_settings=SolidLSPSettings.CustomLSSettings(custom_settings or {}), + ls_resources_dir=str(tmp_path), + project_cache_dir=str(tmp_path / "project-cache"), + ) + + +@pytest.mark.kotlin +class TestKotlinDependencyProvider: + @pytest.mark.parametrize( + ("platform_id", "asset_suffix", "archive_type", "launcher_parts", "sha256"), + [ + ( + PlatformId.WIN_x64, + ".win.zip", + "zip", + ("bin", "intellij-server.exe"), + "f2daaa476f26d99301b406f76de6d87c437d04dc72f06845154619d8f991c51f", + ), + ( + PlatformId.WIN_arm64, + "-aarch64.win.zip", + "zip", + ("bin", "intellij-server.exe"), + "73a552a6a420158622e5ad8d96b53da8aa8ced3f88a24fded01575927a2fd8e7", + ), + ( + PlatformId.LINUX_x64, + ".tar.gz", + "gztar", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e", + ), + ( + PlatformId.LINUX_arm64, + "-aarch64.tar.gz", + "gztar", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e", + ), + ( + PlatformId.OSX_x64, + ".sit", + "zip", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745", + ), + ( + PlatformId.OSX_arm64, + "-aarch64.sit", + "zip", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f", + ), + ], + ) + def test_default_artifacts_match_jetbrains_release_matrix( + self, + tmp_path: Path, + platform_id: PlatformId, + asset_suffix: str, + archive_type: str, + launcher_parts: tuple[str, ...], + sha256: str, + ) -> None: + artifact = KotlinLanguageServer.DependencyProvider._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, platform_id) + url = ( + f"https://download-cdn.jetbrains.com/language-server/kotlin-server/{DEFAULT_KOTLIN_LSP_VERSION}/" + f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}{asset_suffix}" + ) + + with patch("solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified") as download: + artifact.dependency.download_to(tmp_path) + + download.assert_called_once_with( + url, + str(tmp_path), + archive_type=archive_type, + expected_sha256=sha256, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + ) + assert artifact.launcher_parts == launcher_parts + + @pytest.mark.parametrize( + ("version", "platform_id", "url", "launcher_parts"), + [ + ( + "262.2310.0", + PlatformId.LINUX_x64, + "https://download-cdn.jetbrains.com/kotlin-lsp/262.2310.0/kotlin-lsp-262.2310.0-linux-x64.zip", + ("kotlin-lsp.sh",), + ), + ( + "262.4739.0", + PlatformId.LINUX_x64, + "https://download-cdn.jetbrains.com/kotlin-lsp/262.4739.0/kotlin-server-262.4739.0.tar.gz", + ("kotlin-server-262.4739.0", "bin", "intellij-server"), + ), + ( + "262.8190.0", + PlatformId.OSX_arm64, + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.8190.0/kotlin-server-262.8190.0-aarch64.sit", + ("kotlin-server-262.8190.0", "bin", "intellij-server"), + ), + ], + ) + def test_version_boundaries_select_the_published_layout( + self, + version: str, + platform_id: PlatformId, + url: str, + launcher_parts: tuple[str, ...], + ) -> None: + artifact = KotlinLanguageServer.DependencyProvider._create_artifact(version, platform_id) + + assert artifact.dependency.get_url() == url + assert artifact.launcher_parts == launcher_parts + + def test_custom_versions_skip_hash_lookup_by_design(self, tmp_path: Path) -> None: + artifact = KotlinLanguageServer.DependencyProvider._create_artifact("262.8190.1", PlatformId.LINUX_x64) + + with ( + patch( + "solidlsp.dependency_provider.DownloadedDependencyHashDatabase.get_instance", + side_effect=AssertionError("custom versions must not consult the pinned hash database"), + ), + patch("solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified") as download, + ): + artifact.dependency.download_to(tmp_path) + + download.assert_called_once_with( + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.8190.1/kotlin-server-262.8190.1.tar.gz", + str(tmp_path), + archive_type="gztar", + expected_sha256=None, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + ) + + def test_hash_updater_registers_frozen_and_default_artifacts(self) -> None: + updated_asset_names: list[str] = [] + + class RecordingUpdater: + def update(self, dependency: DownloadedDependency) -> None: + updated_asset_names.append(dependency.get_url().rsplit("/", 1)[-1]) + + with patch.object(DownloadedDependencyHashDatabase, "get_instance") as get_hash_database: + get_hash_database.return_value.update_context.return_value = nullcontext(RecordingUpdater()) + KotlinLanguageServer.DependencyProvider.update_dep_hashes() + + assert set(updated_asset_names) == { + "kotlin-lsp-261.13587.0-win-x64.zip", + "kotlin-lsp-261.13587.0-linux-x64.zip", + "kotlin-lsp-261.13587.0-linux-aarch64.zip", + "kotlin-lsp-261.13587.0-mac-x64.zip", + "kotlin-lsp-261.13587.0-mac-aarch64.zip", + "kotlin-server-262.9593.0.win.zip", + "kotlin-server-262.9593.0-aarch64.win.zip", + "kotlin-server-262.9593.0.tar.gz", + "kotlin-server-262.9593.0-aarch64.tar.gz", + "kotlin-server-262.9593.0.sit", + "kotlin-server-262.9593.0-aarch64.sit", + } + + @pytest.mark.parametrize( + ("settings", "platform_id", "relative_launcher", "expected_directory"), + [ + ( + {"kotlin_lsp_version": INITIAL_KOTLIN_LSP_VERSION}, + PlatformId.LINUX_x64, + ("kotlin-lsp.sh",), + "kotlin_language_server", + ), + ( + {}, + PlatformId.LINUX_arm64, + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + f"kotlin_language_server-{DEFAULT_KOTLIN_LSP_VERSION}", + ), + ], + ) + def test_installs_pinned_versions_in_compatible_directories( + self, + tmp_path: Path, + settings: dict[str, str], + platform_id: PlatformId, + relative_launcher: tuple[str, ...], + expected_directory: str, + ) -> None: + provider = _make_provider(tmp_path, settings) + + def fake_download( + _url: str, + target_path: str, + archive_type: str, + expected_sha256: str | None = None, + allowed_hosts: tuple[str, ...] | list[str] | None = None, + ) -> None: + del archive_type, expected_sha256, allowed_hosts + launcher = Path(target_path).joinpath(*relative_launcher) + launcher.parent.mkdir(parents=True, exist_ok=True) + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + + with ( + patch( + "solidlsp.language_servers.kotlin_language_server.PlatformUtils.get_platform_id", + return_value=platform_id, + ), + patch( + "solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified", + side_effect=fake_download, + ), + ): + launcher_path = provider._get_or_install_core_dependency() + + assert launcher_path == str((tmp_path / expected_directory).joinpath(*relative_launcher)) + + @pytest.mark.parametrize( + ("platform_id", "modern_launcher", "other_os_launcher"), + [ + (PlatformId.LINUX_x64, "/path/to/intellij-server", "/path/to/intellij-server.exe"), + (PlatformId.WIN_x64, "/path/to/intellij-server.exe", "/path/to/intellij-server"), + ], + ) + def test_custom_launcher_arguments_use_the_active_os_name( + self, + tmp_path: Path, + platform_id: PlatformId, + modern_launcher: str, + other_os_launcher: str, + ) -> None: + modern_provider = _make_provider(tmp_path, {"ls_path": modern_launcher}) + other_os_provider = _make_provider(tmp_path, {"ls_path": other_os_launcher}) + + with patch( + "solidlsp.language_servers.kotlin_language_server.PlatformUtils.get_platform_id", + return_value=platform_id, + ): + assert modern_provider.create_launch_command() == [ + modern_launcher, + "--stdio", + "--system-path", + str(tmp_path / "project-cache" / "kotlin-lsp-system"), + ] + assert other_os_provider.create_launch_command() == [other_os_launcher, "--stdio"] + + def test_invalid_version_is_rejected_before_download(self) -> None: + with pytest.raises(ValueError, match="dot-separated integers"): + KotlinLanguageServer.DependencyProvider._create_artifact("latest", PlatformId.OSX_arm64) + + def test_unsupported_modern_platform_is_rejected(self) -> None: + with pytest.raises(ValueError, match="Unsupported platform"): + KotlinLanguageServer.DependencyProvider._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, PlatformId.LINUX_x86) diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py index c981add4..0d0d824c 100644 --- a/test/solidlsp/kotlin/test_kotlin_diagnostics.py +++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py @@ -2,9 +2,11 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import LanguageServerId +from test.conftest import language_server_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics +@pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.KOTLIN), reason="Kotlin tests are disabled") @pytest.mark.kotlin class TestKotlinDiagnostics: @pytest.mark.parametrize("language_server", [LanguageServerId.KOTLIN], indirect=True) diff --git a/test/solidlsp/kotlin/test_kotlin_startup.py b/test/solidlsp/kotlin/test_kotlin_startup.py new file mode 100644 index 00000000..0f6a0345 --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_startup.py @@ -0,0 +1,109 @@ +"""Regression tests for Kotlin LSP startup readiness handling.""" + +import logging +from pathlib import Path +from typing import Any, cast +from unittest.mock import Mock, patch + +import pytest + +from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer +from solidlsp.ls_config import LanguageServerConfig, LanguageServerId +from solidlsp.settings import SolidLSPSettings + +pytestmark = pytest.mark.kotlin + +_REQUIRED_CAPABILITIES = { + "textDocumentSync": 1, + "hoverProvider": True, + "completionProvider": {}, + "signatureHelpProvider": {}, + "definitionProvider": True, + "referencesProvider": True, + "documentSymbolProvider": True, + "workspaceSymbolProvider": True, + "semanticTokensProvider": {}, +} + + +def _make_server(tmp_path: Path) -> tuple[KotlinLanguageServer, Mock, Mock]: + settings = SolidLSPSettings( + solidlsp_dir=str(tmp_path / "global"), + project_data_path=str(tmp_path / "project"), + ls_specific_settings={LanguageServerId.KOTLIN: {}}, + ) + server_interface = Mock() + with patch.object(KotlinLanguageServer, "_create_language_server_interface", return_value=server_interface): + server = KotlinLanguageServer( + LanguageServerConfig(ls_id=LanguageServerId.KOTLIN), + str(tmp_path), + settings, + ) + + indexing_complete = Mock() + indexing_complete.wait.return_value = True + intellij_server_ready = Mock() + intellij_server_ready.wait.return_value = True + server_state = cast(Any, server) + server_state._indexing_complete = indexing_complete + server_state._intellij_server_ready = intellij_server_ready + return server, indexing_complete, intellij_server_ready + + +def _configure_initialize(server: KotlinLanguageServer, server_name: str | None) -> dict[str, Any]: + response: dict[str, Any] = {"capabilities": _REQUIRED_CAPABILITIES} + if server_name is not None: + response["serverInfo"] = {"name": server_name} + language_server_interface = cast(Any, server.server) + language_server_interface.send.initialize.return_value = response + return response + + +def test_modern_server_registers_and_waits_for_explicit_ready_notification(tmp_path: Path) -> None: + server, indexing_complete, intellij_server_ready = _make_server(tmp_path) + _configure_initialize(server, "IntelliJ Language Server by JetBrains") + events: list[str] = [] + notification_handlers: dict[str, Any] = {} + language_server_interface = cast(Any, server.server) + + def on_notification(method: str, handler: Any) -> None: + events.append(f"notification:{method}") + notification_handlers[method] = handler + + language_server_interface.on_notification.side_effect = on_notification + language_server_interface.start.side_effect = lambda: events.append("start") + language_server_interface.send.initialize.side_effect = lambda _params: ( + events.append("initialize") + or {"capabilities": _REQUIRED_CAPABILITIES, "serverInfo": {"name": "IntelliJ Language Server by JetBrains"}} + ) + + server._start_server() + + assert events.index("notification:intellij/ready-for-test") < events.index("start") < events.index("initialize") + intellij_server_ready.clear.assert_called_once_with() + intellij_server_ready.wait.assert_called_once_with(timeout=120.0) + indexing_complete.wait.assert_not_called() + + notification_handlers["intellij/ready-for-test"](None) + intellij_server_ready.set.assert_called_once_with() + + +def test_legacy_server_keeps_progress_event_wait(tmp_path: Path) -> None: + server, indexing_complete, intellij_server_ready = _make_server(tmp_path) + _configure_initialize(server, None) + + server._start_server() + + indexing_complete.wait.assert_called_once_with(timeout=120.0) + intellij_server_ready.wait.assert_not_called() + + +def test_modern_ready_timeout_warns_and_continues(tmp_path: Path, caplog: Any) -> None: + server, _indexing_complete, intellij_server_ready = _make_server(tmp_path) + _configure_initialize(server, "IntelliJ Language Server by JetBrains") + intellij_server_ready.wait.return_value = False + + with caplog.at_level(logging.WARNING): + server._start_server() + + assert "Kotlin LSP did not signal indexing completion within 120s; proceeding anyway" in caplog.text