From 1ab1c460cba047a6c4fcb3132fa80582f050cd55 Mon Sep 17 00:00:00 2001 From: Erik M Jacobs Date: Tue, 12 May 2026 10:15:32 -0400 Subject: [PATCH 1/7] feat: add GDScript (Godot Engine) language server support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1446. Adds Language.GDSCRIPT backed by a new TCPLanguageServer that extends LanguageServerInterface directly — no intermediate transport abstraction layer. Changes: - ls_process.py: adds TCPConnectionInfo dataclass and TCPLanguageServer, a LanguageServerInterface subclass that manages a TCP socket directly (mirroring how StdioLanguageServer manages a subprocess). Stop order closes the socket before the BufferedReader wrapper to avoid a deadlock where BufferedReader.close() blocks on its internal read lock while readline() holds it waiting for data. - ls_config.py: adds Language.GDSCRIPT with .gd/.gdscript file matcher. - language_servers/godot_language_server.py: GodotLanguageServer connects to the Godot editor's TCP LSP (port 6008, same for Godot 3 and 4), registers Godot-specific notification handlers, and performs the initialize/initialized handshake. Godot version is auto-detected from config_version in project.godot. - docs: adds GDScript to the language support list and a dedicated setup guide covering prerequisites, setup steps, performance characteristics (no workspace/symbol support → first full scan is slow, disk cache persists across sessions), and known limitations. Tested against Godot 4.6.1 on Linux (Fedora 41): TCP connection, initialize handshake, textDocument/documentSymbol returning full symbol tree, and clean shutdown in <1s. Co-Authored-By: Claude Sonnet 4.6 --- docs/01-about/020_programming-languages.md | 4 + .../godot_gdscript_setup_guide_for_serena.md | 78 +++++++++ .../language_servers/godot_language_server.py | 127 ++++++++++++++ src/solidlsp/ls_config.py | 12 ++ src/solidlsp/ls_process.py | 155 ++++++++++++++++++ 5 files changed, 376 insertions(+) create mode 100644 docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md create mode 100644 src/solidlsp/language_servers/godot_language_server.py diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 95601e85..4ba86b77 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -71,6 +71,10 @@ Some languages require additional installations or setup steps, as noted. (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** (requires installation of fortls: `pip install fortls`) +* **GDScript** (Godot Engine) + (requires the Godot editor to be running with its built-in LSP enabled — default on port 6008; + Serena connects over TCP and does not launch Godot itself; + see the [GDScript Setup Guide](../03-special-guides/godot_gdscript_setup_guide_for_serena) for details) * **Go** (requires installation of `gopls`) * **Groovy** diff --git a/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md b/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md new file mode 100644 index 00000000..8a5520c4 --- /dev/null +++ b/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md @@ -0,0 +1,78 @@ +# GDScript (Godot Engine) Setup Guide for Serena + +This guide explains how to prepare a Godot project so that Serena can provide code intelligence for GDScript (`.gd`) files via Godot's built-in Language Server Protocol (LSP) support. + +Unlike most language servers, Serena does **not** launch a separate LSP process for GDScript. Instead, Serena connects over TCP to the LSP server that the Godot editor itself runs while it is open. This means the Godot editor must already be running with your project loaded before you start Serena. + +--- +## Prerequisites + +- Godot Engine 3.x or 4.x installed and available on your system. +- A Godot project with a `project.godot` file at the project root. + +No additional language server needs to be installed — Godot ships with a built-in LSP server that is enabled by default. + +--- +## How It Works + +When a Godot project is open in the editor, the editor listens for LSP connections on **TCP port 6008** (the same default for both Godot 3 and Godot 4). Serena connects to this port and communicates using standard LSP Content-Length framing. + +Serena automatically detects which major version of Godot your project targets by reading the `config_version` field from `project.godot`: + +- `config_version` ≥ 5 → Godot 4 +- `config_version` = 4 → Godot 3 + +No additional configuration is needed for version detection. + +--- +## Setup Steps + +1. Open your Godot project in the Godot editor and allow it to finish loading. + +2. Verify that the built-in LSP is enabled (it is on by default): + - Go to **Editor → Editor Settings → Network → Language Server** + - Confirm **"Use Language Server"** is checked. + - Confirm the port is **6008** (the default). + +3. Add `gdscript` to the `languages` list in your project's Serena configuration: + ```yaml + # .serena/project.yml + languages: + - gdscript + ``` + +4. Start Serena in your project root. Serena will connect to the already-running Godot editor automatically. + +--- +## Using Serena with GDScript + +- Serena recognizes `.gd` and `.gdscript` files. The language identifier used in LSP communication is `gdscript`. +- The Godot editor must remain open for the entire Serena session. If the editor is closed, Serena will lose its connection and will need to be restarted once the editor is open again. +- On first use, you may see a brief delay while Serena establishes the TCP connection and the editor indexes your project files — this is expected. + +--- +## Performance Note + +Godot's built-in LSP does not implement the `workspace/symbol` request (global symbol search across the whole project). As a result, when Serena performs a workspace-wide symbol search — for example, calling `find_symbol` without a `relative_path` — it must fall back to sending a `textDocument/documentSymbol` request for **every `.gd` file** in the project individually. + +For large projects, this initial full-project scan can take **30–60 seconds or more**. + +To mitigate this: + +- **Pass `relative_path` whenever possible.** Narrowing a search to a specific file or subdirectory avoids scanning the entire workspace. +- **Let the first full scan complete.** Serena caches the results to disk after the initial scan. Subsequent Serena sessions will use the on-disk cache and start instantly. + +--- +## Known Limitations + +| Limitation | Detail | +|---|---| +| No `workspace/symbol` support | Godot's LSP does not implement this request. All symbol lookups fall back to per-file `documentSymbol` requests, making the first full-project scan slow. | +| Editor must stay open | Serena requires the Godot editor to be running throughout the session. Closing the editor breaks the TCP connection; restart Serena after reopening the editor. | +| Slower workspace operations | `find_symbol` and `find_references` across the whole workspace are slower than for languages whose LSP servers support native workspace-wide queries. | + +--- +## Reference + +- Godot LSP documentation: [https://docs.godotengine.org/en/stable/tutorials/editor/external_editor.html](https://docs.godotengine.org/en/stable/tutorials/editor/external_editor.html) +- GDScript language reference: [https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/) diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py new file mode 100644 index 00000000..c90ce3fd --- /dev/null +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -0,0 +1,127 @@ +"""GDScript language server for Godot Engine projects. + +Connects to an already-running Godot editor via TCP on port 6008. +Both Godot 3 and Godot 4 (tested through 4.6.x) use this port. + +The editor must be open with its built-in language server enabled (default). +""" + +import logging +import os +import pathlib +from collections.abc import Callable + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# config_version in project.godot: 5 → Godot 4, 4 → Godot 3 +_GODOT4_CONFIG_VERSION = 5 + +LSP_PORT = 6008 + + +class GodotLanguageServer(SolidLanguageServer): + """GDScript language server that connects to a running Godot editor. + + Both Godot 3 and Godot 4 expose an LSP server on TCP port 6008. + The Godot editor must already be running — this class connects to it + rather than launching it. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + self._godot_version = self._detect_godot_version(repository_root_path) + log.info("Detected Godot version %d for project at %s", self._godot_version, repository_root_path) + self._conn_info = TCPConnectionInfo(host="127.0.0.1", port=LSP_PORT) + + # Dummy ProcessLaunchInfo — _create_language_server_interface() ignores it + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=""), "gdscript", solidlsp_settings) + + @staticmethod + def _detect_godot_version(repo_path: str) -> int: + """Read project.godot to determine the major Godot version. Defaults to 4.""" + project_file = os.path.join(repo_path, "project.godot") + try: + with open(project_file, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith("config_version="): + version = int(line.split("=", 1)[1]) + return 4 if version >= _GODOT4_CONFIG_VERSION else 3 + except (FileNotFoundError, ValueError, OSError): + pass + return 4 + + def _create_language_server_interface( + self, + process_launch_info: ProcessLaunchInfo, + logging_fn: Callable[[str, str, StringDict | str], None] | None, + ) -> LanguageServerInterface: + return TCPLanguageServer( + connection_info=self._conn_info, + language=self.language, + determine_log_level=self._determine_log_level, + logger=logging_fn, + request_timeout=30.0, + ) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + root_uri = pathlib.Path(repository_absolute_path).as_uri() + params = { + "processId": os.getpid(), + "rootUri": root_uri, + "rootPath": repository_absolute_path, + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "declaration": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + } + return params # type: ignore + + def _start_server(self) -> None: + def do_nothing(params: dict) -> None: + return + + # Godot sends this notification immediately on connect to report the open project path. + self.server.on_notification("gdscript_client/changeWorkspace", do_nothing) + # Godot-specific capability advertisement (not standard LSP). + self.server.on_notification("gdscript/capabilities", do_nothing) + self.server.on_notification("window/logMessage", lambda msg: log.info("LSP: window/logMessage: %s", msg)) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_request("client/registerCapability", lambda params: None) + + log.info("Connecting to Godot LSP at %s:%d", self._conn_info.host, self._conn_info.port) + self.server.start() + + initialize_params = self._get_initialize_params(self.repository_root_path) + log.info("Sending LSP initialize request to Godot") + self.server.send.initialize(initialize_params) + self.server.notify.initialized({}) + log.info("Godot LSP initialized") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 0817c3a3..2e2c5c3a 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -123,6 +123,12 @@ class Language(str, Enum): by the same server, since SPARK is distinguished by pragmas/aspects in source rather than by file extension. """ + GDSCRIPT = "gdscript" + """GDScript language server for Godot Engine projects (Godot 3 and 4). + Connects to the Godot editor's built-in LSP server over TCP (port 6008). + The editor must already be running with its built-in LSP enabled (default). + Supports .gd and .gdscript files. + """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -472,6 +478,8 @@ class Language(str, Enum): return FilenameMatcher(".bsl", ".os") case self.ADA: return FilenameMatcher(".ads", ".adb", ".ada", case_sensitive=False) + case self.GDSCRIPT: + return FilenameMatcher(".gd", ".gdscript") case self.HTML: return FilenameMatcher(".html", ".htm") case self.SCSS: @@ -727,6 +735,10 @@ class Language(str, Enum): from solidlsp.language_servers.ada_language_server import AdaLanguageServer return AdaLanguageServer + case self.GDSCRIPT: + from solidlsp.language_servers.godot_language_server import GodotLanguageServer + + return GodotLanguageServer case self.HTML: from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 8bbf6d02..2bc5849d 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -3,6 +3,7 @@ import json import logging import os import platform +import socket import subprocess import threading import time @@ -620,3 +621,157 @@ class StdioLanguageServer(LanguageServerInterface): # Log the error but don't raise to prevent cascading failures log.error(f"Failed to write to stdin: {e}") return + + +@dataclass +class TCPConnectionInfo: + """Connection parameters for an LSP server reachable over a TCP socket.""" + + host: str = "127.0.0.1" + port: int = 6008 + connection_timeout: float = 30.0 + retry_interval: float = 1.0 + + +class TCPLanguageServer(LanguageServerInterface): + """LanguageServerInterface that connects to an already-running LSP server over TCP. + + Unlike :class:`StdioLanguageServer`, this class does not own the server process — it + connects to an externally-managed server (e.g. the Godot editor) and simply closes + the socket on stop. No LSP ``shutdown``/``exit`` sequence is sent, because the remote + server is expected to keep running after Serena disconnects. + """ + + def __init__( + self, + connection_info: TCPConnectionInfo, + language: Language, + determine_log_level: Callable[[str], int], + logger: Callable[[str, str, StringDict | str], None] | None = None, + request_timeout: float | None = None, + ) -> None: + super().__init__(language, determine_log_level, logger, request_timeout) + self._connection_info = connection_info + self._sock: socket.socket | None = None + self._file: Any = None # socket.makefile("rb") - buffered reader + self._write_lock = threading.Lock() + + def is_running(self) -> bool: + return self._sock is not None + + def _start(self) -> None: + deadline = time.monotonic() + self._connection_info.connection_timeout + last_exc: Exception | None = None + while True: + try: + sock = socket.create_connection((self._connection_info.host, self._connection_info.port), timeout=5.0) + sock.settimeout(None) + self._sock = sock + self._file = sock.makefile("rb") + log.info("TCPLanguageServer connected to %s:%d", self._connection_info.host, self._connection_info.port) + break + except OSError as exc: + last_exc = exc + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError( + f"Could not connect to {self._connection_info.host}:{self._connection_info.port} " + f"within {self._connection_info.connection_timeout}s" + ) from last_exc + log.debug( + "TCPLanguageServer: connection failed (%s), retrying in %.1fs (%.0fs left)", + exc, + self._connection_info.retry_interval, + remaining, + ) + time.sleep(min(self._connection_info.retry_interval, remaining)) + + threading.Thread( + target=self._read_loop, + name=f"LSP-tcp-reader:{self.language.value}", + daemon=True, + ).start() + + def _stop(self, timeout: float) -> None: + # Close the socket first — this unblocks any readline() in the reader thread. + # BufferedReader.close() acquires an internal lock that readline() holds while + # blocked waiting for data; closing the socket first causes readline() to return + # with an error, releasing the lock before we call f.close(). + sock = self._sock + self._sock = None + f = self._file + self._file = None + if sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + sock.close() + except Exception: + pass + if f: + try: + f.close() + except Exception: + pass + + def _send_payload(self, payload: StringDict) -> None: + sock = self._sock + if sock is None: + return + self._trace("solidlsp", "ls", payload) + data = b"".join(create_message(payload)) + with self._write_lock: + try: + sock.sendall(data) + except OSError as e: + log.error("Failed to write to TCP language server: %s", e) + + def _read_loop(self) -> None: + """Read Content-Length-framed LSP messages from the TCP socket and dispatch them.""" + exception: Exception | None = None + try: + while self._sock is not None: + f = self._file + if f is None: + break + try: + line = f.readline() + except OSError as exc: + if not self._is_stopping: + exception = LanguageServerTerminatedException("TCP read error", self.language, cause=exc) + break + if not line: + break + try: + num_bytes = content_length(line) + except ValueError: + continue + if num_bytes is None: + continue + while line and line.strip(): + try: + line = f.readline() + except OSError: + line = b"" + break + if not line: + continue + try: + body = f.read(num_bytes) + except OSError as exc: + if not self._is_stopping: + exception = LanguageServerTerminatedException("TCP read error", self.language, cause=exc) + break + if len(body) < num_bytes: + break + self._handle_body(body) + except Exception as exc: + exception = LanguageServerTerminatedException("Unexpected error in TCP language server read loop", self.language, cause=exc) + log.info("TCP language server read loop has terminated") + if not self._is_stopping: + if exception is None: + exception = LanguageServerTerminatedException("TCP language server read loop terminated unexpectedly", self.language) + log.error(str(exception)) + self._cancel_pending_requests(exception) From a1c281dc02a280faee1d7ad1dd72556282e26de7 Mon Sep 17 00:00:00 2001 From: Erik M Jacobs Date: Tue, 12 May 2026 16:18:51 -0400 Subject: [PATCH 2/7] docs: add GDScript to README language list and CHANGELOG Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 715a2c6f..a284566b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - No longer store temporary files (e.g. downloads) in `~/solidlsp_tmp`; instead, use OS-specific temporary directories + - Add **GDScript** (Godot Engine) support. Serena connects over TCP to the Godot editor's built-in LSP server (port 6008, same for Godot 3 and 4) — no separate language server process to install. Godot major version is auto-detected from `config_version` in `project.godot`. Note: Godot's LSP does not implement `workspace/symbol`; first workspace-wide scans fall back to per-file requests and can be slow for large projects (results are cached to disk). See the [GDScript Setup Guide](https://oraios.github.io/serena/03-special-guides/godot_gdscript_setup_guide_for_serena.html) for details. Closes #1446. * Dashboard: - UI polish: switch UI font to Inter (with system fallbacks) and use JetBrains Mono only for code/logs/paths/identifiers; refine the light/dark palette with softer borders, clearer text hierarchy, and a more nuanced shadow/elevation system; introduce a consistent spacing scale; keep the orange accent. diff --git a/README.md b/README.md index 9fe71225..35287ba9 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,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 -Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin From ef22522ee2db668e4add82e645090170b4fbd2f3 Mon Sep 17 00:00:00 2001 From: Erik M Jacobs Date: Wed, 13 May 2026 10:03:46 -0400 Subject: [PATCH 3/7] =?UTF-8?q?fix(gdscript):=20address=20review=20feedbac?= =?UTF-8?q?k=20=E2=80=94=20configurable=20port/timeout,=20safer=20version?= =?UTF-8?q?=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename LSP_PORT → DEFAULT_GODOT_LS_PORT and add DEFAULT_GODOT_REQUEST_TIMEOUT - Make port and request_timeout overridable via ls_specific_settings["gdscript"] by reading self._custom_settings in _create_language_server_interface - Move self._conn_info setup into _create_language_server_interface so it can consume ls_specific_settings (which requires super().__init__ to have run) - Fix _detect_godot_version to return int | None instead of always defaulting to 4: use an explicit config_version→major dict; return None for unrecognized versions and detection failures; caller logs warning on None - Update setup guide and configuration reference docs with new settings Co-Authored-By: Claude Sonnet 4.6 --- docs/02-usage/050_configuration.md | 21 +++++++++++ .../godot_gdscript_setup_guide_for_serena.md | 18 +++++++++- .../language_servers/godot_language_server.py | 36 +++++++++++++------ 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ddc095b1..db09f320 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -487,6 +487,27 @@ Supported settings: | `fsautocomplete_version` | `0.83.0` | Override the FsAutoComplete version Serena installs as a .NET tool. | +#### GDScript (Godot Engine) + +Serena connects to the Godot editor's built-in LSP server over TCP. No separate process is launched. + +Supported settings: + +| Setting | Default | Description | +|---|---|---| +| `port` | `6008` | TCP port the running Godot editor listens on for LSP connections. | +| `request_timeout` | `30.0` | Seconds to wait for a response from the Godot LSP server. | + +Example: + +```yaml +ls_specific_settings: + gdscript: + port: 6008 + request_timeout: 60.0 +``` + + #### Go (`gopls`) Serena forwards `ls_specific_settings.go.gopls_settings` to `gopls` as LSP `initializationOptions` when the Go language server is started. diff --git a/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md b/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md index 8a5520c4..dce20bcc 100644 --- a/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md +++ b/docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md @@ -19,9 +19,11 @@ When a Godot project is open in the editor, the editor listens for LSP connectio Serena automatically detects which major version of Godot your project targets by reading the `config_version` field from `project.godot`: -- `config_version` ≥ 5 → Godot 4 +- `config_version` = 5 → Godot 4 - `config_version` = 4 → Godot 3 +If `config_version` is not recognized (e.g. from a future Godot release), Serena logs a warning and still connects — both Godot 3 and Godot 4 use the same port. + No additional configuration is needed for version detection. --- @@ -71,6 +73,20 @@ To mitigate this: | Editor must stay open | Serena requires the Godot editor to be running throughout the session. Closing the editor breaks the TCP connection; restart Serena after reopening the editor. | | Slower workspace operations | `find_symbol` and `find_references` across the whole workspace are slower than for languages whose LSP servers support native workspace-wide queries. | +--- +## Advanced Configuration + +You can customize Serena's GDScript connection via `ls_specific_settings` in your `serena_config.yml` or `project.yml`: + +```yaml +ls_specific_settings: + gdscript: + port: 6008 # TCP port the Godot editor listens on (default: 6008) + request_timeout: 30.0 # seconds to wait for an LSP response (default: 30.0) +``` + +These override the defaults only if explicitly set. + --- ## Reference diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py index c90ce3fd..b7ed3996 100644 --- a/src/solidlsp/language_servers/godot_language_server.py +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -20,10 +20,11 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -# config_version in project.godot: 5 → Godot 4, 4 → Godot 3 -_GODOT4_CONFIG_VERSION = 5 +# Maps config_version in project.godot to the Godot major version +_CONFIG_VERSION_TO_GODOT_MAJOR: dict[int, int] = {4: 3, 5: 4} -LSP_PORT = 6008 +DEFAULT_GODOT_LS_PORT = 6008 +DEFAULT_GODOT_REQUEST_TIMEOUT = 30.0 class GodotLanguageServer(SolidLanguageServer): @@ -32,42 +33,55 @@ class GodotLanguageServer(SolidLanguageServer): Both Godot 3 and Godot 4 expose an LSP server on TCP port 6008. The Godot editor must already be running — this class connects to it rather than launching it. + + ls_specific_settings for ``gdscript``: + - ``port`` (int): TCP port the Godot editor's LSP listens on (default: 6008). + - ``request_timeout`` (float): seconds to wait for an LSP response (default: 30.0). """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: self._godot_version = self._detect_godot_version(repository_root_path) - log.info("Detected Godot version %d for project at %s", self._godot_version, repository_root_path) - self._conn_info = TCPConnectionInfo(host="127.0.0.1", port=LSP_PORT) + if self._godot_version is not None: + log.info("Detected Godot version %d for project at %s", self._godot_version, repository_root_path) + else: + log.warning("Could not detect Godot version for project at %s", repository_root_path) # Dummy ProcessLaunchInfo — _create_language_server_interface() ignores it super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=""), "gdscript", solidlsp_settings) @staticmethod - def _detect_godot_version(repo_path: str) -> int: - """Read project.godot to determine the major Godot version. Defaults to 4.""" + def _detect_godot_version(repo_path: str) -> int | None: + """Read project.godot to determine the major Godot version. + + Returns None if detection fails or the config_version is unrecognized. + """ project_file = os.path.join(repo_path, "project.godot") try: with open(project_file, encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("config_version="): - version = int(line.split("=", 1)[1]) - return 4 if version >= _GODOT4_CONFIG_VERSION else 3 + config_version = int(line.split("=", 1)[1]) + return _CONFIG_VERSION_TO_GODOT_MAJOR.get(config_version) except (FileNotFoundError, ValueError, OSError): pass - return 4 + return None def _create_language_server_interface( self, process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None, ) -> LanguageServerInterface: + settings: dict = self._custom_settings or {} + port = settings.get("port", DEFAULT_GODOT_LS_PORT) + request_timeout = settings.get("request_timeout", DEFAULT_GODOT_REQUEST_TIMEOUT) + self._conn_info = TCPConnectionInfo(host="127.0.0.1", port=port) return TCPLanguageServer( connection_info=self._conn_info, language=self.language, determine_log_level=self._determine_log_level, logger=logging_fn, - request_timeout=30.0, + request_timeout=request_timeout, ) @staticmethod From c3d9aeafae1d882ed0a17e7b3f63b30f943a1bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20=C3=87elik?= <31419720+BerkayClik@users.noreply.github.com> Date: Thu, 14 May 2026 13:36:24 +0300 Subject: [PATCH 4/7] Docs, minor --- docs/02-usage/030_clients.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index a9378826..0a6e0af5 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -197,7 +197,7 @@ Claude Code settings file (`.claude/settings.json` in your project directory, or ] } ], - "Stop": [ + "SessionEnd": [ { "matcher": "", "hooks": [ From 04630c1abb9c00d5496bf2a93ab02fa085aac63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Magalh=C3=A3es?= Date: Thu, 14 May 2026 07:52:34 -0300 Subject: [PATCH 5/7] Add Svelte support (#1457) Uses svelte LS and typescript LS as companion server --- .gitignore | 2 + CHANGELOG.md | 1 + README.md | 2 +- docs/01-about/020_programming-languages.md | 2 + docs/02-usage/050_configuration.md | 20 + docs/02-usage/070_security.md | 1 + pyproject.toml | 1 + src/serena/resources/project.template.yml | 5 +- .../svelte_language_server.py | 661 ++++++++++++++++++ src/solidlsp/ls_config.py | 19 +- test/conftest.py | 1 + .../repos/svelte/test_repo/.gitignore | 28 + .../repos/svelte/test_repo/package.json | 28 + .../repos/svelte/test_repo/src/app.html | 12 + .../src/lib/components/Counter.svelte | 106 +++ .../src/lib/components/Header.svelte | 121 ++++ .../test_repo/src/lib/components/Words.svelte | 21 + .../src/lib/diagnostics_sample.svelte | 5 + .../test_repo/src/lib/diagnostics_sample.ts | 8 + .../repos/svelte/test_repo/src/lib/game.ts | 64 ++ .../src/routes/(sverdle)/+page.server.ts | 55 ++ .../src/routes/(sverdle)/+page.svelte | 393 +++++++++++ .../routes/(sverdle)/how-to-play/+page.svelte | 95 +++ .../src/routes/(sverdle)/how-to-play/+page.ts | 8 + .../src/routes/(sverdle)/words.server.ts | 10 + .../test_repo/src/routes/+layout.svelte | 56 ++ .../svelte/test_repo/src/routes/layout.css | 104 +++ .../repos/svelte/test_repo/svelte.config.js | 13 + .../repos/svelte/test_repo/tsconfig.json | 20 + .../repos/svelte/test_repo/vite.config.ts | 5 + test/solidlsp/svelte/__init__.py | 15 + test/solidlsp/svelte/conftest.py | 53 ++ test/solidlsp/svelte/test_svelte_basic.py | 69 ++ .../solidlsp/svelte/test_svelte_references.py | 26 + test/solidlsp/svelte/test_svelte_rename.py | 123 ++++ 35 files changed, 2148 insertions(+), 5 deletions(-) create mode 100644 src/solidlsp/language_servers/svelte_language_server.py create mode 100644 test/resources/repos/svelte/test_repo/.gitignore create mode 100644 test/resources/repos/svelte/test_repo/package.json create mode 100644 test/resources/repos/svelte/test_repo/src/app.html create mode 100644 test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts create mode 100644 test/resources/repos/svelte/test_repo/src/lib/game.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/+layout.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/routes/layout.css create mode 100644 test/resources/repos/svelte/test_repo/svelte.config.js create mode 100644 test/resources/repos/svelte/test_repo/tsconfig.json create mode 100644 test/resources/repos/svelte/test_repo/vite.config.ts create mode 100644 test/solidlsp/svelte/__init__.py create mode 100644 test/solidlsp/svelte/conftest.py create mode 100644 test/solidlsp/svelte/test_svelte_basic.py create mode 100644 test/solidlsp/svelte/test_svelte_references.py create mode 100644 test/solidlsp/svelte/test_svelte_rename.py diff --git a/.gitignore b/.gitignore index 37c1649c..9d820467 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,8 @@ eggs/ lib/ !test/resources/repos/dart/test_repo/lib/ !test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart +!test/resources/repos/svelte/test_repo/src/lib/ +!test/resources/repos/svelte/test_repo/src/lib/** lib64/ parts/ sdist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a284566b..d02ecd5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Status of the `main` branch. Changes prior to the next official version change w - `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol * Language Servers: + - Add **Svelte** support via `svelte-language-server@0.18.0`, installed with npm into Serena-managed language-server resources. The `svelte` language handles `.svelte` Single File Components plus TypeScript/JavaScript files for Svelte projects; use it instead of also enabling `typescript` unless intentionally running multiple language servers. - Elixir (`elixir-tools/next-ls`): Fix deadlock in monorepo projects where `mix.exs` lives in a subdirectory. The server now searches immediate subdirectories when no `mix.exs` is found at the repository root. #1444 - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 - Java (eclipse.jdt.ls): Lombok-generated methods (getters/setters, builder(), equals/hashCode/toString, etc.) are now included in symbol-based tools (find_symbol, get_symbols_overview, edits). Added lombok_show_generated setting (default: on) to toggle this. Updated bundled vscode-java to 1.54.0-923. Issue #1432. diff --git a/README.md b/README.md index 35287ba9..ba885644 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,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 -Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, 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 4ba86b77..72cf829b 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -132,6 +132,8 @@ Some languages require additional installations or setup steps, as noted. * **Solidity** (experimental; requires Node.js and npm; automatically installs `@nomicfoundation/solidity-language-server`; works best with a `foundry.toml` or `hardhat.config.js` in the project root) +* **Svelte** + (requires Node.js v18+ and npm; supports `.svelte` Single File Components plus TypeScript/JavaScript files via `svelte-language-server`; a companion `typescript-language-server` + `typescript-svelte-plugin` is spawned automatically for cross-file rename, go-to-definition, and references across `.ts`/`.js` and `.svelte` files; use language `svelte` for Svelte projects instead of also enabling `typescript`) * **Swift** * **TypeScript** * **Vue** diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index db09f320..03731604 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -992,6 +992,26 @@ Supported settings: TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature. +#### Svelte + +Serena uses `svelte-language-server` for the `svelte` language key. Use `svelte` for Svelte projects instead of also listing `typescript`, unless you intentionally want multiple language servers active for the same files. + +A companion TypeScript language server (`typescript-language-server` + `typescript-svelte-plugin`) is spawned automatically alongside the Svelte LSP. The plugin makes the TypeScript program `.svelte`-aware so that cross-file operations — rename, go-to-definition, and find-references from `.ts`/`.js` files — correctly include `.svelte` consumers. Serena merges and deduplicates reference results from both servers automatically. + +Supported settings: + +| Setting | Default | Description | +|---|---|---| +| `ls_path` | managed install | Override the `svelteserver` executable path. | +| `svelte_language_server_version` | `0.18.0` | Override the `svelte-language-server` npm package version Serena installs. | +| `typescript_version` | `6.0.3` (falls back to `ls_specific_settings.typescript.typescript_version`) | Override the `typescript` npm package version used as the shared tsdk. | +| `typescript_language_server_version` | `5.1.3` (falls back to `ls_specific_settings.typescript.typescript_language_server_version`) | Override the `typescript-language-server` npm package version for the companion server. | +| `typescript_svelte_plugin_version` | `0.3.52` | Override the `typescript-svelte-plugin` npm package version used for `.svelte`-aware TS resolution. | +| `npm_registry` | `null` | Override the npm registry Serena uses for all managed installs. | +| `initialization_options_configuration` | `{}` | Deep-merge overrides for any of the ten plugin configuration sections (`svelte`, `prettier`, `emmet`, `typescript`, `javascript`, `js/ts`, `css`, `less`, `scss`, `html`). | + +All four packages are tracked via a version file; changing any version setting triggers a clean reinstall. + #### TypeScript via `vtsls` The actual configuration key for vtsls is `typescript_vts`, not `vts`. diff --git a/docs/02-usage/070_security.md b/docs/02-usage/070_security.md index 3278a7fc..9ac02d7b 100644 --- a/docs/02-usage/070_security.md +++ b/docs/02-usage/070_security.md @@ -150,6 +150,7 @@ Only the language servers listed below download or install additional dependenci - **PHP (`intelephense`)**: `intelephense` - **SCSS / Sass / CSS**: `some-sass-language-server` - **Solidity**: `@nomicfoundation/solidity-language-server` +- **Svelte**: `svelte-language-server` - **TypeScript**: `typescript` and `typescript-language-server` - **Vue**: `@vue/language-server`, plus `typescript` and `typescript-language-server` - **VTSLS**: `@vtsls/language-server` diff --git a/pyproject.toml b/pyproject.toml index f3e3e76b..8f97826e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -310,6 +310,7 @@ markers = [ "groovy: language server running for Groovy", "rust: language server running for Rust", "typescript: language server running for TypeScript", + "svelte: language server running for Svelte (uses svelte-language-server)", "vue: language server running for Vue (uses TypeScript LSP)", "php: language server running for PHP", "perl: language server running for Perl", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index e5c877fe..a4dddb37 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -13,8 +13,8 @@ project_name: "project_name" # perl php php_phpactor powershell python # python_jedi python_ty r rego ruby # ruby_solargraph rust scala scss solidity -# swift systemverilog terraform toml typescript -# typescript_vts vue yaml zig +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -22,6 +22,7 @@ project_name: "project_name" # - For C, use cpp # - For JavaScript, use typescript # - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py new file mode 100644 index 00000000..634d4dbb --- /dev/null +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -0,0 +1,661 @@ +""" +Provides Svelte-specific instantiation of the LanguageServer class using +``svelte-language-server`` from Svelte Language Tools. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +from typing import Any, cast + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import ( + RuntimeDependency, + RuntimeDependencyCollection, + build_npm_install_command, +) +from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) +TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"}) +JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"}) +SVELTE_EXT = frozenset({".svelte"}) + + +def _is_ts_file(uri: str) -> bool: + return uri.lower().endswith(tuple(TS_EXT | JS_EXT)) + + +def _is_svelte_file(uri: str) -> bool: + return uri.lower().endswith(tuple(SVELTE_EXT)) + + +class SvelteTypeScriptServer(TypeScriptLanguageServer): + """Companion TypeScript language server for Svelte projects. + + Loads ``typescript-svelte-plugin`` so the TS graph becomes .svelte-aware: + cross-file rename, find-references, and go-to-definition from .ts/.js files + into .svelte consumers all work correctly through this companion. + + Spawned and owned by :class:`SvelteLanguageServer`; not instantiated directly. + """ + + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): + """Returns the pre-installed typescript-language-server binary. + + The binary is installed by ``SvelteLanguageServer.DependencyProvider``; + this provider just resolves the pre-known path without a separate install. + """ + + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + explicit_executable_path: str, + ) -> None: + super().__init__(custom_settings, ls_resources_dir) + self._explicit_executable_path = explicit_executable_path + + @override + def _get_or_install_core_dependency(self) -> str: + return self._explicit_executable_path + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + svelte_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: str, + ) -> None: + self._svelte_plugin_path = svelte_plugin_path + self._custom_tsdk_path = tsdk_path + # store as instance state, not class attr, to avoid races across parallel instantiations + self._explicit_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + + @classmethod + @override + def get_language_enum_instance(cls) -> Language: + """Return TYPESCRIPT; companion uses the TypeScript LS infrastructure.""" + return Language.TYPESCRIPT + + @override + def get_source_fn_matcher(self) -> FilenameMatcher: + # include .svelte so references returned by the plugin are not filtered out + return Language.SVELTE.get_source_fn_matcher() + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider( + self._custom_settings, + self._ls_resources_dir, + self._explicit_ts_ls_executable, + ) + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """.svelte files map to 'svelte' to activate the plugin; TS/JS as normal.""" + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in SVELTE_EXT: + return "svelte" + if ext in JS_EXT: + return "javascript" + return "typescript" + + @override + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + params = super()._get_initialize_params(repository_absolute_path) + params["initializationOptions"] = { + "plugins": [ + { + "name": "typescript-svelte-plugin", + "location": self._svelte_plugin_path, + "languages": ["svelte"], + } + ], + "tsserver": {"path": self._custom_tsdk_path}, + } + return params + + @override + def _start_server(self) -> None: + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration_handler) + super()._start_server() + + +class SvelteLanguageServer(SolidLanguageServer): + """ + Svelte language server using ``svelte-language-server``. + + ``ls_specific_settings["svelte"]`` keys: + * ``svelte_language_server_version``: version of ``svelte-language-server`` + to install (default: ``0.18.0``). + * ``npm_registry``: optional alternative npm-compatible registry URL. + * ``initialization_options_configuration``: optional dict merged into + ``initializeParams.initializationOptions.configuration`` (same top-level keys as in + Svelte Language Tools: ``svelte``, ``prettier``, ``typescript``, …). + """ + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + ts_settings: SolidLSPSettings.CustomLSSettings, + ) -> None: + super().__init__(custom_settings, ls_resources_dir) + self._ts_settings = ts_settings + + def _get_or_install_core_dependency(self) -> str: + assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again." + assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again." + + package_version = self._custom_settings.get("svelte_language_server_version", "0.18.0") + npm_registry = self._custom_settings.get("npm_registry", self._ts_settings.get("npm_registry")) + typescript_version = self._custom_settings.get("typescript_version", self._ts_settings.get("typescript_version", "6.0.3")) + typescript_language_server_version = self._custom_settings.get( + "typescript_language_server_version", + self._ts_settings.get("typescript_language_server_version", "5.1.3"), + ) + typescript_svelte_plugin_version = self._custom_settings.get("typescript_svelte_plugin_version", "0.3.52") + + # versioned install dir avoids silently reusing stale language-server binaries + install_dir = os.path.join(self._ls_resources_dir, f"svelte-lsp-{package_version}") + executable_path = os.path.join(install_dir, "node_modules", ".bin", "svelteserver") + if os.name == "nt": + executable_path += ".cmd" + + # version file encodes all four component versions; mismatch triggers reinstall + version_file = os.path.join(install_dir, ".installed_version") + expected_version = ( + f"{package_version}_{typescript_version}_{typescript_language_server_version}_{typescript_svelte_plugin_version}" + ) + needs_install = not os.path.exists(executable_path) + if not needs_install: + if os.path.exists(version_file): + with open(version_file) as fv: + if fv.read().strip() != expected_version: + needs_install = True + else: + # absent version file → old install that predates companion deps + needs_install = True + + if needs_install: + log.info( + "Installing svelte-language-server@%s + typescript@%s + typescript-language-server@%s + typescript-svelte-plugin@%s ...", + package_version, + typescript_version, + typescript_language_server_version, + typescript_svelte_plugin_version, + ) + runtime_deps = [ + RuntimeDependency( + id="svelte-language-server", + description="Svelte language server", + command=build_npm_install_command("svelte-language-server", package_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript", + description="TypeScript language service", + command=build_npm_install_command("typescript", typescript_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="TypeScript language server (companion)", + command=build_npm_install_command("typescript-language-server", typescript_language_server_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-svelte-plugin", + description="TypeScript plugin for Svelte cross-file awareness", + command=build_npm_install_command("typescript-svelte-plugin", typescript_svelte_plugin_version, npm_registry), + platform_id="any", + ), + ] + RuntimeDependencyCollection(runtime_deps).install(install_dir) + with open(version_file, "w") as fv: + fv.write(expected_version) + + if not os.path.exists(executable_path): + raise FileNotFoundError( + f"executable not found at {executable_path}; " + f"npm install of svelte-language-server@{package_version} did not produce the expected binary." + ) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + # stdio suits SolidLSP's subprocess RPC; other hosts may use a different transport. + return [core_path, "--stdio"] + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, ts_settings) + + def __init__(self, config: LanguageServerConfig, repo_path: str, solidlsp_settings: SolidLSPSettings): + resolved_root = os.path.abspath(repo_path) + super().__init__( + config, + resolved_root, + None, + "svelte", + solidlsp_settings, + ) + self.repo_path: str = resolved_root + self.tsdk_path = self._get_tsdk_path() + self._lsp_configuration: dict[str, Any] = {} + self._ts_server: SvelteTypeScriptServer | None = None + self._ts_server_started: bool = False + self._svelte_files_indexed: bool = False + self._indexed_svelte_file_uris: list[str] = [] + + def _get_tsdk_path(self) -> str: + """ + Compute the local typescript/lib path for the Svelte language server. + Asserts if not found, since DependencyProvider guarantees install. + """ + package_version = self._custom_settings.get("svelte_language_server_version", "0.18.0") + install_dir = os.path.join(self._ls_resources_dir, f"svelte-lsp-{package_version}") + tsdk_candidate = os.path.join(install_dir, "node_modules", "typescript", "lib") + assert os.path.isdir(tsdk_candidate), ( + f"TypeScript SDK not found at expected path: {tsdk_candidate}. Installation via DependencyProvider failed or version mismatch." + ) + return tsdk_candidate + + def _get_install_dir(self) -> str: + """:return: versioned install directory for svelte-language-server and companion deps.""" + version = self._custom_settings.get("svelte_language_server_version", "0.18.0") + return os.path.join(self._ls_resources_dir, f"svelte-lsp-{version}") + + def _get_ts_ls_executable(self) -> str: + """:return: path to the typescript-language-server binary installed alongside the svelte LS.""" + path = os.path.join(self._get_install_dir(), "node_modules", ".bin", "typescript-language-server") + if os.name == "nt": + path += ".cmd" + return path + + def _get_svelte_ts_plugin_path(self) -> str: + """:return: path to the ``typescript-svelte-plugin`` package directory.""" + return os.path.join(self._get_install_dir(), "node_modules", "typescript-svelte-plugin") + + def _find_all_svelte_files(self) -> list[str]: + """:return: relative paths of all .svelte files in the repo (excluding node_modules and dot-dirs).""" + svelte_files = [] + repo = pathlib.Path(self.repo_path) + for svelte_file in repo.rglob("*.svelte"): + try: + relative = str(svelte_file.relative_to(repo)) + if "node_modules" not in relative and not relative.startswith("."): + svelte_files.append(relative) + except Exception as exc: + log.debug("Error processing svelte file %s: %s", svelte_file, exc) + return svelte_files + + def _ensure_svelte_files_indexed_on_ts_server(self) -> None: + """Open all .svelte files on the companion TS server so the plugin includes them in the TS program. + + The ``typescript-svelte-plugin``'s ``getExternalFiles`` is called by tsserver when a project + is set up, but only after the first file in that project is opened. Opening each .svelte file + with languageId ``"svelte"`` causes tsserver to invoke the plugin's ``getScriptSnapshot`` + for those files, adding them to the project graph so cross-file rename and references work. + """ + if self._svelte_files_indexed: + return + assert self._ts_server is not None + + log.info("Indexing .svelte files on companion TypeScript server for cross-file awareness") + svelte_files = self._find_all_svelte_files() + log.debug("Found %d .svelte files to index", len(svelte_files)) + + # prepare progress tracking BEFORE opening files to avoid a race + self._ts_server.expect_indexing() + + for svelte_file in svelte_files: + try: + with self._ts_server.open_file(svelte_file) as file_buffer: + file_buffer.ref_count += 1 + self._indexed_svelte_file_uris.append(file_buffer.uri) + except Exception as exc: + log.debug("Failed to open %s on companion TS server: %s", svelte_file, exc) + + self._svelte_files_indexed = True + log.info("Svelte file indexing complete; waiting for companion TS server to finish processing") + + timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT + if self._ts_server.wait_for_indexing(timeout=timeout): + log.info("Companion TypeScript server finished indexing .svelte files") + else: + log.warning("Timeout (%ss) waiting for companion TS server to index .svelte files; proceeding anyway", timeout) + + def _cleanup_indexed_svelte_files(self) -> None: + """Decrement ref-counts for all .svelte files opened during indexing.""" + if not self._indexed_svelte_file_uris or self._ts_server is None: + return + log.debug("Cleaning up %d indexed .svelte files", len(self._indexed_svelte_file_uris)) + for uri in self._indexed_svelte_file_uris: + try: + if uri in self._ts_server.open_file_buffers: + file_buffer = self._ts_server.open_file_buffers[uri] + file_buffer.ref_count -= 1 + if file_buffer.ref_count == 0: + self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}}) + del self._ts_server.open_file_buffers[uri] + except Exception as exc: + log.debug("Error closing indexed svelte file %s: %s", uri, exc) + self._indexed_svelte_file_uris.clear() + + def _start_typescript_server(self) -> None: + """Spawn the companion :class:`SvelteTypeScriptServer`, wait for ready, then index .svelte files.""" + try: + ts_config = LanguageServerConfig( + code_language=Language.TYPESCRIPT, + trace_lsp_communication=False, + ) + log.info("Creating companion SvelteTypeScriptServer") + self._ts_server = SvelteTypeScriptServer( + config=ts_config, + repository_root_path=self.repo_path, + solidlsp_settings=self._solidlsp_settings, + svelte_plugin_path=self._get_svelte_ts_plugin_path(), + tsdk_path=self.tsdk_path, + ts_ls_executable_path=self._get_ts_ls_executable(), + ) + log.info("Starting companion SvelteTypeScriptServer") + self._ts_server.start() + log.info("Waiting for companion SvelteTypeScriptServer to be ready ...") + if not self._ts_server.server_ready.wait(timeout=30.0): + log.warning("Timeout waiting for companion SvelteTypeScriptServer; proceeding anyway") + self._ts_server.server_ready.set() + self._ts_server_started = True + log.info("Companion SvelteTypeScriptServer ready") + self._ensure_svelte_files_indexed_on_ts_server() + except Exception: + log.exception("Error starting companion SvelteTypeScriptServer; TS-side operations degrade to svelte LS") + self._ts_server = None + self._ts_server_started = False + + def _stop_typescript_server(self) -> None: + """Shut down the companion TypeScript server if running.""" + if self._ts_server is not None: + self._cleanup_indexed_svelte_files() + try: + log.info("Stopping companion SvelteTypeScriptServer") + self._ts_server.stop() + except Exception as exc: + log.warning("Error stopping companion SvelteTypeScriptServer: %s", exc) + finally: + self._ts_server = None + self._ts_server_started = False + + def _wrap_notify_send_for_ts_js_mirror(self) -> None: + """Mirror TS/JS didChange via ``$/onDidChangeTsOrJsFile`` so the server updates TS snapshots. + + Unlike upstream ``svelte-vscode`` (svelte-only documentSelector), Serena must also open and + query TS/JS files directly through the same server instance. Standard sync notifications are + therefore kept; ``$/onDidChangeTsOrJsFile`` is sent additionally for didChange so the svelte + LS keeps its internal TS snapshot in sync with the open-buffer content. + """ + _orig_notify_send = self.server.notify.send_notification + + def send_notification_wrapped(method: str, params: dict | None = None) -> None: + _orig_notify_send(method, params) + if method != "textDocument/didChange" or not params: + return + text_document = params.get("textDocument") + if not text_document: + return + uri = text_document.get("uri") + if not uri: + return + fb = self.open_file_buffers.get(uri) + if fb is None or fb.language_id not in ("typescript", "javascript"): + return + changes = params.get("contentChanges") + if changes is None: + return + _orig_notify_send("$/onDidChangeTsOrJsFile", {"uri": uri, "changes": changes}) + + self.server.notify.send_notification = send_notification_wrapped # type: ignore[method-assign] # type: ignore[method-assign] # type: ignore[method-assign] + + def _get_initialize_params(self) -> InitializeParams: + """ + Returns the initialize params for the Svelte Language Server. + + Builds the full ``initializationOptions.configuration`` section mirroring all + keys expected by ``svelte-language-server`` plugins (svelte, prettier, emmet, + typescript, javascript, js/ts, css, less, scss, html). Caller-supplied overrides + from ``initialization_options_configuration`` are deep-merged on top. + The resulting dict is also stored as :attr:`_lsp_configuration` so + ``workspace/configuration`` requests can be answered with real values. + """ + root_uri = pathlib.Path(self.repo_path).as_uri() + + # base configuration mirroring all plugin-sections from svelte-vscode initializationOptions + lsp_config: dict[str, Any] = { + "svelte": {}, + "prettier": {}, + "emmet": {}, + "javascript": {"tsdk": self.tsdk_path}, + "typescript": {"tsdk": self.tsdk_path}, + "js/ts": {"tsdk": self.tsdk_path}, + "css": {}, + "less": {}, + "scss": {}, + "html": {}, + } + + # apply caller-supplied overrides (same top-level keys) + for key, val in self._custom_settings.get("initialization_options_configuration", {}).items(): + if key in lsp_config and isinstance(lsp_config[key], dict) and isinstance(val, dict): + lsp_config[key] = {**lsp_config[key], **val} + else: + lsp_config[key] = val + + self._lsp_configuration = lsp_config + + initialize_params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "implementation": {"dynamicRegistration": True}, + "typeDefinition": {"dynamicRegistration": True}, + "diagnostic": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "applyEdit": True, + "configuration": True, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": {"dynamicRegistration": True}, + "diagnostics": {"refreshSupport": True}, + "fileOperations": {"didRename": True}, + }, + }, + "initializationOptions": { + "isTrusted": True, + "dontFilterIncompleteCompletions": True, + "configuration": lsp_config, + }, + "processId": os.getpid(), + "rootPath": self.repo_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(self.repo_path), + } + ], + } + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg) + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + result = [] + for item in items: + section = item.get("section", "") if isinstance(item, dict) else "" + result.append(self._lsp_configuration.get(section, {})) + return result + + def workspace_apply_edit_handler(_params: dict) -> dict[str, Any]: + return {"applied": False} + + def work_done_progress_create(_params: dict) -> dict: + return {} + + def do_nothing(_params: dict) -> None: + pass + + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_request("workspace/applyEdit", workspace_apply_edit_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_request("workspace/diagnostic/refresh", do_nothing) + self.server.on_request("workspace/inlayHints/refresh", do_nothing) + self.server.on_request("workspace/semanticTokens/refresh", do_nothing) + self._wrap_notify_send_for_ts_js_mirror() + self.server.start() + + init_params = self._get_initialize_params() + init_response = self.server.send.initialize(init_params) + + assert "documentSymbolProvider" in init_response["capabilities"], "Svelte LSP did not advertise documentSymbolProvider" + assert "definitionProvider" in init_response["capabilities"], "Svelte LSP did not advertise definitionProvider" + + self.server.notify.initialized({}) + self._start_typescript_server() + + @staticmethod + def _deduplicate_reference_locations(a: list[ls_types.Location], b: list[ls_types.Location]) -> list[ls_types.Location]: + seen = set() + + for loc in a: + start = loc["range"]["start"] + seen.add((loc["uri"], start["line"], start["character"])) + + deduped_refs = list(a) + + for loc in b: + start = loc["range"]["start"] + key = (loc["uri"], start["line"], start["character"]) + + if key not in seen: + seen.add(key) + deduped_refs.append(loc) + + return deduped_refs + + @override + def stop(self, shutdown_timeout: float = 5.0) -> None: + self._stop_typescript_server() + super().stop(shutdown_timeout) + + @override + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """Combine references from svelte LS and companion TS server. + + For .svelte files: svelte LS + ``$/getComponentReferences``. + For .ts/.js files: companion TS server (svelte-plugin-aware) merged with + svelte LS ``$/getFileReferences`` to maximise cross-file coverage. + Falls back to svelte-LS-only behaviour when companion is unavailable. + """ + symbol_refs = super().request_references(relative_file_path, line, column) + normalize_helper = self.ReferencesLocationRequest(self, relative_file_path, line, column) + + if _is_ts_file(relative_file_path): + # augment with svelte LS file-level references + raw = self.server.send_request("$/getFileReferences", cast(Any, self._resolve_file_uri(relative_file_path))) + file_refs = normalize_helper.normalize_response(raw if isinstance(raw, list) else []) + symbol_refs = self._deduplicate_reference_locations(symbol_refs, file_refs) + + # augment with companion TS server (typescript-svelte-plugin gives .svelte awareness) + if self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + ts_refs = self._ts_server.request_references(relative_file_path, line, column) + symbol_refs = self._deduplicate_reference_locations(symbol_refs, ts_refs) + + elif _is_svelte_file(relative_file_path): + raw = self.server.send_request("$/getComponentReferences", cast(Any, self._resolve_file_uri(relative_file_path))) + comp_refs = normalize_helper.normalize_response(raw if isinstance(raw, list) else []) + symbol_refs = self._deduplicate_reference_locations(symbol_refs, comp_refs) + + return symbol_refs + + @override + def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: + """Delegate TS/JS renames to the companion so the svelte plugin handles cross-file edits. + + Falls back to the svelte LS when the companion is unavailable or when the file is .svelte. + """ + if _is_ts_file(relative_file_path) and self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + return super().request_rename_symbol_edit(relative_file_path, line, column, new_name) + + @override + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """Delegate TS/JS go-to-definition to the companion for .svelte-aware resolution. + + Falls back to the svelte LS when the companion is unavailable or when the file is .svelte. + """ + if _is_ts_file(relative_file_path) and self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_definition(relative_file_path, line, column) + return super().request_definition(relative_file_path, line, column) + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in TS_EXT: + return "typescript" + if ext in JS_EXT: + return "javascript" + return self.language_id + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["dist", "build", "coverage"] diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 2e2c5c3a..7efdf749 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -93,6 +93,11 @@ class Language(str, Enum): LEAN4 = "lean4" GROOVY = "groovy" VUE = "vue" + SVELTE = "svelte" + """Svelte language server using svelte-language-server. + Supports .svelte Single File Components plus TypeScript and JavaScript + files in Svelte projects. Requires Node.js v18+ and npm. + """ POWERSHELL = "powershell" PASCAL = "pascal" """Pascal Language Server (pasls) for Free Pascal and Lazarus projects. @@ -272,8 +277,8 @@ class Language(str, Enum): # We assign lower priority to languages that are supersets of others, such that # the "larger" language is only chosen when it matches more strongly match self: - # languages that are supersets of others (Vue is superset of TypeScript/JavaScript) - case self.VUE: + # languages that are supersets of others (Vue/Svelte are supersets of TypeScript/JavaScript) + case self.VUE | self.SVELTE: return 1 # regular languages case _: @@ -440,6 +445,12 @@ class Language(str, Enum): for base_pattern in ["ts", "js"]: path_patterns.append(f".{prefix}{base_pattern}{postfix}") return FilenameMatcher(*path_patterns) + case self.SVELTE: + path_patterns = [".svelte"] + for prefix in ["c", "m", ""]: + for base_pattern in ["ts", "js"]: + path_patterns.append(f".{prefix}{base_pattern}") + return FilenameMatcher(*path_patterns) case self.POWERSHELL: return FilenameMatcher(".ps1", ".psm1", ".psd1") case self.PASCAL: @@ -545,6 +556,10 @@ class Language(str, Enum): from solidlsp.language_servers.vue_language_server import VueLanguageServer return VueLanguageServer + case self.SVELTE: + from solidlsp.language_servers.svelte_language_server import SvelteLanguageServer + + return SvelteLanguageServer case self.GO: from solidlsp.language_servers.gopls import Gopls diff --git a/test/conftest.py b/test/conftest.py index b0fdff45..70b3a8fe 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -268,6 +268,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { pytest.mark.bsl, pytest.mark.skipif(_sh.which("java") is None, reason="Java is not installed"), ], + Language.SVELTE: [pytest.mark.svelte], Language.ANGULAR: [pytest.mark.angular], Language.HTML: [pytest.mark.html], Language.SCSS: [pytest.mark.scss], diff --git a/test/resources/repos/svelte/test_repo/.gitignore b/test/resources/repos/svelte/test_repo/.gitignore new file mode 100644 index 00000000..fdfcaa3b --- /dev/null +++ b/test/resources/repos/svelte/test_repo/.gitignore @@ -0,0 +1,28 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Lock files +package-lock.json +pnpm-lock.yaml +yarn.lock \ No newline at end of file diff --git a/test/resources/repos/svelte/test_repo/package.json b/test/resources/repos/svelte/test_repo/package.json new file mode 100644 index 00000000..b0dde71f --- /dev/null +++ b/test/resources/repos/svelte/test_repo/package.json @@ -0,0 +1,28 @@ +{ + "name": "test-repo", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@fontsource/fira-mono": "^5.2.7", + "@neoconfetti/svelte": "^2.2.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.6.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } +} diff --git a/test/resources/repos/svelte/test_repo/src/app.html b/test/resources/repos/svelte/test_repo/src/app.html new file mode 100644 index 00000000..b6a30735 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte b/test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte new file mode 100644 index 00000000..3f872d97 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte @@ -0,0 +1,106 @@ + + + + +
+ + +
+
+ + {Math.floor(count.current)} +
+
+ + +
+ + diff --git a/test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte b/test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte new file mode 100644 index 00000000..a9c6d1df --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte @@ -0,0 +1,121 @@ + + +
+
+
+ + + +
+
+
+ + diff --git a/test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte b/test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte new file mode 100644 index 00000000..5229cbf9 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte new file mode 100644 index 00000000..1d4cb9d1 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte @@ -0,0 +1,5 @@ + + +

