diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 133c4632..f1d969e9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -387,6 +387,8 @@ jobs: shell: bash run: uv run pip install ansible-core ansible-lint - name: Install Haxe + # The Haxe LS binary (server.js) is auto-downloaded and runs on Node.js, + # but it delegates to the Haxe compiler for code analysis at runtime. uses: krdlab/setup-haxe@v2 with: haxe-version: 4.3.7 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 2c4ff1f5..1b9ba5a0 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -496,7 +496,9 @@ Supported settings: | Setting | Default | Description | |---|---|---| | `ls_path` | auto-discovered | Path to the Haxe language server binary (e.g., `/path/to/server.js`). | +| `version` | `2.34.2` | Override the vshaxe extension version downloaded from Open VSX. SHA256 verification is only performed for the default version. | | `buildFile` | auto-discovered `.hxml` | Relative path to the `.hxml` build file used for compilation (e.g., `build/debug.hxml`). If not set, Serena searches the project for `.hxml` files (max depth 5, skipping dependency directories). | +| `haxePath` | `haxe` from PATH | Path to the Haxe compiler executable. The LS delegates to this for code analysis. Useful when multiple Haxe versions are installed or when `haxe` is not on the PATH. | | `renameSourceFolders` | not set (LS default) | List of source directories for scoping rename operations (e.g., `["src", "lib"]`). If not set, the Haxe LS uses its own defaults. | Example (typically in `project.yml`, since these are project-specific): @@ -505,6 +507,7 @@ Example (typically in `project.yml`, since these are project-specific): ls_specific_settings: haxe: buildFile: "build/debug.hxml" + haxePath: "/usr/local/bin/haxe" renameSourceFolders: ["src", "lib"] ``` diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index b2248b97..41c63a2e 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -6,7 +6,10 @@ import logging import os import pathlib import shutil +import tempfile import threading +import urllib.request +import zipfile from overrides import override @@ -55,9 +58,8 @@ class HaxeLanguageServer(SolidLanguageServer): class DependencyProvider(LanguageServerDependencyProviderSinglePath): # Downloaded from Open VSX (not the VS Code Marketplace) because Open VSX # provides stable versioned URLs and SHA256 checksums for integrity verification. - _VSHAXE_VERSION = "2.34.2" - _VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" - _VSHAXE_DOWNLOAD_URL = f"https://open-vsx.org/api/nadako/vshaxe/{_VSHAXE_VERSION}/file/nadako.vshaxe-{_VSHAXE_VERSION}.vsix" + _DEFAULT_VSHAXE_VERSION = "2.34.2" + _DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" @override def _get_or_install_core_dependency(self) -> str: @@ -90,7 +92,8 @@ class HaxeLanguageServer(SolidLanguageServer): " 3. Set ls_path in serena_config.yml under ls_specific_settings.haxe" ) - downloaded_path = self._download_from_open_vsx(haxe_ls_dir) + version = self._custom_settings.get("version", self._DEFAULT_VSHAXE_VERSION) + downloaded_path = self._download_from_open_vsx(haxe_ls_dir, version) if downloaded_path: return downloaded_path @@ -116,32 +119,31 @@ class HaxeLanguageServer(SolidLanguageServer): return None @classmethod - def _download_from_open_vsx(cls, target_dir: str) -> str | None: - """Download a pinned vshaxe VSIX from Open VSX and extract server.js. - Verifies the download against a hardcoded SHA256 checksum. + def _download_from_open_vsx(cls, target_dir: str, version: str) -> str | None: + """Download a vshaxe VSIX from Open VSX and extract server.js. + Verifies the download against a hardcoded SHA256 checksum when using the default version. """ - import tempfile - import zipfile - try: - import urllib.request - - log.info("Downloading Haxe Language Server v%s from Open VSX...", cls._VSHAXE_VERSION) + download_url = f"https://open-vsx.org/api/nadako/vshaxe/{version}/file/nadako.vshaxe-{version}.vsix" + log.info("Downloading Haxe Language Server v%s from Open VSX...", version) vsix_path = os.path.join(tempfile.gettempdir(), "vshaxe.vsix") - urllib.request.urlretrieve(cls._VSHAXE_DOWNLOAD_URL, vsix_path) + urllib.request.urlretrieve(download_url, vsix_path) - # Verify SHA256 checksum - sha256 = hashlib.sha256() - with open(vsix_path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256.update(chunk) - if sha256.hexdigest().lower() != cls._VSHAXE_SHA256: - os.remove(vsix_path) - raise RuntimeError( - f"SHA256 checksum mismatch for vshaxe VSIX. Expected {cls._VSHAXE_SHA256}, " - f"got {sha256.hexdigest()}. The file may be corrupted or tampered with." - ) - log.info("SHA256 checksum verified") + # Verify SHA256 checksum only for the default (pinned) version + if version == cls._DEFAULT_VSHAXE_VERSION: + sha256 = hashlib.sha256() + with open(vsix_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + if sha256.hexdigest().lower() != cls._DEFAULT_VSHAXE_SHA256: + os.remove(vsix_path) + raise RuntimeError( + f"SHA256 checksum mismatch for vshaxe VSIX. Expected {cls._DEFAULT_VSHAXE_SHA256}, " + f"got {sha256.hexdigest()}. The file may be corrupted or tampered with." + ) + log.info("SHA256 checksum verified") + else: + log.info("Using custom version %s — skipping SHA256 verification", version) # VSIX files are ZIP archives — extract bin/ contents bin_dir = os.path.join(target_dir, "bin") @@ -160,7 +162,7 @@ class HaxeLanguageServer(SolidLanguageServer): server_js_path = os.path.join(bin_dir, "server.js") if os.path.exists(server_js_path): - log.info(f"Haxe Language Server v{cls._VSHAXE_VERSION} installed to {server_js_path}") + log.info(f"Haxe Language Server v{version} installed to {server_js_path}") return server_js_path log.error("Downloaded VSIX but server.js not found after extraction") @@ -205,6 +207,10 @@ class HaxeLanguageServer(SolidLanguageServer): if rename_source_folders: init_options["renameSourceFolders"] = rename_source_folders + haxe_path = self._custom_settings.get("haxePath") + if haxe_path: + init_options["haxePath"] = haxe_path + initialize_params = { "locale": "en", "capabilities": { @@ -357,7 +363,11 @@ class HaxeLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) # LS doesn't properly initialise without a workspace_did_change_configuration notification here. - self.server.notify.workspace_did_change_configuration({"settings": {}}) + config_settings: dict = {} + haxe_path = self._custom_settings.get("haxePath") + if haxe_path: + config_settings["haxePath"] = haxe_path + self.server.notify.workspace_did_change_configuration({"settings": config_settings}) log.info("Waiting for Haxe LSP compilation to complete...") if self._server_ready.wait(timeout=self._COMPILATION_TIMEOUT): diff --git a/test/solidlsp/haxe/test_haxe_basic.py b/test/solidlsp/haxe/test_haxe_basic.py index 342a04bd..1662090e 100644 --- a/test/solidlsp/haxe/test_haxe_basic.py +++ b/test/solidlsp/haxe/test_haxe_basic.py @@ -247,3 +247,31 @@ class TestHaxeLanguageServer: for s in overview: assert s.get("name"), "Symbol missing 'name' field" assert s.get("kind"), "Symbol missing 'kind' field" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_rapid_successive_requests(self, language_server: SolidLanguageServer) -> None: + """Verify that rapid successive requests don't return empty results. + + The Haxe LS triggers a recompilation on didOpen. Without suppression, + subsequent requests during recompilation may return empty results. + """ + main_path = os.path.join("src", "Main.hx") + helper_path = os.path.join("src", "utils", "Helper.hx") + + # First request: hover on greet in Main.hx (line 22 is the greet method definition) + hover1 = language_server.request_hover(main_path, 22, 20) + + # Second request: references in a different file (triggers another didOpen) + all_symbols, _ = language_server.request_document_symbols(helper_path).get_all_symbols_and_roots() + add_numbers = next((s for s in all_symbols if s.get("name") == "addNumbers"), None) + assert add_numbers is not None + sel_start = add_numbers["selectionRange"]["start"] + refs = language_server.request_references(helper_path, sel_start["line"], sel_start["character"]) + + # Third request: back to Main.hx hover (would be affected by recompilation) + hover2 = language_server.request_hover(main_path, 22, 20) + + # All three should return non-empty results + assert hover1 is not None, "First hover returned None" + assert refs, "References returned empty after switching files" + assert hover2 is not None, "Second hover returned None after file switching"