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% + + +{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 is a clone of Wordle, the + word guessing game. To play, enter a five-letter English word. For example: +
+ ++ 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: +
+ +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!
+