{count}

diff --git a/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts new file mode 100644 index 00000000..80c2656e --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts @@ -0,0 +1,8 @@ +export function brokenFactory(): string { + return missingGreeting; +} + +export function brokenConsumer(): void { + const value = brokenFactory(); + console.log(missingConsumerValue); +} diff --git a/test/resources/repos/svelte/test_repo/src/lib/game.ts b/test/resources/repos/svelte/test_repo/src/lib/game.ts new file mode 100644 index 00000000..6e17d54c --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/game.ts @@ -0,0 +1,64 @@ +import { allowed } from "../routes/(sverdle)/words.server.ts"; +import { words } from "$lib/components/Words.svelte"; + +export const GAME_VERSION = "1.0"; + +export class Game { + index: number; + guesses: string[]; + answers: string[]; + answer: string; + + constructor(serialized: string | undefined = undefined) { + if (serialized) { + const [index, guesses, answers] = serialized.split("-"); + + this.index = +index; + this.guesses = guesses ? guesses.split(" ") : []; + this.answers = answers ? answers.split(" ") : []; + } else { + this.index = Math.floor(Math.random() * words.length); + this.guesses = ["", "", "", "", "", ""]; + this.answers = []; + } + + this.answer = words[this.index]; + } + + enter(letters: string[]) { + const word = letters.join(""); + const valid = allowed.has(word); + + if (!valid) return false; + + this.guesses[this.answers.length] = word; + + const available = Array.from(this.answer); + const answer = Array(5).fill("_"); + + for (let i = 0; i < 5; i += 1) { + if (letters[i] === available[i]) { + answer[i] = "x"; + available[i] = " "; + } + } + + for (let i = 0; i < 5; i += 1) { + if (answer[i] === "_") { + const index = available.indexOf(letters[i]); + if (index !== -1) { + answer[i] = "c"; + available[index] = " "; + } + } + } + + this.answers.push(answer.join("")); + + return true; + } + + toString() { + return `${this.index}-${this.guesses.join(" ")}-${this.answers.join(" ")}`; + } +} diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts new file mode 100644 index 00000000..70da2543 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts @@ -0,0 +1,55 @@ +import { fail } from "@sveltejs/kit"; +import { Game, GAME_VERSION } from "$lib/game.js"; + +console.log(GAME_VERSION); +import { count } from "$lib/components/Counter.svelte"; + +export const load = ({ cookies }) => { + const game = new Game(cookies.get("sverdle")); + + return { + startingCount: count.current, + + guesses: game.guesses, + + answers: game.answers, + + answer: game.answers.length >= 6 ? game.answer : null, + }; +}; + +export const actions = { + update: async ({ request, cookies }) => { + const game = new Game(cookies.get("sverdle")); + + const data = await request.formData(); + const key = data.get("key"); + + const i = game.answers.length; + + if (key === "backspace") { + game.guesses[i] = game.guesses[i].slice(0, -1); + } else { + game.guesses[i] += key; + } + + cookies.set("sverdle", game.toString(), { path: "/" }); + }, + + enter: async ({ request, cookies }) => { + const game = new Game(cookies.get("sverdle")); + + const data = await request.formData(); + const guess = data.getAll("guess") as string[]; + + if (!game.enter(guess)) { + return fail(400, { badGuess: true }); + } + + cookies.set("sverdle", game.toString(), { path: "/" }); + }, + + restart: async ({ cookies }) => { + cookies.delete("sverdle", { path: "/" }); + }, +}; diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte new file mode 100644 index 00000000..b237ca34 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte @@ -0,0 +1,393 @@ + + + + + + Sverdle + + + +

