diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a1da894b --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1f20f585..f1d969e9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -386,6 +386,12 @@ 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 + # 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 - name: Install Elm shell: bash run: npm install -g elm@0.19.1-6 diff --git a/CHANGELOG.md b/CHANGELOG.md index b27daa90..83ff8b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: git commit id in Serena version strings was incorrect * Language Servers: + - 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 Crystal language support (uses [Crystalline](https://github.com/elbywan/crystalline) language server) - Fix: Reactivation of the same project restarted language servers #1280 diff --git a/README.md b/README.md index 1a2738e3..83c6bad2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,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 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 65abbd47..33fcd54d 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -64,7 +64,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) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 798daa3c..fe2b0d9e 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -475,6 +475,33 @@ 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`). | +| `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): + +```yaml +ls_specific_settings: + haxe: + buildFile: "build/debug.hxml" + haxePath: "/usr/local/bin/haxe" + renameSourceFolders: ["src", "lib"] +``` + #### Java (`eclipse.jdt.ls`) The following settings are supported for the Java language server: diff --git a/pyproject.toml b/pyproject.toml index 210603a9..fc7f1355 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -331,6 +331,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)", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 9a2c92e5..ada6fd67 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -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 diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py new file mode 100644 index 00000000..41c63a2e --- /dev/null +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -0,0 +1,396 @@ +"""Haxe language server integration using vshaxe/haxe-language-server.""" + +import glob +import hashlib +import logging +import os +import pathlib +import shutil +import tempfile +import threading +import urllib.request +import zipfile + +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. + _DEFAULT_VSHAXE_VERSION = "2.34.2" + _DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" + + @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" + ) + + 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 + + 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, 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. + """ + try: + 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(download_url, vsix_path) + + # 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") + 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{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 + + haxe_path = self._custom_settings.get("haxePath") + if haxe_path: + init_options["haxePath"] = haxe_path + + 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. + 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): + 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 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 4ec425c1..ab1a4038 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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 diff --git a/test/conftest.py b/test/conftest.py index 48443484..ce5761c6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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")], diff --git a/test/resources/repos/haxe/test_repo/.gitignore b/test/resources/repos/haxe/test_repo/.gitignore new file mode 100644 index 00000000..e714affd --- /dev/null +++ b/test/resources/repos/haxe/test_repo/.gitignore @@ -0,0 +1,3 @@ +export/ +dump/ +bin/ diff --git a/test/resources/repos/haxe/test_repo/build.hxml b/test/resources/repos/haxe/test_repo/build.hxml new file mode 100644 index 00000000..40cd557d --- /dev/null +++ b/test/resources/repos/haxe/test_repo/build.hxml @@ -0,0 +1,3 @@ +-cp src +-main Main +--no-output diff --git a/test/resources/repos/haxe/test_repo/src/Main.hx b/test/resources/repos/haxe/test_repo/src/Main.hx new file mode 100644 index 00000000..a0b3252e --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/Main.hx @@ -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()); + } +} diff --git a/test/resources/repos/haxe/test_repo/src/utils/Helper.hx b/test/resources/repos/haxe/test_repo/src/utils/Helper.hx new file mode 100644 index 00000000..0c899504 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/utils/Helper.hx @@ -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; + } +} diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index c2c1e61e..01cd29d1 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -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"], diff --git a/test/solidlsp/haxe/__init__.py b/test/solidlsp/haxe/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/haxe/test_haxe_basic.py b/test/solidlsp/haxe/test_haxe_basic.py new file mode 100644 index 00000000..1662090e --- /dev/null +++ b/test/solidlsp/haxe/test_haxe_basic.py @@ -0,0 +1,277 @@ +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" + + @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"