Add Haxe language support via vshaxe/haxe-language-server

Includes full setup with ls provider, tests, docs, etc.
This commit is contained in:
Tobias Jansing committed 2026-04-10 15:07:23 +02:00
1 parent ded6f9a93c
commit e9b6c0ff85
18 files changed
+772 -3

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Force LF line endings for Haxe files across platforms.
# The Haxe language server cannot properly parse/index files with CRLF endings, causing it to fail on Windows without this.
*.hxml text eol=lf
*.hx text eol=lf
+4
View File
@@ -386,6 +386,10 @@ jobs:
- name: Install ansible-core and ansible-lint (for Ansible language server tests)
shell: bash
run: uv run pip install ansible-core ansible-lint
- name: Install Haxe
uses: krdlab/setup-haxe@v2
with:
haxe-version: 4.3.7
- name: Install Elm
shell: bash
run: npm install -g elm@0.19.1-6
+1
View File
@@ -69,6 +69,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Add support for Lean 4** via built-in `lean --server` with cross-file reference support (requires `lean` and `lake` via [elan](https://github.com/leanprover/elan))
* **Add support for OCaml** via ocaml-lsp-server with cross-file reference support on OCaml 5.2+ (requires opam; see [setup guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md))
* **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+)
* **Add support for Haxe** via vshaxe/haxe-language-server. Requires Haxe compiler 3.4.0+ and Node.js. Auto-discovered from the vshaxe VSCode extension or configurable via `ls_path` in `ls_specific_settings`.
* **Add support for Fortran** via fortls language server (requires `pip install fortls`)
* **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md))
* **Add support for Julia** via LanguageServer.jl
+1 -1
View File
@@ -60,7 +60,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
The underlying language servers are typically open-source projects or at least freely available for use.
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
### The Serena JetBrains Plugin
+4 -1
View File
@@ -63,7 +63,10 @@ Some languages require additional installations or setup steps, as noted.
(requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration)
* **Haskell**
(automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects)
* **HLSL / GLSL / WGSL**
* **Haxe**
(requires Haxe compiler 3.4.0+ and Node.js; uses the [vshaxe language server](https://github.com/vshaxe/haxe-language-server);
automatically downloaded from Open VSX, or discovered from the vshaxe VSCode extension)
* **HLSL / GLSL / WGSL**
(uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded;
on macOS, requires Rust toolchain for building from source;
note: reference search is not supported by this language server)
+24
View File
@@ -484,6 +484,30 @@ Supported settings:
| `version` | `1.3.0` | Override the bundled version Serena downloads, or builds from source on macOS, when `ls_path` is not set. |
#### Haxe
Serena uses the [vshaxe/haxe-language-server](https://github.com/vshaxe/haxe-language-server) for Haxe support.
Requires Haxe compiler (3.4.0+) and Node.js.
The server is discovered in order: user-configured `ls_path`, system PATH, vshaxe VSCode extension, auto-download from Open VSX.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | auto-discovered | Path to the Haxe language server binary (e.g., `/path/to/server.js`). |
| `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). |
| `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):
```yaml
ls_specific_settings:
haxe:
buildFile: "build/debug.hxml"
renameSourceFolders: ["src", "lib"]
```
#### Java (`eclipse.jdt.ls`)
The following settings are supported for the Java language server:
+1
View File
@@ -332,6 +332,7 @@ markers = [
"julia: Julia language server tests",
"fortran: language server running for Fortran",
"haskell: Haskell language server tests",
"haxe: Haxe language server tests",
"yaml: language server running for YAML",
"powershell: language server running for PowerShell",
"pascal: language server running for Pascal (Free Pascal/Lazarus)",
+2 -1
View File
@@ -6,7 +6,8 @@ project_name: "project_name"
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
@@ -0,0 +1,386 @@
"""Haxe language server integration using vshaxe/haxe-language-server."""
import glob
import hashlib
import logging
import os
import pathlib
import shutil
import threading
from overrides import override
from solidlsp.ls import (
LanguageServerDependencyProvider,
LanguageServerDependencyProviderSinglePath,
LSPFileBuffer,
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Hover
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity, InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class HaxeLanguageServer(SolidLanguageServer):
"""Haxe language server integration using vshaxe/haxe-language-server.
Requires Haxe compiler (3.4.0+) and Node.js.
"""
_COMPILATION_TIMEOUT = 60.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""Creates a HaxeLanguageServer instance. Use LanguageServer.create() instead."""
super().__init__(
config,
repository_root_path,
None,
"haxe",
solidlsp_settings,
)
self._server_ready = threading.Event()
self._server_ready.set()
self._active_progress_tokens: set[str] = set()
self._progress_lock = threading.Lock()
@override
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
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"
@override
def _get_or_install_core_dependency(self) -> str:
"""Find the Haxe Language Server binary."""
# 1. Check for haxe-language-server in PATH
system_haxe_ls = shutil.which("haxe-language-server")
if system_haxe_ls:
log.info(f"Found system-installed haxe-language-server at {system_haxe_ls}")
return system_haxe_ls
# 2. Check VSCode extension locations
vscode_server_path = self._find_vscode_extension_server()
if vscode_server_path:
log.info(f"Found Haxe Language Server in VSCode extension at {vscode_server_path}")
return vscode_server_path
# 3. Check resource dir / download from Open VSX
haxe_ls_dir = os.path.join(self._ls_resources_dir, "haxe-lsp")
server_js_path = os.path.join(haxe_ls_dir, "bin", "server.js")
if os.path.exists(server_js_path):
log.info(f"Found Haxe Language Server at {server_js_path}")
return server_js_path
if shutil.which("node") is None:
raise FileNotFoundError(
"Haxe Language Server not found and Node.js is not installed (required to run it).\n"
"Install options:\n"
" 1. Install Node.js and re-run (auto-download will proceed)\n"
" 2. Install the vshaxe VSCode extension: code --install-extension nadako.vshaxe\n"
" 3. Set ls_path in serena_config.yml under ls_specific_settings.haxe"
)
downloaded_path = self._download_from_open_vsx(haxe_ls_dir)
if downloaded_path:
return downloaded_path
raise FileNotFoundError(
"Haxe Language Server not found. Install options:\n"
" 1. Install the vshaxe VSCode extension: code --install-extension nadako.vshaxe\n"
" 2. Set ls_path in serena_config.yml under ls_specific_settings.haxe"
)
@staticmethod
def _find_vscode_extension_server() -> str | None:
"""Search for the Haxe language server in VSCode extension directories."""
search_paths = [
os.path.expanduser("~/.vscode/extensions/nadako.vshaxe-*/bin/server.js"),
os.path.expanduser("~/.vscode-server/extensions/nadako.vshaxe-*/bin/server.js"),
os.path.expanduser("~/.vscode-insiders/extensions/nadako.vshaxe-*/bin/server.js"),
]
for pattern in search_paths:
matches = sorted(glob.glob(pattern), reverse=True) # newest version first
for match in matches:
if os.path.isfile(match):
return match
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.
"""
import tempfile
import zipfile
try:
import urllib.request
log.info("Downloading Haxe Language Server v%s from Open VSX...", cls._VSHAXE_VERSION)
vsix_path = os.path.join(tempfile.gettempdir(), "vshaxe.vsix")
urllib.request.urlretrieve(cls._VSHAXE_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")
# VSIX files are ZIP archives — extract bin/ contents
bin_dir = os.path.join(target_dir, "bin")
os.makedirs(bin_dir, exist_ok=True)
with zipfile.ZipFile(vsix_path, "r") as zf:
for entry in zf.namelist():
if "/bin/" in entry:
filename = entry.split("/bin/", 1)[-1]
if filename and ".." not in filename:
dest_path = os.path.join(bin_dir, filename)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
with zf.open(entry) as src, open(dest_path, "wb") as dst:
dst.write(src.read())
os.remove(vsix_path)
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}")
return server_js_path
log.error("Downloaded VSIX but server.js not found after extraction")
return None
except Exception:
log.exception("Failed to download Haxe Language Server from Open VSX")
return None
@override
def _create_launch_command(self, core_path: str) -> list[str]:
if core_path.endswith(".js"):
return ["node", core_path]
return [core_path, "--stdio"]
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [
"node_modules",
"export",
"dump",
]
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""Return initialize params for the Haxe Language Server.
displayArguments are resolved from user-configured buildFile or auto-discovered .hxml files.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
# 1. Check for user-configured .hxml path
configured_build_file = self._custom_settings.get("buildFile")
if configured_build_file:
log.info(f"Using user-configured Haxe build file: {configured_build_file}")
display_arguments = [configured_build_file]
else:
# 2. Auto-discover .hxml files recursively
display_arguments = self._discover_hxml_file(repository_absolute_path)
init_options: dict = {"displayArguments": display_arguments}
rename_source_folders = self._custom_settings.get("renameSourceFolders")
if rename_source_folders:
init_options["renameSourceFolders"] = rename_source_folders
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True},
"completion": {"completionItem": {"snippetSupport": True}},
"definition": {},
"references": {},
"documentSymbol": {
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"contentFormat": ["markdown", "plaintext"]},
"codeAction": {},
"rename": {},
"signatureHelp": {},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {},
"symbol": {},
},
},
"initializationOptions": init_options,
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore[return-value]
@staticmethod
def _discover_hxml_file(repository_absolute_path: str) -> list[str]:
"""Return the first .hxml file found, filtering out dependency directories.
For more control, set ``ls_specific_settings.haxe.buildFile`` in ``serena_config.yml``.
"""
max_depth = 5
skip_dirs = {"node_modules", "haxe_libraries", ".haxelib", "export", "dump", "bin", ".git", "build"}
for root, dirs, files in os.walk(repository_absolute_path):
# Skip dependency/build output directories
dirs[:] = [d for d in dirs if d not in skip_dirs]
depth = len(pathlib.Path(root).relative_to(repository_absolute_path).parts)
if depth > max_depth:
dirs.clear()
continue
for f in files:
if f.endswith(".hxml") and "haxe_libraries" not in root:
hxml_path = os.path.relpath(os.path.join(root, f), repository_absolute_path)
log.info(
"Auto-discovered Haxe build file: %s. To use a different file, set "
"ls_specific_settings.haxe.buildFile in serena_config.yml.",
hxml_path,
)
return [hxml_path]
log.info("No .hxml file found in project")
return []
@override
def _start_server(self) -> None:
"""Start the Haxe Language Server and wait for initial compilation.
Uses textDocument/publishDiagnostics as the primary compilation-complete signal
and $/progress tokens as a secondary signal.
"""
def diagnostics_handler(params: dict) -> None:
"""Signal compilation complete when diagnostics arrive.
Defers if progress tokens are still active to avoid a race condition.
"""
uri = params.get("uri", "unknown")
diags = params.get("diagnostics", [])
errors = [d for d in diags if d.get("severity") == DiagnosticSeverity.Error]
if errors:
log.warning("Haxe LSP diagnostics for %s: %d errors: %s", uri, len(errors), errors)
else:
log.info("Haxe LSP diagnostics for %s: clean (%d total)", uri, len(diags))
with self._progress_lock:
if not self._active_progress_tokens:
log.info("Haxe LSP: no active progress tokens — signalling compilation complete")
self._server_ready.set()
else:
log.info(
"Haxe LSP: diagnostics received but %d progress tokens still active — deferring",
len(self._active_progress_tokens),
)
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def register_capability_handler(params: dict) -> None:
# Haxe LS sends this but we don't need dynamic capability registration.
return
def work_done_progress_create(params: dict) -> dict:
"""Handle window/workDoneProgress/create — clear compilation event until tokens finish."""
token = str(params.get("token", ""))
log.debug(f"Haxe LSP workDoneProgress/create: token={token!r}")
with self._progress_lock:
self._active_progress_tokens.add(token)
self._server_ready.clear()
return {}
def progress_handler(params: dict) -> None:
"""Track $/progress begin/end to detect when all async compilation work finishes."""
token = str(params.get("token", ""))
value = params.get("value", {})
kind = value.get("kind")
if kind == "begin":
title = value.get("title", "")
log.info(f"Haxe LSP progress [{token}]: started - {title}")
with self._progress_lock:
self._active_progress_tokens.add(token)
self._server_ready.clear()
elif kind == "report":
pct = value.get("percentage")
msg = value.get("message", "")
pct_str = f" ({pct}%)" if pct is not None else ""
log.debug(f"Haxe LSP progress [{token}]: {msg}{pct_str}")
elif kind == "end":
msg = value.get("message", "")
log.info(f"Haxe LSP progress [{token}]: ended - {msg}")
with self._progress_lock:
self._active_progress_tokens.discard(token)
if not self._active_progress_tokens:
self._server_ready.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("window/workDoneProgress/create", work_done_progress_create)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", progress_handler)
self.server.on_notification("textDocument/publishDiagnostics", diagnostics_handler)
log.info("Starting Haxe server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
self.server.send.initialize(initialize_params)
self._server_ready.clear()
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": {}})
log.info("Waiting for Haxe LSP compilation to complete...")
if self._server_ready.wait(timeout=self._COMPILATION_TIMEOUT):
log.info("Haxe server compilation completed, server ready")
else:
log.warning(
"Haxe LSP did not signal compilation completion within %.0fs — responses may be incomplete",
self._COMPILATION_TIMEOUT,
)
@override
def request_hover(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> Hover | None:
"""Request hover info, returning None instead of raising on failure.
The Haxe language server does not provide hover for all symbol types (e.g. class
declarations), and may raise errors instead of returning None in those cases.
"""
try:
return super().request_hover(relative_file_path, line, column, file_buffer=file_buffer)
except SolidLSPException:
log.warning("Hover request failed for %s:%d:%d", relative_file_path, line, column, exc_info=True)
return None
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 5
+11
View File
@@ -69,6 +69,11 @@ class Language(str, Enum):
JULIA = "julia"
FORTRAN = "fortran"
HASKELL = "haskell"
HAXE = "haxe"
"""Haxe language server using vshaxe/haxe-language-server.
Requires Haxe compiler (3.4.0+) and Node.js.
Discovered from system PATH or vshaxe VSCode extension, otherwise downloaded from Open VSX.
"""
LEAN4 = "lean4"
GROOVY = "groovy"
VUE = "vue"
@@ -273,6 +278,8 @@ class Language(str, Enum):
)
case self.HASKELL:
return FilenameMatcher("*.hs", "*.lhs")
case self.HAXE:
return FilenameMatcher("*.hx")
case self.LEAN4:
return FilenameMatcher("*.lean")
case self.VUE:
@@ -493,6 +500,10 @@ class Language(str, Enum):
from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer
return HaskellLanguageServer
case self.HAXE:
from solidlsp.language_servers.haxe_language_server import HaxeLanguageServer
return HaxeLanguageServer
case self.LEAN4:
from solidlsp.language_servers.lean4_language_server import Lean4LanguageServer
+1
View File
@@ -255,6 +255,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
Language.CSHARP: [pytest.mark.csharp],
Language.FSHARP: [pytest.mark.fsharp],
Language.GO: [pytest.mark.go],
Language.HAXE: [pytest.mark.haxe],
Language.JAVA: [pytest.mark.java],
Language.KOTLIN: [pytest.mark.kotlin, pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM crashes on restart in CI")],
Language.LEAN4: [pytest.mark.lean4, pytest.mark.skipif(_sh.which("lean") is None, reason="Lean is not installed")],
@@ -0,0 +1,3 @@
export/
dump/
bin/
@@ -0,0 +1,3 @@
-cp src
-main Main
--no-output
@@ -0,0 +1,40 @@
import utils.Helper;
/**
* Main class for testing Haxe language server functionality.
*
* This class tests:
* - Symbol discovery (class, methods, fields)
* - Within-file references
* - Cross-file references to utils.Helper
*/
class Main {
var message:String;
var count:Int;
public function new() {
message = greet("World");
count = Helper.addNumbers(5, 10);
}
/**
* Greet someone by name.
*/
public function greet(name:String):String {
return "Hello, " + name + "!";
}
/**
* Calculate and return the formatted result.
*/
public function calculateResult():String {
var sum = Helper.addNumbers(count, 20);
var formatted = Helper.formatMessage(message);
return formatted + " (sum: " + Std.string(sum) + ")";
}
public static function main() {
var app = new Main();
Sys.println(app.calculateResult());
}
}
@@ -0,0 +1,29 @@
package utils;
/**
* Utility functions for the Haxe test application.
*
* This class provides helper functions used by the Main class.
*/
class Helper {
/**
* Format a message by adding brackets around it.
*/
public static function formatMessage(msg:String):String {
return "[ " + msg + " ]";
}
/**
* Add two numbers together.
*/
public static function addNumbers(x:Int, y:Int):Int {
return x + y;
}
/**
* Multiply two numbers.
*/
public static function multiplyNumbers(x:Int, y:Int):Int {
return x * y;
}
}
+9
View File
@@ -45,6 +45,7 @@ def serena_config():
Language.FSHARP,
Language.POWERSHELL,
Language.CPP_CCLS,
Language.HAXE,
Language.LEAN4,
]:
repo_path = get_repo_path(language)
@@ -196,6 +197,7 @@ class TestSerenaAgent:
pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.POWERSHELL, "Greet-User", "Function", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp),
pytest.param(Language.HAXE, "Main", "Class", "Main.hx", marks=pytest.mark.haxe),
pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4),
],
indirect=["serena_agent"],
@@ -297,6 +299,13 @@ class TestSerenaAgent:
pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.POWERSHELL, "Greet-User", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp),
pytest.param(
Language.HAXE,
"addNumbers",
os.path.join("src", "utils", "Helper.hx"),
os.path.join("src", "Main.hx"),
marks=pytest.mark.haxe,
),
pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4),
],
indirect=["serena_agent"],
View File
Whitespace-only changes.
+249
View File
@@ -0,0 +1,249 @@
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@pytest.mark.haxe
class TestHaxeLanguageServer:
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer) -> None:
"""Test that the Haxe language server starts successfully."""
assert language_server.is_running()
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
symbols = language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet method not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "calculateResult"), "calculateResult method not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Helper"), "Helper class not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "addNumbers"), "addNumbers method not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "formatMessage"), "formatMessage method not found in symbol tree"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None:
"""Test that symbol names do not contain unexpected formatting characters."""
all_symbols = request_all_symbols(language_server)
malformed_symbols = []
for s in all_symbols:
if has_malformed_name(s):
malformed_symbols.append(s)
if malformed_symbols:
pytest.fail(
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None:
file_path = os.path.join("src", "Main.hx")
all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None)
assert greet_symbol is not None, "Could not find 'greet' symbol in Main.hx"
sel_start = greet_symbol["selectionRange"]["start"]
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert refs, f"Expected non-empty references for greet but got {refs=}"
# Convert references to a comparable format
actual_locations = [
{
"uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))),
"line": ref["range"]["start"]["line"],
}
for ref in refs
]
# greet is called on line 15 (0-indexed) in Main.hx: `message = greet("World");`
call_site = {"uri_suffix": "Main.hx", "line": 15}
assert call_site in actual_locations, f"Expected reference to greet at line 15 in Main.hx, got {actual_locations}"
# All references should be within Main.hx (greet is not used in other files)
assert all(loc["uri_suffix"] == "Main.hx" for loc in actual_locations), (
f"Expected all greet references in Main.hx, got {actual_locations}"
)
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None:
# Test addNumbers which is defined in Helper.hx and used in Main.hx
helper_path = os.path.join("src", "utils", "Helper.hx")
all_symbols, _ = language_server.request_document_symbols(helper_path).get_all_symbols_and_roots()
add_numbers_symbol = next((s for s in all_symbols if s.get("name") == "addNumbers"), None)
assert add_numbers_symbol is not None, "Could not find 'addNumbers' symbol in Helper.hx"
sel_start = add_numbers_symbol["selectionRange"]["start"]
refs = language_server.request_references(helper_path, sel_start["line"], sel_start["character"])
assert refs, f"Expected non-empty references for addNumbers but got {refs=}"
# Convert references to a comparable format
actual_locations = [
{
"uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))),
"line": ref["range"]["start"]["line"],
}
for ref in refs
]
# addNumbers is called on line 16 in Main.hx: `count = Helper.addNumbers(5, 10);`
call_site_1 = {"uri_suffix": "Main.hx", "line": 16}
assert call_site_1 in actual_locations, f"Expected reference to addNumbers at line 16 in Main.hx, got {actual_locations}"
# addNumbers is called on line 30 in Main.hx: `var sum = Helper.addNumbers(count, 20);`
call_site_2 = {"uri_suffix": "Main.hx", "line": 30}
assert call_site_2 in actual_locations, f"Expected reference to addNumbers at line 30 in Main.hx, got {actual_locations}"
# Verify cross-file: at least one reference is in Main.hx (different file from definition)
main_refs = [loc for loc in actual_locations if loc["uri_suffix"] == "Main.hx"]
assert len(main_refs) >= 2, f"Expected at least 2 references in Main.hx (lines 16 and 30), got {main_refs}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_document_symbols_structure(self, language_server: SolidLanguageServer) -> None:
file_path = os.path.join("src", "Main.hx")
result = language_server.request_document_symbols(file_path)
all_symbols, roots = result.get_all_symbols_and_roots()
# Main class should be a root symbol
main_symbol = None
for sym in roots:
if sym.get("name") == "Main":
main_symbol = sym
break
assert main_symbol is not None, "Main class not found as root symbol"
assert main_symbol.get("kind") in (SymbolKind.Class, SymbolKind.Struct), f"Expected Main to be Class, got {main_symbol.get('kind')}"
# Check that methods and fields exist and are children of Main
child_names = {s.get("name") for s in all_symbols if s.get("name") != "Main"}
assert "greet" in child_names, "greet method not found in symbols"
assert "calculateResult" in child_names, "calculateResult method not found in symbols"
assert "message" in child_names, "message field not found in symbols"
assert "count" in child_names, "count field not found in symbols"
# Verify symbol kinds for specific symbols
for sym in all_symbols:
if sym.get("name") == "greet":
assert sym.get("kind") in (
SymbolKind.Method,
SymbolKind.Function,
), f"Expected greet to be Method/Function, got {sym.get('kind')}"
if sym.get("name") == "message":
assert sym.get("kind") in (
SymbolKind.Field,
SymbolKind.Variable,
SymbolKind.Property,
), f"Expected message to be Field/Variable, got {sym.get('kind')}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_workspace_symbol(self, language_server: SolidLanguageServer) -> None:
result = language_server.request_workspace_symbol("Helper")
assert result is not None, "Workspace symbol search returned None"
assert len(result) > 0, "Workspace symbol search returned no results"
assert any("Helper" in str(s.get("name", "")) for s in result), f"Expected at least one result containing 'Helper', got {result}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_go_to_definition_within_file(self, language_server: SolidLanguageServer) -> None:
"""Go to definition of greet from its call site in Main.hx -- should resolve within the same file."""
main_path = os.path.join("src", "Main.hx")
# Line 16 (0-indexed: 15): `message = greet("World");`
# `greet` starts at character 12 on that line.
definitions = language_server.request_definition(main_path, 15, 12)
assert definitions, "Expected to find definition for greet"
assert any("Main.hx" in d.get("uri", d.get("relativePath", "")) for d in definitions), (
f"Expected definition in Main.hx, got {definitions}"
)
# greet is defined on line 23 (0-indexed: 22) in Main.hx
definition_lines = [d["range"]["start"]["line"] for d in definitions if "Main.hx" in d.get("uri", d.get("relativePath", ""))]
assert 22 in definition_lines, f"Expected definition of greet at line 22 in Main.hx, got lines {definition_lines}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_go_to_definition(self, language_server: SolidLanguageServer) -> None:
"""Go to definition of addNumbers from a call site in Main.hx -- should resolve to Helper.hx."""
main_path = os.path.join("src", "Main.hx")
# Line 17 (0-indexed: 16): `\t\tcount = Helper.addNumbers(5, 10);`
# `addNumbers` starts at character 17 (0-indexed) on that line.
definitions = language_server.request_definition(main_path, 16, 17)
assert definitions, "Expected to find definition for addNumbers"
assert any("Helper.hx" in d.get("uri", d.get("relativePath", "")) for d in definitions), (
f"Expected definition in Helper.hx, got {definitions}"
)
# addNumbers is defined on line 18 (0-indexed) in Helper.hx
definition_lines = [d["range"]["start"]["line"] for d in definitions if "Helper.hx" in d.get("uri", d.get("relativePath", ""))]
assert 18 in definition_lines, f"Expected definition of addNumbers at line 18 in Helper.hx, got lines {definition_lines}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_hover(self, language_server: SolidLanguageServer) -> None:
file_path = os.path.join("src", "Main.hx")
result = language_server.request_document_symbols(file_path)
all_symbols, _ = result.get_all_symbols_and_roots()
greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None)
assert greet_symbol is not None, "Could not find 'greet' symbol"
sel_start = greet_symbol["selectionRange"]["start"]
hover = language_server.request_hover(file_path, sel_start["line"], sel_start["character"])
assert hover is not None, "Hover returned None for greet method"
hover_str = str(hover)
assert "String" in hover_str or "greet" in hover_str, f"Expected hover to contain type info, got {hover_str}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_hover_on_class_declaration(self, language_server: SolidLanguageServer) -> None:
"""Hovering on a class name should return hover info."""
file_path = os.path.join("src", "Main.hx")
result = language_server.request_document_symbols(file_path)
all_symbols, _ = result.get_all_symbols_and_roots()
main_symbol = next((s for s in all_symbols if s.get("name") == "Main"), None)
assert main_symbol is not None
sel_start = main_symbol["selectionRange"]["start"]
hover = language_server.request_hover(file_path, sel_start["line"], sel_start["character"])
assert hover is not None, "Hover on class declaration returned None"
hover_str = str(hover)
assert "Main" in hover_str, f"Expected hover to contain 'Main', got {hover_str}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_rename_symbol(self, language_server: SolidLanguageServer) -> None:
file_path = os.path.join("src", "Main.hx")
result = language_server.request_document_symbols(file_path)
all_symbols, _ = result.get_all_symbols_and_roots()
greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None)
assert greet_symbol is not None, "Could not find 'greet' symbol"
sel_start = greet_symbol["selectionRange"]["start"]
edits = language_server.request_rename_symbol_edit(file_path, sel_start["line"], sel_start["character"], "sayHello")
assert edits is not None, "Rename returned None"
# Verify edits contain changes (WorkspaceEdit has 'changes' or 'documentChanges')
edits_str = str(edits)
assert "Main.hx" in edits_str, f"Expected rename edits for Main.hx, got {edits}"
assert "sayHello" in edits_str, f"Expected new name 'sayHello' in rename edits, got {edits}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_completions(self, language_server: SolidLanguageServer) -> None:
"""Request completions after Helper. in calculateResult — should return Helper's static methods."""
file_path = os.path.join("src", "Main.hx")
# Line 31 (0-indexed: 30): `var sum = Helper.addNumbers(count, 20);`
# Trigger completions after `Helper.` — character 19 is right after the dot.
completions = language_server.request_completions(file_path, 30, 19)
assert completions, "Expected non-empty completions after Helper."
completion_texts = [c.get("completionText", c.get("label", "")) for c in completions]
assert "addNumbers" in completion_texts, f"Expected 'addNumbers' in completions after Helper., got {completion_texts[:10]}"
@pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
def test_document_overview(self, language_server: SolidLanguageServer) -> None:
overview = language_server.request_document_overview(os.path.join("src", "Main.hx"))
assert overview, "Document overview returned empty list"
symbol_names = [s.get("name", "") for s in overview]
assert any("Main" in name for name in symbol_names), f"Expected 'Main' in overview, got {symbol_names}"
main_entry = next((s for s in overview if s.get("name") == "Main"), None)
assert main_entry is not None, f"Expected 'Main' entry in overview, got {symbol_names}"
assert main_entry.get("kind") in (SymbolKind.Class, SymbolKind.Struct), (
f"Expected Main to be Class/Struct in overview, got kind {main_entry.get('kind')}"
)
for s in overview:
assert s.get("name"), "Symbol missing 'name' field"
assert s.get("kind"), "Symbol missing 'kind' field"