Sverdle

+ +
{ + // prevent default callback from resetting the form + return ({ result, update }) => { + shake = result.type === 'failure'; + update({ reset: false }); + }; + }} +> + How to play + +
(shake = false)}> + {#each Array.from(Array(6).keys()) as row (row)} + {@const current = row === i} +

Row {row + 1}

+
+ {#each Array.from(Array(5).keys()) as column (column)} + {@const guess = current ? currentGuess : data.guesses[row]} + {@const answer = data.answers[row]?.[column]} + {@const value = guess?.[column] ?? ''} + {@const selected = current && column === guess.length} + {@const exact = answer === 'x'} + {@const close = answer === 'c'} + {@const missing = answer === '_'} +
+ {value} + + {#if exact} + (correct) + {:else if close} + (present) + {:else if missing} + (absent) + {:else} + empty + {/if} + + +
+ {/each} +
+ {/each} +
+ +
+ {#if won || data.answers.length >= 6} + {#if !won && data.answer} +

the answer was "{data.answer}"

+ {/if} + + {:else} +
+ + + + + {#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)} +
+ {#each row as letter, index (index)} + + {/each} +
+ {/each} +
+ {/if} +
+
+ +{#if won} +
+{/if} + + diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte new file mode 100644 index 00000000..e8e2cec3 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte @@ -0,0 +1,95 @@ + + How to play Sverdle + + + +
+

How to play Sverdle

+ +

+ Sverdle is a clone of Wordle, the + word guessing game. To play, enter a five-letter English word. For example: +

+ +
+ r + i + t + z + y +
+ +

+ The y is in the right place. r and + t + are the right letters, but in the wrong place. The other letters are wrong, and can be discarded. + Let's make another guess: +

+ +
+ p + a + r + t + y +
+ +

This time we guessed right! You have six guesses to get the word.

+ +

+ Unlike the original Wordle, Sverdle runs on the server instead of in the browser, making it + impossible to cheat. It uses <form> and cookies to submit data, meaning you can + even play with JavaScript disabled! +

+
+ + diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts new file mode 100644 index 00000000..6838d9f2 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts @@ -0,0 +1,8 @@ +import { dev } from "$app/environment"; +import { GAME_VERSION } from "$lib/game"; + +console.log(GAME_VERSION); + +export const csr = dev; + +export const prerender = true; diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts new file mode 100644 index 00000000..6e4996ad --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts @@ -0,0 +1,10 @@ +import { words } from "$lib/components/Words.svelte"; + +export const allowed = new Set([ + ...words, + "aahed", + "aalii", + "aargh", + "aarti", + "abaca", +]); diff --git a/test/resources/repos/svelte/test_repo/src/routes/+layout.svelte b/test/resources/repos/svelte/test_repo/src/routes/+layout.svelte new file mode 100644 index 00000000..1c303749 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/+layout.svelte @@ -0,0 +1,56 @@ + + +
+
+
{@render children()}
+ + +
+ + diff --git a/test/resources/repos/svelte/test_repo/src/routes/layout.css b/test/resources/repos/svelte/test_repo/src/routes/layout.css new file mode 100644 index 00000000..a0907fe3 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/layout.css @@ -0,0 +1,104 @@ +@import 'tailwindcss'; +@import '@fontsource/fira-mono'; + +:root { + --font-body: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, + 'Open Sans', 'Helvetica Neue', sans-serif; + --font-mono: 'Fira Mono', monospace; + --color-bg-0: rgb(202, 216, 228); + --color-bg-1: hsl(209, 36%, 86%); + --color-bg-2: hsl(224, 44%, 95%); + --color-theme-1: #ff3e00; + --color-theme-2: #4075a6; + --color-text: rgba(0, 0, 0, 0.7); + --column-width: 42rem; + --column-margin-top: 4rem; + font-family: var(--font-body); + color: var(--color-text); +} + +body { + min-height: 100vh; + margin: 0; + background-attachment: fixed; + background-color: var(--color-bg-1); + background-size: 100vw 100vh; + background-image: radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0) 100%), + linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%); +} + +h1, +h2, +p { + font-weight: 400; +} + +p { + line-height: 1.5; +} + +a { + color: var(--color-theme-1); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1 { + font-size: 2rem; + text-align: center; +} + +h2 { + font-size: 1rem; +} + +pre { + font-size: 16px; + font-family: var(--font-mono); + background-color: rgba(255, 255, 255, 0.45); + border-radius: 3px; + box-shadow: 2px 2px 6px rgb(255 255 255 / 25%); + padding: 0.5em; + overflow-x: auto; + color: var(--color-text); +} + +.text-column { + display: flex; + max-width: 48rem; + flex: 0.6; + flex-direction: column; + justify-content: center; + margin: 0 auto; +} + +input, +button { + font-size: inherit; + font-family: inherit; +} + +button:focus:not(:focus-visible) { + outline: none; +} + +@media (min-width: 720px) { + h1 { + font-size: 2.4rem; + } +} + +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: auto; + margin: 0; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; +} diff --git a/test/resources/repos/svelte/test_repo/svelte.config.js b/test/resources/repos/svelte/test_repo/svelte.config.js new file mode 100644 index 00000000..10c4eeb2 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/svelte.config.js @@ -0,0 +1,13 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/test/resources/repos/svelte/test_repo/tsconfig.json b/test/resources/repos/svelte/test_repo/tsconfig.json new file mode 100644 index 00000000..2c2ed3c4 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/test/resources/repos/svelte/test_repo/vite.config.ts b/test/resources/repos/svelte/test_repo/vite.config.ts new file mode 100644 index 00000000..56f40c72 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/vite.config.ts @@ -0,0 +1,5 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); diff --git a/test/solidlsp/svelte/__init__.py b/test/solidlsp/svelte/__init__.py new file mode 100644 index 00000000..3b8849f6 --- /dev/null +++ b/test/solidlsp/svelte/__init__.py @@ -0,0 +1,15 @@ +"""Svelte language server tests.""" + +import shutil + + +def _test_npm_available() -> str: + """Test if npm is available and return error reason if not.""" + # Check if npm is installed + if not shutil.which("npm"): + return "npm is not installed or not in PATH" + return "" # No error, npm is available + + +NPM_UNAVAILABLE_REASON = _test_npm_available() +NPM_UNAVAILABLE = bool(NPM_UNAVAILABLE_REASON) diff --git a/test/solidlsp/svelte/conftest.py b/test/solidlsp/svelte/conftest.py new file mode 100644 index 00000000..24b5a836 --- /dev/null +++ b/test/solidlsp/svelte/conftest.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from filelock import FileLock + +log = logging.getLogger(__name__) + +repo_path = Path(__file__).resolve().parents[2] / "resources" / "repos" / "svelte" / "test_repo" +NODE_MODULES = repo_path / "node_modules" +SVELTE_MARKER = NODE_MODULES / "svelte" / "package.json" +SVELTE_KIT_ADAPTER_MARKER = NODE_MODULES / "@sveltejs" / "adapter-auto" / "package.json" +INSTALL_LOCK = repo_path / ".svelte-install.lock" + + +@pytest.fixture(scope="session", autouse=True) +def _install_svelte_test_repo_node_modules() -> None: + """Populate the Svelte fixture's project dependencies via npm.""" + if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists(): + log.info("Svelte test repo node_modules already populated; skipping npm install") + return + + npm_executable = shutil.which("npm.cmd") or shutil.which("npm") + if npm_executable is None: + pytest.skip("npm is not available; cannot install Svelte test repo dependencies") + + with FileLock(str(INSTALL_LOCK)): + if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists(): + log.info("Svelte test repo node_modules populated by another worker; skipping npm install") + return + + log.warning("Installing npm dependencies into the Svelte test repo at %s.", repo_path) + proc = subprocess.run( + [npm_executable, "install"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if proc.returncode != 0: + log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) + pytest.skip(f"npm install failed in {repo_path} (rc={proc.returncode}); see logs for details") + + if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): + pytest.skip("npm install completed but required Svelte fixture packages are missing") + + log.info("Svelte test repo node_modules installed successfully") diff --git a/test/solidlsp/svelte/test_svelte_basic.py b/test/solidlsp/svelte/test_svelte_basic.py new file mode 100644 index 00000000..6540d1b8 --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_basic.py @@ -0,0 +1,69 @@ +import os +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import read_repo_file +from test.solidlsp.svelte import conftest as svelte_test_conftest +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.svelte + + +class TestSvelteLanguageServer: + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SVELTE], indirect=True) + def test_svelte_language_server_root_matches_repo_path(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert repo_path.resolve() == svelte_test_conftest.repo_path.resolve() + assert Path(language_server.language_server.repo_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_svelte_and_typescript_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + + assert SymbolUtils.symbol_tree_contains_name(symbols, "game"), "game variable not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Game"), "Game class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "words"), "words export not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "count"), "count export not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_document_symbols_inside_svelte_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Counter.svelte") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_names = [symbol.get("name") for symbol in symbols[0]] + + assert "offset" in symbol_names + assert "modulo" in symbol_names + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_definition_from_component_import_to_svelte_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Header.svelte") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") + + definitions = language_server.request_definition(file_path, coords.line, coords.col) + + assert len(definitions) == 1, definitions + assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte" + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "lib", "diagnostics_sample.ts"), + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_diagnostics_in_svelte_file(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "lib", "diagnostics_sample.svelte"), + ("number", "string"), + min_count=1, + ) diff --git a/test/solidlsp/svelte/test_svelte_references.py b/test/solidlsp/svelte/test_svelte_references.py new file mode 100644 index 00000000..7e25597c --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_references.py @@ -0,0 +1,26 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.svelte + + +class TestSvelteReferences: + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_references_across_svelte_and_typescript(self, language_server: SolidLanguageServer) -> None: + refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17) + ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} + + assert "src/routes/(sverdle)/words.server.ts" in ref_paths + assert "src/lib/game.ts" in ref_paths + assert "src/routes/(sverdle)/+page.svelte" in ref_paths + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None: + refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13) + ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} + + assert "src/routes/(sverdle)/+page.server.ts" in ref_paths diff --git a/test/solidlsp/svelte/test_svelte_rename.py b/test/solidlsp/svelte/test_svelte_rename.py new file mode 100644 index 00000000..58a0254b --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_rename.py @@ -0,0 +1,123 @@ +import os +from collections.abc import Iterable +from urllib.parse import unquote + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import TextEdit, WorkspaceEdit +from test.solidlsp.conftest import read_repo_file + +pytestmark = pytest.mark.svelte + + +def _iter_workspace_edit_entries(workspace_edit: WorkspaceEdit) -> Iterable[tuple[str, TextEdit]]: + if workspace_edit.get("changes"): + for uri, edits in workspace_edit["changes"].items(): + for edit in edits: + yield uri, edit + + for change in workspace_edit.get("documentChanges") or []: + if "textDocument" not in change or "edits" not in change: + continue + uri = change["textDocument"]["uri"] + for edit in change["edits"]: + yield uri, edit + + +def _assert_rename_edit( + workspace_edit: WorkspaceEdit | None, + new_name: str, + expected_path_fragments: set[str], +) -> None: + assert workspace_edit is not None, "rename should return a WorkspaceEdit" + + entries = list(_iter_workspace_edit_entries(workspace_edit)) + assert entries, workspace_edit + + edited_paths = {unquote(uri).replace("\\", "/") for uri, _edit in entries} + for expected_path in expected_path_fragments: + assert any(expected_path in edited_path for edited_path in edited_paths), ( + f"Expected rename edit for {expected_path}, got {sorted(edited_paths)}" + ) + + for uri, edit in entries: + assert "range" in edit, f"TextEdit in {uri} should have a range" + assert "newText" in edit, f"TextEdit in {uri} should have newText" + assert new_name in edit["newText"], f"TextEdit in {uri} should include {new_name}, got {edit['newText']}" + assert edit["range"]["start"]["line"] >= 0 + assert edit["range"]["start"]["character"] >= 0 + + +class TestSvelteRename: + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_svelte_export_updates_svelte_importers(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Counter.svelte") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "score") + + _assert_rename_edit( + workspace_edit, + "score", + {"src/lib/components/Counter.svelte", "src/lib/components/Header.svelte"}, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_svelte_export_updates_ts_and_svelte_files(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Words.svelte") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(words)") + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "vocabulary") + + _assert_rename_edit( + workspace_edit, + "vocabulary", + { + "src/lib/components/Words.svelte", + "src/routes/(sverdle)/words.server.ts", + "src/lib/game.ts", + "src/routes/(sverdle)/+page.svelte", + }, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_ts_export_declaration_site_workspace_edit(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "routes", "(sverdle)", "words.server.ts") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(allowed)") + + assert coords is not None + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "allowedWords") + + _assert_rename_edit( + workspace_edit, + "allowedWords", + {"src/routes/(sverdle)/words.server.ts"}, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_ts_class_cross_file_workspace_edit_when_supported(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "game.ts") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(Game)") + + assert coords is not None + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "SverdleGame") + + assert workspace_edit is not None, ( + "SvelteLanguageServer.request_rename_symbol_edit returned None for a cross-file TS class rename; " + "companion SvelteTypeScriptServer (typescript-svelte-plugin) should provide this edit." + ) + + _assert_rename_edit( + workspace_edit, + "SverdleGame", + { + "src/lib/game.ts", + "src/routes/(sverdle)/+page.server.ts", + "src/lib/components/Counter.svelte", + }, + ) From 5b9600bcaf2dc600e263f25654eb62a963bd027c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 14 May 2026 18:17:46 +0200 Subject: [PATCH 6/7] Make tool descriptions more amenable to tool search mechanisms Avoid referencing other tools' names, general improvements --- CHANGELOG.md | 3 ++ src/serena/tools/file_tools.py | 24 +++------------- src/serena/tools/symbol_tools.py | 48 ++++++++++++++------------------ 3 files changed, 28 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d02ecd5b..2281bd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* General: + - Make tool descriptions more amenable to tool search mechanisms as now used in several clients (e.g. avoid referencing other tools' names, etc.) + * Language Servers: - No longer store temporary files (e.g. downloads) in `~/solidlsp_tmp`; instead, use OS-specific temporary directories - Add **GDScript** (Godot Engine) support. Serena connects over TCP to the Godot editor's built-in LSP server (port 6008, same for Godot 3 and 4) — no separate language server process to install. Godot major version is auto-detected from `config_version` in `project.godot`. Note: Godot's LSP does not implement `workspace/symbol`; first workspace-wide scans fall back to per-file requests and can be slow for large projects (results are cached to disk). See the [GDScript Setup Guide](https://oraios.github.io/serena/03-special-guides/godot_gdscript_setup_guide_for_serena.html) for details. Closes #1446. diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index c913ca60..7e6a2288 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -24,8 +24,7 @@ class ReadFileTool(Tool): def apply(self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = -1) -> str: """ - Reads the given file or a chunk of it. Generally, symbolic operations - like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for. + Reads the given file or a chunk of it. :param relative_path: the relative path to the file to read :param start_line: the 0-based index of the first line to be retrieved. @@ -337,8 +336,7 @@ class SearchForPatternTool(Tool): """ Offers a flexible search for arbitrary patterns in the codebase, including the possibility to search in non-code files. - Generally, symbolic operations like find_symbol or find_referencing_symbols - should be preferred if you know which symbols you are looking for. + Prefer symbolic operations if you know which symbols you are looking for! Pattern Matching Logic: For each match, the returned result will contain the full lines where the @@ -350,16 +348,6 @@ class SearchForPatternTool(Tool): Be careful to not use greedy quantifiers unnecessarily, it is usually better to use non-greedy quantifiers like .*? to avoid matching too much content. - File Selection Logic: - The files in which the search is performed can be restricted very flexibly. - Using `restrict_search_to_code_files` is useful if you are only interested in code symbols (i.e., those - symbols that can be manipulated with symbolic tools like find_symbol). - You can also restrict the search to a specific file or directory, - and provide glob patterns to include or exclude certain files on top of that. - The globs are matched against relative file paths from the project root (not to the `relative_path` parameter that - is used to further restrict the search). - Smartly combining the various restrictions allows you to perform very targeted searches. - :param substring_pattern: Regular expression for a substring pattern to search for :param context_lines_before: Number of lines of context to include before each match :param context_lines_after: Number of lines of context to include after each match @@ -379,12 +367,8 @@ class SearchForPatternTool(Tool): Don't adjust unless there is really no other way to get the content required for the task. Instead, if the output is too long, you should make a stricter query. - :param restrict_search_to_code_files: whether to restrict the search to only those files where - analyzed code symbols can be found. Otherwise, will search all non-ignored files. - Set this to True if your search is only meant to discover code that can be manipulated with symbolic tools. - For example, for finding classes or methods from a name pattern. - Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, - which is why it is the default. + :param restrict_search_to_code_files: whether to restrict the search to source files. + Otherwise, will search all non-ignored files (default). :return: A mapping from file paths to matched consecutive lines (0-based line numbers). """ relative_path = relative_path.strip() diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index caa42b3a..2045c0c5 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -167,25 +167,20 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; for the case where the symbol is a class, this will return its methods). Ignored if `include_body=True`. Default 0. - :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. + :param relative_path: (optional) restrict search to this file or directory. If None, searches entire codebase. If a directory is passed, the search will be restricted to the files in that directory. If a file is passed, the search will be restricted to that file. - If you have some knowledge about the codebase, you should use this parameter, as it will significantly - speed up the search as well as reduce the number of results. :param include_body: whether to include the symbol's source code. Use judiciously. :param include_info: whether to include additional info (hover-like, typically including docstring and signature), about the symbol (ignored if include_body is True). Info is never included for child symbols. Note: Depending on the language, this can be slow (e.g., C/C++). - :param include_kinds: List of LSP symbol kind integers to include. - If not provided, all kinds are included. - :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. - If not provided, no kinds are excluded. + :param include_kinds: (optional) limits results to the given LSP symbol kinds (integers) + :param exclude_kinds: (optional) list of LSP symbol kinds (integers) to exclude. :param substring_matching: If True, use substring matching for the last element of the pattern, such that "Foo/get" would match "Foo/getValue" and "Foo/getData". - :param max_matches: Maximum number of permitted matches. If exceeded, a shortened result is returned + :param max_matches: maximum number of permitted matches. If exceeded, a shortened result is returned which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol. - :param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned. - -1 means the default value from the config will be used. + :param max_answer_chars: max result length; -1 for default :return: symbols (with locations) matching the name. """ if include_body: @@ -260,13 +255,13 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): Finds references to the symbol at the given `name_path`. The result will contain metadata about the referencing symbols as well as a short code snippet around the reference. - :param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool. + :param name_path: name path of the symbol :param relative_path: the relative path to the file containing the symbol for which to find references. Note: for external dependencies, this must be an identifier starting with ` Date: Thu, 14 May 2026 22:00:25 +0200 Subject: [PATCH 7/7] Improve docstring of ReplaceSymbolBodyTool, clarifying what constitutes the body #1484 --- src/serena/tools/symbol_tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 2045c0c5..add9d345 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -570,16 +570,16 @@ class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): body: str, ) -> str: r""" - Replaces the body of the symbol with the given `name_path`. + Replaces the body of the given symbol. - The tool shall be used to replace symbol bodies that have been previously retrieved explicitly! - IMPORTANT: Do not use this tool if you do not know what exactly constitutes the body of the symbol. + IMPORTANT: Only replace symbol bodies if you have previously made a retrieval with include_body=True and thus know what + constitutes the body! :param name_path: name path of the symbol whose body to replace :param relative_path: the relative path to the file containing the symbol :param body: the new symbol body. The symbol body is the definition of a symbol in the programming language, including e.g. the signature line for functions. - IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. + Depending on the language, it may or may not include a preceding docstring or other preceding annotations. """ with self.DiagnosticsContext(self, relative_path) as diagnostics_context: code_editor = self.create_code_editor()