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", + }, + )