diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6cbcf36c..e9b73699 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -81,7 +81,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11"] # 5 batches, each installs ONLY the toolchains its languages need (steps gated by matrix.batch). - # Node/Python/uv stay in every batch (cheap, needed for the venv + auto-installed LSes). + # Python/uv stay in every batch; Node is limited to other-langs and catch-all. # catch-all = web + python + the core framework. batch: [jvm, native, other-langs, niche, catch-all] # --- OS gating: which batch runs on which OS ----------------------------------------- @@ -106,9 +106,10 @@ jobs: with: go-version: ">=1.17.0" - name: Set up Node.js - uses: actions/setup-node@v4 + if: matrix.batch == 'other-langs' || matrix.batch == 'catch-all' + uses: actions/setup-node@v7 with: - node-version: '20.x' + node-version: '24.x' - name: Ensure cached directory exist before calling cache-related actions shell: bash run: | diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index adf97b18..162683e7 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -66,6 +66,50 @@ To implement a new language server using the DependencyProvider pattern: You should look at at least one existing implementation of each base class to understand how they work. +#### Downloading Runtime Dependencies + +Use `DownloadedDependency` (in `solidlsp.dependency_provider`), which bundles the URL, +archive type, allowed hosts and checksum verification behind a single `download_to()` call: + +```python +dep = DownloadedDependency( + url=f"https://example.org/foo-{version}-{platform}.zip", + archive_type="zip", # optional FileUtils.ArchiveType for extraction + allowed_hosts=FOO_ALLOWED_HOSTS, # optional list of allowed hosts +) +dep.download_to(target_dir) +``` + +Checksums for downloads live in a URL-keyed database, `src/solidlsp/resources/downloaded_dependency_hashes.json`, +managed by `DownloadedDependencyHashDatabase`. + +Consequences for your implementation: + + * Build each dependency in a factory classmethod (`_create_dep_*`) that takes an + optional version and falls back to the pinned `DEFAULT_*` constant. + * Add an `update_dep_hashes()` classmethod that constructs every dependency + and updates the hashes: + ```python + @classmethod + def update_dep_hashes(cls) -> None: + deps = [cls._create_dep_foo(), cls_._create_dep_bar(), ...] + with DownloadedDependencyHashDatabase.get_instance().update_context() as db: + for dep in deps: + db.update(dep) + ``` + Hook a call to this method into `scripts/update_downloaded_dependency_hashes.py`, run that script, + and commit the resulting JSON changes. + * After bumping any pinned version, re-run the script. Add a NOTE comment next to + the version constants saying so; a stale database means unverified downloads + locally and a CI failure. + * Pass `verified=False` only for dependencies whose hash cannot be pinned by design + (e.g. a user-supplied version override). + +Reference implementation: `EclipseJDTLS.DependencyProvider` + +Note that several older language servers still define hashes locally in constants and +call `FileUtils.download_and_extract_archive_verified`directly. Do not apply this legacy approach. + ### 1.2 LSP Initialization Override `_create_base_initialize_params` to provide server-specific initialization diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a895d15..bc1ac42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: F#'s `module ` declarations reported a `selectionRange` pointing at the `module` keyword instead of at ``, so looking up hover/references from a module symbol's position returned the keyword's own docs instead of the module's #925 + - Fix: Erlang functions could not be addressed by any tool taking an exact name path, because + Erlang LS identifies them as `name/arity` and `/` separates name path components. The arity is + now separated by `#` instead (e.g. `create_user#4`), so the reported name path round-trips and + `find_referencing_symbols`/`replace_symbol_body`/`insert_after_symbol` work on Erlang + functions #1797 - Fix: `LSPFileBuffer`: a stale content hash could be returned if files are kept open and file contents were not read before trying to retrieve the hash value - Fix: Change semantics of file opening (`open_file`) in the language server from "open file (if not already open)" diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 46d764db..00bfe33b 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -68,7 +68,8 @@ Some languages require additional installations or setup steps, as noted. * **Elm** (requires Elm compiler) * **Erlang** - (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang) + (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang; + note that functions are addressed as `name#arity`, e.g. `create_user#4`, because `/` is reserved as the name path separator) * **F#** (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** diff --git a/scripts/update_downloaded_dependency_hashes.py b/scripts/update_downloaded_dependency_hashes.py new file mode 100644 index 00000000..b82eab1d --- /dev/null +++ b/scripts/update_downloaded_dependency_hashes.py @@ -0,0 +1,7 @@ +from sensai.util import logging + +from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS + +if __name__ == "__main__": + logging.configure() + EclipseJDTLS.DependencyProvider.update_dep_hashes() diff --git a/src/solidlsp/dependency_provider.py b/src/solidlsp/dependency_provider.py index 386e14d5..21dccb4e 100644 --- a/src/solidlsp/dependency_provider.py +++ b/src/solidlsp/dependency_provider.py @@ -1,10 +1,16 @@ +import json import logging import os import shutil +import tempfile +import threading from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from os import PathLike -from solidlsp.settings import SolidLSPSettings +from solidlsp.ls_utils import FileUtils, is_running_in_ci +from solidlsp.settings import SOLIDLSP_RESOURCES_DIR, SolidLSPSettings log = logging.getLogger(__name__) @@ -239,3 +245,125 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCo def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: return base_command + list(self._extra_args) + + +class DownloadedDependencyHashDatabase: + RESOURCE_FILE_NAME = "downloaded_dependency_hashes.json" + _instance: "DownloadedDependencyHashDatabase | None" = None + _instance_lock: threading.Lock = threading.Lock() + + def __init__(self, _singleton: bool): + self._json_file = os.path.join(SOLIDLSP_RESOURCES_DIR, self.RESOURCE_FILE_NAME) + self._hashes = {} + if os.path.exists(self._json_file): + try: + with open(self._json_file, encoding="utf-8") as f: + self._hashes = json.load(f) + except: + log.warning("Error loading %s", self._json_file, exc_info=True) + + @classmethod + def get_instance(cls) -> "DownloadedDependencyHashDatabase": + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = DownloadedDependencyHashDatabase(True) + return cls._instance + + def get_sha256(self, dep: "DownloadedDependency") -> str | None: + return self._hashes.get(dep.get_url()) + + class Updater: + def __init__(self, db: "DownloadedDependencyHashDatabase"): + self._db = db + + def update(self, dep: "DownloadedDependency", force: bool = False) -> None: + """ + :param dep: the dependency to update the hash for + :param force: whether to force an update even if the hash already exists + """ + self._db._update(dep, force) + + @contextmanager + def update_context(self) -> Iterator[Updater]: + try: + yield self.Updater(self) + finally: + self._save() + + def _update(self, dep: "DownloadedDependency", force: bool = False) -> None: + if not force and dep.get_url() in self._hashes: + log.info("SHA256 for %s already exists, skipping update", dep.get_url()) + return + try: + with tempfile.TemporaryDirectory() as temp_dir: + path = os.path.join(temp_dir, "downloaded_dependency") + log.info("Downloading %s for hash calculation", dep.get_url()) + FileUtils.download_file(dep.get_url(), path) + sha = FileUtils.calculate_sha256(path) + log.info("SHA256 for %s: %s", dep.get_url(), sha) + self._hashes[dep.get_url()] = sha + except: + log.warning("Error updating hash for %s", dep.get_url(), exc_info=True) + + def _save(self) -> None: + try: + with open(self._json_file, "w", encoding="utf-8") as f: + json.dump(self._hashes, f, indent=2) + except: + log.warning("Error saving %s", self._json_file, exc_info=True) + + +class DownloadedDependency: + """ + Represents a dependency that can be downloaded from a URL, optionally verified against a known SHA256 hash, + and optionally extracted if it is an archive. + """ + + def __init__( + self, + url: str, + archive_type: FileUtils.ArchiveType | None = None, + allowed_hosts: Sequence[str] | None = None, + verified: bool = True, + ): + """ + :param url: the URL of the file to be downloaded + :param archive_type: optional type of archive for the case where `url` points to an archive. + Use `None` or "binary" for a binary file (no extraction). + :param allowed_hosts: optional list of allowed hosts for the download URL + :param verified: whether to verify the downloaded file against a known SHA256 hash (if available). + If enabled and the hash for the URL is not known, will raise an exception in CI and issue a warning + in non-CI environments. + """ + self._url = url + self._allowed_hosts = allowed_hosts + self._archive_type = archive_type if archive_type is not None else "binary" + self._verified = verified + + def get_url(self) -> str: + return self._url + + def download_to(self, target_path: str | PathLike) -> None: + """ + Downloads the dependency to the specified target path. + If defined at construction, will apply hash verification and archive extraction. + + :param target_path: the path to which the dependency should be downloaded (or extracted if applicable) + """ + if not isinstance(target_path, str): + target_path = str(target_path) + if self._verified: + sha256 = DownloadedDependencyHashDatabase.get_instance().get_sha256(self) + if sha256 is None: + if is_running_in_ci(): + raise RuntimeError( + f"No SHA256 hash found for {self._url}. " + "Please update the hash database by running 'scripts/update_downloaded_dependency_hashes.py'." + ) + log.warning("No SHA256 hash found for %s. The downloaded file will not be verified.", self._url) + else: + sha256 = None + FileUtils.download_and_extract_archive_verified( + self._url, target_path, archive_type=self._archive_type, expected_sha256=sha256, allowed_hosts=self._allowed_hosts + ) diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index e5e7ca84..d5be6807 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -24,7 +24,7 @@ class RuntimeDependency: url: str | None = None sha256: str | None = None allowed_hosts: tuple[str, ...] | list[str] | None = None - archive_type: str | None = None + archive_type: FileUtils.ArchiveType | None = None binary_name: str | None = None command: str | list[str] | None = None package_name: str | None = None diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index a67392fb..94531a9a 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -13,18 +13,20 @@ import re import shutil import subprocess import threading +from dataclasses import dataclass from pathlib import Path, PurePath from time import sleep -from typing import Any, cast +from typing import Any from overrides import override from solidlsp import ls_types +from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase 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.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.settings import SolidLSPSettings from solidlsp.util.subprocess_util import subprocess_run @@ -32,7 +34,6 @@ from solidlsp.util.subprocess_util import subprocess_run log = logging.getLogger(__name__) GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -GRADLE_SHA256 = "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999" VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") INTELLICODE_ALLOWED_HOSTS = ( "visualstudioexptteam.gallery.vsassets.io", @@ -41,30 +42,6 @@ INTELLICODE_ALLOWED_HOSTS = ( ) -# Version pinning convention (read this before bumping anything below): -# -# INITIAL_* — the very first version we shipped runtime-dependency support for, paired with -# its SHA. NEVER edited. The legacy unversioned install directory (e.g. "vscode-java/") -# is reserved exclusively for this version, so users who installed under the original -# layout keep their existing cache forever. -# -# DEFAULT_* — the version (and matching SHA) used when the user does not override via -# custom_settings. THIS is what we edit to upgrade: copy the new version + new SHA over -# the existing DEFAULT_* literals, leave INITIAL_* alone. -# -# Resolution at install time: -# resolved_version = custom_settings.get("..._version", DEFAULT_*_VERSION) -# - resolved == INITIAL_* -> legacy unversioned dir, SHA = INITIAL_*_SHA256 -# - resolved == DEFAULT_* -> versioned dir "{name}-{resolved}", SHA = DEFAULT_*_SHA256 -# - any other version -> versioned dir "{name}-{resolved}", SHA verification skipped -# -# Consequences of this scheme: -# - Bumping DEFAULT_* makes new installs land in a new versioned subdir => no silent reuse -# of stale binaries (the bug this scheme exists to fix). -# - Previously-downloaded versions are not lost: a user can request any past version via -# custom_settings and the matching cached subdir is picked up without re-downloading. -# - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on -# the first DEFAULT_* bump and stay independent thereafter. @dataclasses.dataclass(frozen=True) class VsixResourcePaths: """ @@ -81,40 +58,6 @@ class VsixResourcePaths: equinox_launcher_basename: str # e.g. "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar" -INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" -INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { - "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", - "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", - "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", - "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", - "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", -} -INITIAL_VSCODE_JAVA_PATHS = VsixResourcePaths( - jre_version="21.0.7", - lombok_jar_basename="lombok-1.18.36.jar", - equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", -) -# Bumped from 1.42.0-561 to surface Lombok-generated methods (#1432); brings JDTLS commit b2d8952 -# (java.symbols.includeGeneratedCode), JRE 21.0.10, Lombok 1.18.39 and Equinox launcher 1.7.100. -DEFAULT_VSCODE_JAVA_VERSION = "1.54.0-923" -DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { - "osx-arm64": "c54c45cb0d2579d8e0a4ddeb24d4a9dd0b460d07d9366adea2b38a1da22a463c", - "osx-x64": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6", - "linux-arm64": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c", - "linux-x64": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b", - "win-x64": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4", -} -DEFAULT_VSCODE_JAVA_PATHS = VsixResourcePaths( - jre_version="21.0.10", - lombok_jar_basename="lombok-1.18.39-4050.jar", - equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar", -) - -INITIAL_INTELLICODE_VERSION = "1.2.30" -INITIAL_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" -DEFAULT_INTELLICODE_VERSION = "1.2.30" -DEFAULT_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" - # Mapping from Serena's platform identifiers to upstream JDTLS config_ directory names JDTLS_CONFIG_DIR_BY_PLATFORM = { "osx-arm64": "config_mac_arm", @@ -150,6 +93,19 @@ class RuntimeDependencyPaths: intellisense_members_path: str | None = None +@dataclass +class VSCodeJavaConfig: + """ + Represents a (platform-specific) configuration for VSCode Java + """ + + jre_home_path: str + jre_path: str + lombok_jar_path: str + jdtls_launcher_jar_path: str + jdtls_readonly_config_path: str + + class EclipseJDTLS(SolidLanguageServer): r""" The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class @@ -271,6 +227,56 @@ class EclipseJDTLS(SolidLanguageServer): return self.DependencyProvider(self._custom_settings, ls_resources_dir, self._solidlsp_settings, self.repository_root_path) class DependencyProvider(LanguageServerDependencyProvider): + """ + Dependency provider for Eclipse JDTLS. + + Version pinning convention (read this before bumping anything below): + + INITIAL_* — the very first version we shipped runtime-dependency support for, paired with + its SHA. NEVER edited. The legacy unversioned install directory (e.g. "vscode-java/") + is reserved exclusively for this version, so users who installed under the original + layout keep their existing cache forever. + + DEFAULT_* — the version (and matching SHA) used when the user does not override via + custom_settings. THIS is what we edit to upgrade: copy the new version + new SHA over + the existing DEFAULT_* literals, leave INITIAL_* alone. + + Resolution at install time: + resolved_version = custom_settings.get("..._version", DEFAULT_*_VERSION) + - resolved == INITIAL_* -> legacy unversioned dir, SHA = INITIAL_*_SHA256 + - resolved == DEFAULT_* -> versioned dir "{name}-{resolved}", SHA = DEFAULT_*_SHA256 + - any other version -> versioned dir "{name}-{resolved}", SHA verification skipped + + Consequences of this scheme: + - Bumping DEFAULT_* makes new installs land in a new versioned subdir => no silent reuse + of stale binaries (the bug this scheme exists to fix). + - Previously-downloaded versions are not lost: a user can request any past version via + custom_settings and the matching cached subdir is picked up without re-downloading. + - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on + the first DEFAULT_* bump and stay independent thereafter. + """ + + # versions used initially (which use default paths without version suffix) + INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" + INITIAL_INTELLICODE_VERSION = "1.2.30" + INITIAL_VSCODE_JAVA_PATHS = VsixResourcePaths( + jre_version="21.0.7", + lombok_jar_basename="lombok-1.18.36.jar", + equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + ) + + # currently used default versions + # NOTE: After bumping any of these, also update the corresponding hashes by running + # scripts/update_downloaded_dependency_hashes.py and commit the resulting changes. + DEFAULT_GRADLE_VERSION = "8.14.2" + DEFAULT_VSCODE_JAVA_VERSION = "1.54.0-923" + DEFAULT_INTELLICODE_VERSION = "1.2.30" + DEFAULT_VSCODE_JAVA_PATHS = VsixResourcePaths( + jre_version="21.0.10", + lombok_jar_basename="lombok-1.18.39-4050.jar", + equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar", + ) + def __init__( self, custom_settings: SolidLSPSettings.CustomLSSettings, @@ -283,9 +289,57 @@ class EclipseJDTLS(SolidLanguageServer): self._repository_root_path = repository_root_path self.runtime_dependency_paths = self._setup_runtime_dependencies(ls_resources_dir, custom_settings) - @staticmethod + @classmethod + def _create_dep_gradle(cls, gradle_version: str | None = None) -> DownloadedDependency: + gradle_version = gradle_version or cls.DEFAULT_GRADLE_VERSION + return DownloadedDependency( + url=f"https://services.gradle.org/distributions/gradle-{gradle_version}-bin.zip", + archive_type="zip", + allowed_hosts=GRADLE_ALLOWED_HOSTS, + verified=gradle_version == cls.DEFAULT_GRADLE_VERSION, + ) + + @classmethod + def _create_deps_vscode_java(cls, vscode_java_version: str | None = None) -> dict[str, DownloadedDependency]: + vscode_java_version = vscode_java_version or cls.DEFAULT_VSCODE_JAVA_VERSION + vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" + urls = { + "osx-arm64": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", + "osx-x64": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", + "linux-arm64": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", + "linux-x64": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", + "win-x64": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix", + } + deps = {} + for platform_key, url in urls.items(): + deps[platform_key] = DownloadedDependency( + url=url, + archive_type="zip", + allowed_hosts=VSCODE_JAVA_ALLOWED_HOSTS, + verified=vscode_java_version == cls.DEFAULT_VSCODE_JAVA_VERSION, + ) + return deps + + @classmethod + def _create_dep_intellicode(cls, intellicode_version: str | None = None) -> DownloadedDependency: + intellicode_version = intellicode_version or cls.DEFAULT_INTELLICODE_VERSION + return DownloadedDependency( + url=f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", + archive_type="zip", + allowed_hosts=INTELLICODE_ALLOWED_HOSTS, + verified=intellicode_version == cls.DEFAULT_INTELLICODE_VERSION, + ) + + @classmethod + def update_dep_hashes(cls) -> None: + deps = [cls._create_dep_gradle(), cls._create_dep_intellicode()] + list(cls._create_deps_vscode_java().values()) + with DownloadedDependencyHashDatabase.get_instance().update_context() as db: + for dep in deps: + db.update(dep) + + @classmethod def _setup_runtime_dependencies( - ls_resources_dir: str, custom_settings: SolidLSPSettings.CustomLSSettings + cls, ls_resources_dir: str, custom_settings: SolidLSPSettings.CustomLSSettings ) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. @@ -313,145 +367,72 @@ class EclipseJDTLS(SolidLanguageServer): 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", DEFAULT_VSCODE_JAVA_VERSION) - vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" - intellicode_version = custom_settings.get("intellicode_version", DEFAULT_INTELLICODE_VERSION) - default_gradle_version = gradle_version == "8.14.2" + gradle_version = custom_settings.get("gradle_version", cls.DEFAULT_GRADLE_VERSION) + vscode_java_version = custom_settings.get("vscode_java_version", cls.DEFAULT_VSCODE_JAVA_VERSION) + intellicode_version = custom_settings.get("intellicode_version", cls.DEFAULT_INTELLICODE_VERSION) # install-dir name per the version-pinning convention (see module-level block): # INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir vscode_java_dirname = ( - "vscode-java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}" + "vscode-java" if vscode_java_version == cls.INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}" ) intellicode_dirname = ( - "intellicode" if intellicode_version == INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}" + "intellicode" if intellicode_version == cls.INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}" ) - # SHA is only known for our two pinned versions (INITIAL and current DEFAULT); - # for any other user-supplied version we skip verification (returns None) - def vscode_java_sha(platform_key: str) -> str | None: - if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION: - return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] - if vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION: - return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] - return None - - def intellicode_sha() -> str | None: - if intellicode_version == INITIAL_INTELLICODE_VERSION: - return INITIAL_INTELLICODE_SHA256 - if intellicode_version == DEFAULT_INTELLICODE_VERSION: - return DEFAULT_INTELLICODE_SHA256 - return None - # Resolve internal VSIX paths (JRE / Lombok / launcher filenames). For pinned versions # these are known; for any other user-supplied version we bail out — guessing would # silently produce broken paths at JDTLS launch time, which is a worse UX than failing # fast here with a pointer to upstream-JDTLS mode (which doesn't need pinned paths). - if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION: - vsix_paths = INITIAL_VSCODE_JAVA_PATHS - elif vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION: - vsix_paths = DEFAULT_VSCODE_JAVA_PATHS + if vscode_java_version == cls.INITIAL_VSCODE_JAVA_VERSION: + vsix_paths = cls.INITIAL_VSCODE_JAVA_PATHS + elif vscode_java_version == cls.DEFAULT_VSCODE_JAVA_VERSION: + vsix_paths = cls.DEFAULT_VSCODE_JAVA_PATHS else: raise SolidLSPException( f"Resource paths inside the vscode-java {vscode_java_version} VSIX are not pinned in serena " - f"(known: {INITIAL_VSCODE_JAVA_VERSION}, {DEFAULT_VSCODE_JAVA_VERSION}). " - f"Either remove the 'vscode_java_version' override (defaults to {DEFAULT_VSCODE_JAVA_VERSION}), " + f"(known: {cls.INITIAL_VSCODE_JAVA_VERSION}, {cls.DEFAULT_VSCODE_JAVA_VERSION}). " + f"Either remove the 'vscode_java_version' override (defaults to {cls.DEFAULT_VSCODE_JAVA_VERSION}), " f"or use upstream JDTLS mode by setting both 'jdtls_path' and 'lombok_path' in " f"ls_specific_settings.java (no pinning required)." ) - runtime_dependencies: dict[str, dict[str, dict[str, object]]] = { - "gradle": { - "platform-agnostic": { - "url": f"https://services.gradle.org/distributions/gradle-{gradle_version}-bin.zip", - "archiveType": "zip", - "relative_extraction_path": ".", - "sha256": GRADLE_SHA256 if default_gradle_version else None, - "allowed_hosts": GRADLE_ALLOWED_HOSTS, - } - }, - "vscode-java": { - "darwin-arm64": { - "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", - "archiveType": "zip", - "relative_extraction_path": vscode_java_dirname, - "sha256": vscode_java_sha("osx-arm64"), - "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, - }, - "osx-arm64": { - "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", - "archiveType": "zip", - "relative_extraction_path": vscode_java_dirname, - "sha256": vscode_java_sha("osx-arm64"), - "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, - "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64", - "jre_path": f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64/bin/java", - "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", - "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", - "jdtls_readonly_config_path": "extension/server/config_mac_arm", - }, - "osx-x64": { - "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", - "archiveType": "zip", - "relative_extraction_path": vscode_java_dirname, - "sha256": vscode_java_sha("osx-x64"), - "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, - "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64", - "jre_path": f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64/bin/java", - "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", - "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", - "jdtls_readonly_config_path": "extension/server/config_mac", - }, - "linux-arm64": { - "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", - "archiveType": "zip", - "relative_extraction_path": vscode_java_dirname, - "sha256": vscode_java_sha("linux-arm64"), - "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, - "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-linux-aarch64", - "jre_path": f"extension/jre/{vsix_paths.jre_version}-linux-aarch64/bin/java", - "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", - "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", - "jdtls_readonly_config_path": "extension/server/config_linux_arm", - }, - "linux-x64": { - "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", - "archiveType": "zip", - "relative_extraction_path": vscode_java_dirname, - "sha256": vscode_java_sha("linux-x64"), - "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, - "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-linux-x86_64", - "jre_path": f"extension/jre/{vsix_paths.jre_version}-linux-x86_64/bin/java", - "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", - "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", - "jdtls_readonly_config_path": "extension/server/config_linux", - }, - "win-x64": { - "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix", - "archiveType": "zip", - "relative_extraction_path": vscode_java_dirname, - "sha256": vscode_java_sha("win-x64"), - "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, - "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-win32-x86_64", - "jre_path": f"extension/jre/{vsix_paths.jre_version}-win32-x86_64/bin/java.exe", - "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", - "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", - "jdtls_readonly_config_path": "extension/server/config_win", - }, - }, - "intellicode": { - "platform-agnostic": { - "url": f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", - "alternate_url": f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/{intellicode_version}/vspackage", - "archiveType": "zip", - "relative_extraction_path": intellicode_dirname, - "sha256": intellicode_sha(), - "allowed_hosts": INTELLICODE_ALLOWED_HOSTS, - "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", - "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", - } - }, + vscode_java_configs: dict[str, VSCodeJavaConfig] = { + "osx-arm64": VSCodeJavaConfig( + jre_home_path=f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64", + jre_path=f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64/bin/java", + lombok_jar_path=f"extension/lombok/{vsix_paths.lombok_jar_basename}", + jdtls_launcher_jar_path=f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + jdtls_readonly_config_path="extension/server/config_mac_arm", + ), + "osx-x64": VSCodeJavaConfig( + jre_home_path=f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64", + jre_path=f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64/bin/java", + lombok_jar_path=f"extension/lombok/{vsix_paths.lombok_jar_basename}", + jdtls_launcher_jar_path=f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + jdtls_readonly_config_path="extension/server/config_mac", + ), + "linux-arm64": VSCodeJavaConfig( + jre_home_path=f"extension/jre/{vsix_paths.jre_version}-linux-aarch64", + jre_path=f"extension/jre/{vsix_paths.jre_version}-linux-aarch64/bin/java", + lombok_jar_path=f"extension/lombok/{vsix_paths.lombok_jar_basename}", + jdtls_launcher_jar_path=f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + jdtls_readonly_config_path="extension/server/config_linux_arm", + ), + "linux-x64": VSCodeJavaConfig( + jre_home_path=f"extension/jre/{vsix_paths.jre_version}-linux-x86_64", + jre_path=f"extension/jre/{vsix_paths.jre_version}-linux-x86_64/bin/java", + lombok_jar_path=f"extension/lombok/{vsix_paths.lombok_jar_basename}", + jdtls_launcher_jar_path=f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + jdtls_readonly_config_path="extension/server/config_linux", + ), + "win-x64": VSCodeJavaConfig( + jre_home_path=f"extension/jre/{vsix_paths.jre_version}-win32-x86_64", + jre_path=f"extension/jre/{vsix_paths.jre_version}-win32-x86_64/bin/java.exe", + lombok_jar_path=f"extension/lombok/{vsix_paths.lombok_jar_basename}", + jdtls_launcher_jar_path=f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + jdtls_readonly_config_path="extension/server/config_win", + ), } gradle_path = str( @@ -462,25 +443,20 @@ class EclipseJDTLS(SolidLanguageServer): ) if not os.path.exists(gradle_path): - gradle_dependency = runtime_dependencies["gradle"]["platform-agnostic"] - FileUtils.download_and_extract_archive_verified( - cast(str, gradle_dependency["url"]), - str(PurePath(gradle_path).parent), - cast(str, gradle_dependency["archiveType"]), - expected_sha256=cast(str | None, gradle_dependency["sha256"]), - allowed_hosts=cast(tuple[str, ...], gradle_dependency["allowed_hosts"]), - ) + gradle_dependency = cls._create_dep_gradle(gradle_version) + gradle_dependency.download_to(PurePath(gradle_path).parent) assert os.path.exists(gradle_path) - dependency = runtime_dependencies["vscode-java"][platformId.value] - vscode_java_path = str(PurePath(ls_resources_dir, cast(str, dependency["relative_extraction_path"]))) + vscode_java_dep = cls._create_deps_vscode_java(vscode_java_version)[platformId.value] + vscode_java_cfg = vscode_java_configs[platformId.value] + vscode_java_path = str(PurePath(ls_resources_dir, vscode_java_dirname)) os.makedirs(vscode_java_path, exist_ok=True) - jre_home_path = str(PurePath(vscode_java_path, cast(str, dependency["jre_home_path"]))) - jre_path = str(PurePath(vscode_java_path, cast(str, dependency["jre_path"]))) - lombok_jar_path = str(PurePath(vscode_java_path, cast(str, dependency["lombok_jar_path"]))) - jdtls_launcher_jar_path = str(PurePath(vscode_java_path, cast(str, dependency["jdtls_launcher_jar_path"]))) - jdtls_readonly_config_path = str(PurePath(vscode_java_path, cast(str, dependency["jdtls_readonly_config_path"]))) + jre_home_path = str(PurePath(vscode_java_path, vscode_java_cfg.jre_home_path)) + jre_path = str(PurePath(vscode_java_path, vscode_java_cfg.jre_path)) + lombok_jar_path = str(PurePath(vscode_java_path, vscode_java_cfg.lombok_jar_path)) + jdtls_launcher_jar_path = str(PurePath(vscode_java_path, vscode_java_cfg.jdtls_launcher_jar_path)) + jdtls_readonly_config_path = str(PurePath(vscode_java_path, vscode_java_cfg.jdtls_readonly_config_path)) if not all( [ os.path.exists(vscode_java_path), @@ -491,13 +467,7 @@ class EclipseJDTLS(SolidLanguageServer): os.path.exists(jdtls_readonly_config_path), ] ): - FileUtils.download_and_extract_archive_verified( - cast(str, dependency["url"]), - vscode_java_path, - cast(str, dependency["archiveType"]), - expected_sha256=cast(str | None, dependency["sha256"]), - allowed_hosts=cast(tuple[str, ...], dependency["allowed_hosts"]), - ) + vscode_java_dep.download_to(vscode_java_path) os.chmod(jre_path, 0o755) @@ -508,11 +478,13 @@ class EclipseJDTLS(SolidLanguageServer): assert os.path.exists(jdtls_launcher_jar_path) assert os.path.exists(jdtls_readonly_config_path) - dependency = runtime_dependencies["intellicode"]["platform-agnostic"] - intellicode_directory_path = str(PurePath(ls_resources_dir, cast(str, dependency["relative_extraction_path"]))) + intellicode_dep = cls._create_dep_intellicode(intellicode_version) + intellicode_directory_path = str(PurePath(ls_resources_dir, intellicode_dirname)) os.makedirs(intellicode_directory_path, exist_ok=True) - intellicode_jar_path = str(PurePath(intellicode_directory_path, cast(str, dependency["intellicode_jar_path"]))) - intellisense_members_path = str(PurePath(intellicode_directory_path, cast(str, dependency["intellisense_members_path"]))) + intellicode_jar_path = str( + PurePath(intellicode_directory_path, "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar") + ) + intellisense_members_path = str(PurePath(intellicode_directory_path, "extension/dist/bundledModels/java_intellisense-members")) if not all( [ os.path.exists(intellicode_directory_path), @@ -520,13 +492,7 @@ class EclipseJDTLS(SolidLanguageServer): os.path.exists(intellisense_members_path), ] ): - FileUtils.download_and_extract_archive_verified( - cast(str, dependency["url"]), - intellicode_directory_path, - cast(str, dependency["archiveType"]), - expected_sha256=cast(str | None, dependency["sha256"]), - allowed_hosts=cast(tuple[str, ...], dependency["allowed_hosts"]), - ) + intellicode_dep.download_to(intellicode_directory_path) assert os.path.exists(intellicode_directory_path) assert os.path.exists(intellicode_jar_path) @@ -764,8 +730,9 @@ class EclipseJDTLS(SolidLanguageServer): major = int(version_match.group(1)) return real_home, major - @staticmethod + @classmethod def _compute_workspace_hash( + cls, repository_root_path: str, jdtls_launcher_jar_path: str, custom_settings: SolidLSPSettings.CustomLSSettings, @@ -802,7 +769,7 @@ class EclipseJDTLS(SolidLanguageServer): is_legacy_initial = ( not custom_settings.get("jdtls_path") - and custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) == INITIAL_VSCODE_JAVA_VERSION + and custom_settings.get("vscode_java_version", cls.DEFAULT_VSCODE_JAVA_VERSION) == cls.INITIAL_VSCODE_JAVA_VERSION and not workspace_settings ) if is_legacy_initial: diff --git a/src/solidlsp/language_servers/erlang_language_server.py b/src/solidlsp/language_servers/erlang_language_server.py index a9c4897d..f34c93f0 100644 --- a/src/solidlsp/language_servers/erlang_language_server.py +++ b/src/solidlsp/language_servers/erlang_language_server.py @@ -6,17 +6,27 @@ import shutil import subprocess import threading import time +from collections.abc import Hashable from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import is_running_in_ci from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from solidlsp.util.subprocess_util import subprocess_run log = logging.getLogger(__name__) +ARITY_SEPARATOR = "#" +""" +The character that replaces the `/` in the `name/arity` identifiers reported by Erlang LS. + +`#` was chosen because it cannot occur in an unquoted Erlang atom, so it can never collide with a +real function, type or macro name (unlike `@`, which is a legal atom character). +""" + class ErlangLanguageServer(SolidLanguageServer): """Language server for Erlang using Erlang LS.""" @@ -47,6 +57,26 @@ class ErlangLanguageServer(SolidLanguageServer): # Set generous timeout for Erlang LS initialization self.set_request_timeout(120.0) + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + """ + Replaces the `/` in Erlang's `name/arity` identifiers, which would otherwise be interpreted + as Serena's name path separator. + + Erlang LS names functions, types and parameterised macros `name/arity` (e.g. `create_user/2`). + Since `/` separates name path components, such a name is parsed as "symbol `2` nested inside + `create_user`" and can never be matched, not even by the very name path that Serena itself + reports for the symbol. The arity is not simply dropped because it is part of a function's + identity in Erlang: `create_user/2` and `create_user/3` are different functions which may + both be defined in the same module. + """ + return symbol["name"].replace("/", ARITY_SEPARATOR) + def _check_erlang_installation(self) -> bool: """Check if Erlang/OTP is available.""" try: @@ -160,7 +190,7 @@ class ErlangLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) # Wait for Erlang LS to be ready - adjust timeout based on environment - is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" + is_ci = is_running_in_ci() is_macos = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False # macOS in CI can be particularly slow for language server startup diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py index 2d801656..4d7a2f76 100644 --- a/src/solidlsp/language_servers/groovy_language_server.py +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -6,6 +6,7 @@ import dataclasses import logging import os import shlex +from typing import cast from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig, LanguageServerId @@ -184,7 +185,7 @@ class GroovyLanguageServer(SolidLanguageServer): java_home_relative_path = java_dependency["java_home_path"] java_relative_path = java_dependency["java_path"] java_download_url = java_dependency["url"] - java_archive_type = java_dependency["archiveType"] + java_archive_type = cast(FileUtils.ArchiveType, java_dependency["archiveType"]) assert java_home_relative_path is not None assert java_relative_path is not None assert java_download_url is not None diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index c9881c60..990b1422 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1909,6 +1909,12 @@ class SolidLanguageServer(ABC): NOTE: When changing the override of this method after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint` in order to ensure that the caches are invalidated appropriately. + NOTE: The returned name must not contain '/', which separates the components of a name path + (see :class:`serena.symbol.NamePathMatcher`). A symbol whose name contains it cannot be + addressed by any tool taking an exact name path, not even via the name path that is + reported for the symbol itself. Language servers that identify symbols in such a way + (e.g. Erlang LS, which reports functions as `name/arity`) must substitute another + character here. :param symbol: the symbol :param relative_file_path: the relative path of the file the symbol is located in diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index b07abd8d..f46f09ea 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -13,6 +13,7 @@ import tarfile import tempfile import uuid import zipfile +from collections.abc import Sequence from enum import Enum from pathlib import Path, PurePath from typing import Literal, cast @@ -28,6 +29,15 @@ from solidlsp.util.subprocess_util import subprocess_run log = logging.getLogger(__name__) +def is_running_in_ci() -> bool: + """ + Determines whether the current process is running in a Continuous Integration (CI) environment. + + :return: True if running in CI, False otherwise + """ + return os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" + + class TextStepper: r""" A utility class for stepping through a text string line by line, keeping track of the current line and column numbers. @@ -383,6 +393,8 @@ class FileUtils: Utility functions for file operations. """ + ArchiveType = Literal["tar", "gztar", "bztar", "xztar", "zip", "zip.gz", "gz", "binary"] + @staticmethod def read_file(file_path: str, encoding: str) -> str: """ @@ -429,7 +441,7 @@ class FileUtils: url: str, target_path: str, expected_sha256: str | None = None, - allowed_hosts: tuple[str, ...] | list[str] | None = None, + allowed_hosts: Sequence[str] | None = None, ) -> None: """ Downloads a file from ``url`` to ``target_path`` with optional integrity and host validation. @@ -468,7 +480,7 @@ class FileUtils: Path.unlink(Path(temp_file_path)) @staticmethod - def download_and_extract_archive(url: str, target_path: str, archive_type: str) -> None: + def download_and_extract_archive(url: str, target_path: str, archive_type: ArchiveType) -> None: """ Downloads the archive from the given URL having format {archive_type} and extracts it to the given {target_path} """ @@ -478,9 +490,9 @@ class FileUtils: def download_and_extract_archive_verified( url: str, target_path: str, - archive_type: str, + archive_type: ArchiveType, expected_sha256: str | None = None, - allowed_hosts: tuple[str, ...] | list[str] | None = None, + allowed_hosts: Sequence[str] | None = None, ) -> None: """ Downloads an archive from ``url`` and extracts it safely into ``target_path``. @@ -560,7 +572,7 @@ class FileUtils: raise SolidLSPException(f"Checksum verification failed for '{file_path}': expected {expected_sha256}, got {actual_sha256}") @staticmethod - def _validate_download_host(url: str, allowed_hosts: tuple[str, ...] | list[str] | None) -> None: + def _validate_download_host(url: str, allowed_hosts: Sequence[str] | None) -> None: """ Validates that a download URL resolves to one of the configured hosts. """ diff --git a/src/solidlsp/resources/downloaded_dependency_hashes.json b/src/solidlsp/resources/downloaded_dependency_hashes.json new file mode 100644 index 00000000..7fc0bdd9 --- /dev/null +++ b/src/solidlsp/resources/downloaded_dependency_hashes.json @@ -0,0 +1,9 @@ +{ + "https://services.gradle.org/distributions/gradle-8.14.2-bin.zip": "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999", + "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage": "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251", + "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-arm64-1.54.0-923.vsix": "c54c45cb0d2579d8e0a4ddeb24d4a9dd0b460d07d9366adea2b38a1da22a463c", + "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" +} \ No newline at end of file diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index a5ee4ace..a1fa3003 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -16,6 +16,9 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +SOLIDLSP_RESOURCES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources") + + @dataclass class SolidLSPSettings: """ diff --git a/test/solidlsp/erlang/test_erlang_symbol_names.py b/test/solidlsp/erlang/test_erlang_symbol_names.py new file mode 100644 index 00000000..e940fb60 --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_symbol_names.py @@ -0,0 +1,105 @@ +"""Normalization of Erlang's ``name/arity`` symbol identifiers. + +Reproduces https://github.com/oraios/serena/issues/1797: Erlang LS names functions, types and +parameterised macros ``name/arity`` (e.g. ``create_user/4``), but ``/`` separates the components of +a Serena name path. Such a name was therefore parsed as "symbol ``4`` nested inside ``create_user``" +and could never be matched -- not even by the very name path Serena itself reported for the symbol. +In practice that made ``find_referencing_symbols``, ``replace_symbol_body`` and +``insert_after_symbol`` unusable on Erlang functions, while read-only browsing kept working, so the +language looked supported until one tried to do anything with a function. + +``ErlangLanguageServer._normalize_symbol_name`` now substitutes +:data:`~solidlsp.language_servers.erlang_language_server.ARITY_SEPARATOR` for the ``/``, which makes +the reported name path round-trip back into the symbol tools. +""" + +import os + +import pytest + +from serena.project import Project +from serena.symbol import LanguageServerSymbolRetriever +from solidlsp.language_servers.erlang_language_server import ARITY_SEPARATOR +from solidlsp.ls_config import LanguageServerId +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation +from test.conftest import language_server_tests_enabled + +pytestmark = [ + pytest.mark.erlang, + pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.ERLANG), reason="Erlang tests are disabled"), +] + +MODELS_ERL = os.path.join("src", "models.erl") +SERVICES_ERL = os.path.join("src", "services.erl") +RECORDS_HRL = os.path.join("include", "records.hrl") + +# `models:create_user/4`, spelled the way Serena addresses it +CREATE_USER_4 = f"create_user{ARITY_SEPARATOR}4" + +# file and directory symbols are named after path components, so `/` is legitimate for them +CONTAINER_KINDS = (SymbolKind.File, SymbolKind.Package) + + +class TestErlangSymbolNames: + @pytest.mark.parametrize("project_with_ls", [LanguageServerId.ERLANG], indirect=True) + def test_no_symbol_name_contains_the_name_path_separator(self, project_with_ls: Project) -> None: + """No Erlang symbol may carry a `/` in its name, since that is the name path separator.""" + offenders: list[str] = [] + + def visit(symbol: UnifiedSymbolInformation) -> None: + if symbol["kind"] not in CONTAINER_KINDS and "/" in symbol["name"]: + offenders.append(symbol["name"]) + for child in symbol.get("children", []): + visit(child) + + for ls in project_with_ls.get_language_server_manager_or_raise().iter_language_servers(): + for root in ls.request_full_symbol_tree(): + visit(root) + + assert not offenders, f"Symbol names containing the name path separator: {sorted(set(offenders))}" + + @pytest.mark.parametrize("project_with_ls", [LanguageServerId.ERLANG], indirect=True) + def test_function_name_path_round_trips(self, project_with_ls: Project) -> None: + """The name path reported for a function must find that same function again.""" + retriever = LanguageServerSymbolRetriever(project_with_ls) + + symbol = retriever.find_unique(CREATE_USER_4, within_relative_path=MODELS_ERL) + assert symbol.symbol_kind == SymbolKind.Function + assert symbol.get_name_path() == CREATE_USER_4 + + # feeding the reported name path back in is what used to yield no match at all + assert [s.get_name_path() for s in retriever.find(symbol.get_name_path())] == [CREATE_USER_4] + + @pytest.mark.parametrize("project_with_ls", [LanguageServerId.ERLANG], indirect=True) + def test_arity_remains_part_of_the_name(self, project_with_ls: Project) -> None: + """The arity is kept rather than stripped: it is part of a function's identity in Erlang.""" + retriever = LanguageServerSymbolRetriever(project_with_ls) + + # models:create_order/3 and services:create_order/2 are different functions + models_create_order = retriever.find_unique(f"create_order{ARITY_SEPARATOR}3") + services_create_order = retriever.find_unique(f"create_order{ARITY_SEPARATOR}2") + + assert models_create_order.relative_path is not None + assert models_create_order.relative_path.replace("\\", "/") == "src/models.erl" + assert services_create_order.relative_path is not None + assert services_create_order.relative_path.replace("\\", "/") == "src/services.erl" + + @pytest.mark.parametrize("project_with_ls", [LanguageServerId.ERLANG], indirect=True) + def test_find_referencing_symbols_locates_a_function(self, project_with_ls: Project) -> None: + """The headline symptom of #1797: this used to raise `No symbol matching ...`.""" + retriever = LanguageServerSymbolRetriever(project_with_ls) + + references = retriever.find_referencing_symbols(CREATE_USER_4, MODELS_ERL) + + # create_user/4 is called from models.erl itself and from at least one other module + referencing_paths = {r.symbol.relative_path.replace("\\", "/") for r in references if r.symbol.relative_path is not None} + assert referencing_paths, f"Expected references to {CREATE_USER_4}, got none" + assert referencing_paths - {"src/models.erl"}, f"Expected cross-file references, got only {referencing_paths}" + + @pytest.mark.parametrize("project_with_ls", [LanguageServerId.ERLANG], indirect=True) + def test_names_without_an_arity_are_untouched(self, project_with_ls: Project) -> None: + """Records and macros have no arity, so normalization must leave their names alone.""" + retriever = LanguageServerSymbolRetriever(project_with_ls) + + user_record = retriever.find_unique("user", within_relative_path=RECORDS_HRL) + assert user_record.name == "user" diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index fa05866e..3bf738b7 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -545,9 +545,7 @@ class TestComputeWorkspaceHash: UPSTREAM_LAUNCHER = "/opt/homebrew/Cellar/jdtls/1.50.0/libexec/plugins/org.eclipse.equinox.launcher_1.7.0.jar" def _initial_settings(self) -> "SolidLSPSettings.CustomLSSettings": - from solidlsp.language_servers.eclipse_jdtls import INITIAL_VSCODE_JAVA_VERSION - - return SolidLSPSettings.CustomLSSettings({"vscode_java_version": INITIAL_VSCODE_JAVA_VERSION}) + return SolidLSPSettings.CustomLSSettings({"vscode_java_version": EclipseJDTLS.DependencyProvider.INITIAL_VSCODE_JAVA_VERSION}) def test_initial_default_mode_matches_pre_upstream_format(self) -> None: """Legacy carve-out: INITIAL default-mode hash MUST equal md5(repository_root_path)."""