Improve Haxe LS: configurable version/haxePath, top-level imports, CI docs, regression test

This commit is contained in:
Tobias Jansing committed 2026-04-10 17:49:48 +02:00
1 parent e9b6c0ff85
commit addccfa41f
4 files changed
+71 -28

No files matched your search

+2
View File
@@ -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
+3
View File
@@ -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"]
```
@@ -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):
+28
View File
@@ -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"