diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac7d1a197..20fed5625c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: + - Add support for **Astro** (`@astrojs/language-server`) with a companion `typescript-language-server` + (configured with `@astrojs/ts-plugin`) for cross-file go-to-definition, references and rename between + `.ts`/`.js` and `.astro` files. Requires Node.js v18+ and npm; dependencies auto-install on first use. + Use language `astro` instead of also enabling `typescript`. - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. diff --git a/README.md b/README.md index 76c0e383f8..dc78cb6ade 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, CUE, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, 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. +Ada / SPARK, AL, Angular, Ansible, Astro, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, 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 d827971f5e..0c0851435d 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -46,6 +46,11 @@ Some languages require additional installations or setup steps, as noted. the upstream `@ansible/ansible-language-server@1.2.3` supports hover, completion, definition, semantic tokens, and validation; document symbols, workspace symbols, references, and rename are not supported by this version) +* **Astro** + (requires Node.js v18+ and npm; supports `.astro` components plus TypeScript/JavaScript files via + `@astrojs/language-server`; a companion `typescript-language-server` configured with `@astrojs/ts-plugin` + is spawned automatically for cross-file go-to-definition, references, and rename across `.ts`/`.js` and + `.astro` files; use language `astro` for Astro projects instead of also enabling `typescript`) * **Bash** * **BSL** (1C:Enterprise / OneScript) (requires Java 21+ on PATH; uses [bsl-language-server](https://github.com/1c-syntax/bsl-language-server) by 1c-syntax; the JAR is auto-downloaded and SHA-256-verified for the bundled default version; supports `.bsl` and `.os` files; configure optional `ls_path` or `bsl_ls_version` under `ls_specific_settings.bsl`) diff --git a/pyproject.toml b/pyproject.toml index bdd04a6f6b..48afd3b173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -331,6 +331,7 @@ markers = [ "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)", + "astro: language server running for Astro (uses TypeScript LSP)", "php: language server running for PHP", "perl: language server running for Perl", "csharp: language server running for C#", diff --git a/src/serena/hooks.py b/src/serena/hooks.py index e4c76769c6..3c56f73ddb 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -282,6 +282,7 @@ def reset(self) -> None: _CODE_FILE_EXTENSIONS: frozenset[str] = frozenset( ( ".al", + ".astro", ".bash", ".c", ".clj", diff --git a/src/solidlsp/language_servers/astro_language_server.py b/src/solidlsp/language_servers/astro_language_server.py new file mode 100644 index 0000000000..ed150b7aac --- /dev/null +++ b/src/solidlsp/language_servers/astro_language_server.py @@ -0,0 +1,703 @@ +""" +Astro Language Server implementation using @astrojs/language-server with companion TypeScript LS. +Operates in a dual-server setup: the Astro LS handles .astro files (parsing, document symbols), +while a companion TypeScript LS (configured with @astrojs/ts-plugin) handles definitions, +references and rename for .ts/.js files and cross-file resolution. + +This mirrors the Vue and Svelte language servers, which use a self-contained companion server +held in ``self._ts_server`` rather than a shared companion base class. +""" + +import logging +import os +import pathlib +import shutil +import threading +from collections.abc import Callable +from pathlib import Path, PurePath +from time import sleep + +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, + prefer_non_node_modules_definition, +) +from solidlsp.ls import LanguageServerDependencyProvider, SolidLanguageServer +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_utils import PathUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class AstroTypeScriptServer(TypeScriptLanguageServer): + """TypeScript LS configured with @astrojs/ts-plugin for Astro file support.""" + + @classmethod + @override + def get_language_enum_instance(cls) -> Language: + """Return TYPESCRIPT since this is a TypeScript language server variant. + + AstroTypeScriptServer is a companion server that uses TypeScript's language server + with the Astro TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility + with the TypeScript language server infrastructure. + """ + return Language.TYPESCRIPT + + def get_source_fn_matcher(self) -> FilenameMatcher: + # Override with an Astro-specific matcher to ensure .astro files are included (they can be + # discovered via references); otherwise references in .astro files would be filtered out. + return Language.ASTRO.get_source_fn_matcher() + + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): + """Dependency provider that returns a pre-resolved executable path. + + The Astro LS install (run by ``AstroLanguageServer._setup_runtime_dependencies``) + already locates the ``typescript-language-server`` binary alongside the Astro + language server, so the companion does not need to perform another install + lookup -- it just returns the path it was constructed with. + """ + + 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 + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """Return the correct language ID for files. + + Astro files must be opened with language ID "astro" for the @astrojs/ts-plugin + to process them correctly. The plugin is configured with "languages": ["astro"] + in the initialization options. JSX/TSX use the react variants so tsserver does + not truncate symbol ranges at the first multi-line JSX expression (see the base + ``TypeScriptLanguageServer._get_language_id_for_file``); Astro projects commonly + include .tsx/.jsx via React/Preact/Solid integrations. + """ + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".astro": + return "astro" + elif ext == ".tsx": + return "typescriptreact" + elif ext == ".jsx": + return "javascriptreact" + elif ext in (".ts", ".mts", ".cts"): + return "typescript" + elif ext in (".js", ".mjs", ".cjs"): + return "javascript" + else: + return "typescript" + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + astro_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: str, + ): + self._astro_plugin_path = astro_plugin_path + self._custom_tsdk_path = tsdk_path + # Stored as instance state so the override survives across concurrent + # constructions of multiple AstroLanguageServer instances. + self._explicit_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider( + self._custom_settings, + self._ls_resources_dir, + self._explicit_ts_ls_executable, + ) + + @override + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() + + params["initializationOptions"] = { + "plugins": [ + { + "name": "@astrojs/ts-plugin", + "location": self._astro_plugin_path, + "languages": ["astro"], + } + ], + "tsserver": { + "path": self._custom_tsdk_path, + }, + } + + if "workspace" in params["capabilities"]: + params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True} + + 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 AstroLanguageServer(SolidLanguageServer): + """ + Language server for Astro components using @astrojs/language-server with companion TypeScript LS. + + You can pass the following entries in ls_specific_settings["astro"]: + - astro_language_server_version: Version of @astrojs/language-server to install (default: "2.16.11") + + Note: TypeScript versions are configured via ls_specific_settings["typescript"]: + - typescript_version: Version of TypeScript to install (default: "5.9.3") + - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") + """ + + TS_SERVER_READY_TIMEOUT = 5.0 + ASTRO_SERVER_READY_TIMEOUT = 3.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + astro_lsp_executable_path, self.tsdk_path, self._ts_ls_cmd = self._setup_runtime_dependencies(config, solidlsp_settings) + self._astro_ls_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "astro-lsp") + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=astro_lsp_executable_path, cwd=repository_root_path), + "astro", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self._ts_server: AstroTypeScriptServer | None = None + self._ts_server_started = False + self._astro_files_indexed = False + self._indexed_astro_file_uris: list[str] = [] + self._ls_operational_ready_event = threading.Event() + self._ls_operational_lock = threading.Lock() + + def _ensure_ls_operational(self) -> None: + # short-circuit completed warm-up + if self._ls_operational_ready_event.is_set(): + return + + # serialize the warm-up sequence + with self._ls_operational_lock: + # short-circuit repeated callers after waiting for the lock + if self._ls_operational_ready_event.is_set(): + return + + # validate server availability + if not self.server_started: + raise SolidLSPException("Language Server not started") + + # wait for cross-file reference readiness + if not self._has_waited_for_cross_file_references: + sleep(self._get_wait_time_for_cross_file_referencing()) + self._has_waited_for_cross_file_references = True + + # index Astro files on the companion TypeScript server + self._ensure_astro_files_indexed_on_ts_server() + + # publish operational readiness + self._ls_operational_ready_event.set() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ".astro", + ] + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # Inherited document-symbol requests open files on this primary server, so JSX/TSX must use + # the react variants here too (see the companion's _get_language_id_for_file for rationale). + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".astro": + return "astro" + elif ext == ".tsx": + return "typescriptreact" + elif ext == ".jsx": + return "javascriptreact" + elif ext in (".ts", ".mts", ".cts"): + return "typescript" + elif ext in (".js", ".mjs", ".cjs"): + return "javascript" + else: + return "astro" + + def _is_typescript_file(self, file_path: str) -> bool: + ext = os.path.splitext(file_path)[1].lower() + return ext in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") + + def _find_all_astro_files(self) -> list[str]: + astro_files = [] + repo_path = Path(self.repository_root_path) + + for astro_file in repo_path.rglob("*.astro"): + try: + relative_path = str(astro_file.relative_to(repo_path)) + if "node_modules" not in relative_path and not relative_path.startswith("."): + astro_files.append(relative_path) + except Exception as e: + log.debug(f"Error processing Astro file {astro_file}: {e}") + + return astro_files + + def _ensure_astro_files_indexed_on_ts_server(self) -> None: + if self._astro_files_indexed: + return + + assert self._ts_server is not None + log.info("Indexing .astro files on TypeScript server for cross-file references") + astro_files = self._find_all_astro_files() + log.debug(f"Found {len(astro_files)} .astro files to index") + + # Prepare the TS server to track new $/progress notifications triggered + # by the didOpen calls below. Must happen BEFORE opening files to avoid + # a race where progress begins and ends before we start waiting. + self._ts_server.expect_indexing() + + for astro_file in astro_files: + try: + with self._ts_server.open_file(astro_file) as file_buffer: + file_buffer.ref_count += 1 + self._indexed_astro_file_uris.append(file_buffer.uri) + except Exception as e: + log.debug(f"Failed to open {astro_file} on TS server: {e}") + + self._astro_files_indexed = True + log.info("Astro file indexing on TypeScript server complete, waiting for TS server to finish processing") + + self._wait_for_ts_indexing_complete() + + def _wait_for_ts_indexing_complete(self) -> None: + """Wait for the companion TypeScript server to finish processing opened Astro files. + + Uses the $/progress tracking in TypeScriptLanguageServer: after Astro files are + opened, tsserver sends "Initializing JS/TS language features..." progress. + We wait for all progress tokens to complete, with a timeout fallback. + """ + assert self._ts_server is not None + timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT + if self._ts_server.wait_for_indexing(timeout=timeout): + log.info("TypeScript server finished indexing Astro files (signaled via $/progress)") + else: + log.warning(f"Timeout ({timeout}s) waiting for TypeScript server to finish indexing Astro files, proceeding anyway") + + def _send_ts_references_request(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + assert self._ts_server is not None + uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) + request_params = { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": True}, + } + + with self._ts_server.open_file(relative_file_path): + response = self._ts_server.handler.send.references(request_params) # type: ignore[arg-type] + + result: list[ls_types.Location] = [] + if response is not None: + for item in response: + abs_path = PathUtils.uri_to_path(item["uri"]) + if not Path(abs_path).is_relative_to(self.repository_root_path): + log.debug(f"Found reference outside repository: {abs_path}, skipping") + continue + + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.is_ignored_path(str(rel_path)): + log.debug(f"Ignoring reference in {rel_path}") + continue + + new_item: dict = {} + new_item.update(item) + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) + result.append(ls_types.Location(**new_item)) # type: ignore + + return result + + @override + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + self._ensure_ls_operational() + return self._send_ts_references_request(relative_file_path, line=line, column=column) + + @override + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + self._ensure_ls_operational() + assert 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) + + @override + def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: + self._ensure_ls_operational() + assert 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) + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._ensure_ls_operational() + assert self._ts_server is not None + return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + + def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None: + """ + Calls ``edit_fn`` on the TypeScript server if the file is open there. + + Only applicable to non-TypeScript files (i.e. .astro files) that have been + indexed on the TypeScript server for cross-file reference support. + """ + if self._ts_server is None or not self._ts_server_started: + return + if self._is_typescript_file(relative_file_path): + return + + absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) + uri = pathlib.Path(absolute_file_path).as_uri() + if uri in self._ts_server.open_file_buffers: + edit_fn() + + @override + def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: + result = super().insert_text_at_position(relative_file_path, line, column, text_to_be_inserted) + self._forward_edit_to_ts_server_if_needed( + relative_file_path, + lambda: self._ts_server.insert_text_at_position( # type: ignore[union-attr] + relative_file_path, line, column, text_to_be_inserted + ), + ) + return result + + @override + def delete_text_between_positions( + self, + relative_file_path: str, + start: ls_types.Position, + end: ls_types.Position, + ) -> str: + deleted_text = super().delete_text_between_positions(relative_file_path, start, end) + self._forward_edit_to_ts_server_if_needed( + relative_file_path, + lambda: self._ts_server.delete_text_between_positions( # type: ignore[union-attr] + relative_file_path, start, end + ), + ) + return deleted_text + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[list[str], str, str]: + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Get TypeScript version settings from TypeScript language server settings + typescript_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + typescript_version = typescript_config.get("typescript_version", "5.9.3") + typescript_language_server_version = typescript_config.get("typescript_language_server_version", "5.1.3") + astro_config = solidlsp_settings.get_ls_specific_settings(Language.ASTRO) + astro_language_server_version = astro_config.get("astro_language_server_version", "2.16.11") + # @astrojs/ts-plugin is NOT a dependency of @astrojs/language-server, so it must be installed + # explicitly. Without it the companion tsserver has no .astro awareness and cross-file + # resolution between .ts/.js and .astro files silently returns nothing. + astro_ts_plugin_version = astro_config.get("astro_ts_plugin_version", "1.10.10") + npm_registry = astro_config.get("npm_registry", typescript_config.get("npm_registry")) + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="astro-language-server", + description="Astro language server package", + command=build_npm_install_command("@astrojs/language-server", astro_language_server_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="astro-ts-plugin", + description="Astro TypeScript plugin, gives the companion tsserver .astro awareness", + command=build_npm_install_command("@astrojs/ts-plugin", astro_ts_plugin_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript", + description="TypeScript (required for tsdk)", + command=build_npm_install_command("typescript", typescript_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="TypeScript language server (for Astro companion TS forwarding)", + command=build_npm_install_command("typescript-language-server", typescript_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + astro_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "astro-lsp") + astro_executable_path = os.path.join(astro_ls_dir, "node_modules", ".bin", "astro-ls") + ts_ls_executable_path = os.path.join(astro_ls_dir, "node_modules", ".bin", "typescript-language-server") + + if os.name == "nt": + astro_executable_path += ".cmd" + ts_ls_executable_path += ".cmd" + + tsdk_path = os.path.join(astro_ls_dir, "node_modules", "typescript", "lib") + + # Check if installation is needed based on executables AND version + version_file = os.path.join(astro_ls_dir, ".installed_version") + expected_version = ( + f"{astro_language_server_version}_{astro_ts_plugin_version}_{typescript_version}_{typescript_language_server_version}" + ) + + needs_install = False + if not os.path.exists(astro_executable_path) or not os.path.exists(ts_ls_executable_path): + log.info("Astro/TypeScript Language Server executables not found.") + needs_install = True + elif os.path.exists(version_file): + with open(version_file) as f: + installed_version = f.read().strip() + if installed_version != expected_version: + log.info( + f"Astro Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." + ) + needs_install = True + else: + # No version file exists, assume old installation needs refresh + log.info("Astro Language Server version file not found. Reinstalling to ensure correct version...") + needs_install = True + + if needs_install: + log.info("Installing Astro/TypeScript Language Server dependencies...") + deps.install(astro_ls_dir) + # Write version marker file + with open(version_file, "w") as f: + f.write(expected_version) + log.info("Astro language server dependencies installed successfully") + + if not os.path.exists(astro_executable_path): + raise FileNotFoundError( + f"astro-ls executable not found at {astro_executable_path}, something went wrong with the installation." + ) + + if not os.path.exists(ts_ls_executable_path): + raise FileNotFoundError( + f"typescript-language-server executable not found at {ts_ls_executable_path}, something went wrong with the installation." + ) + + return [astro_executable_path, "--stdio"], tsdk_path, ts_ls_executable_path + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "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}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "typescript": { + "tsdk": self.tsdk_path, + }, + }, + } + return initialize_params + + def _start_typescript_server(self) -> None: + try: + astro_ts_plugin_path = os.path.join(self._astro_ls_dir, "node_modules", "@astrojs", "ts-plugin") + if not os.path.exists(astro_ts_plugin_path): + log.warning( + "Astro TypeScript plugin not found at %s. The companion tsserver will lack .astro " + "awareness, so cross-file resolution between .ts/.js and .astro files will not work. " + "This usually means the @astrojs/ts-plugin install did not complete.", + astro_ts_plugin_path, + ) + + ts_config = LanguageServerConfig( + code_language=Language.TYPESCRIPT, + trace_lsp_communication=False, + ) + + log.info("Creating companion AstroTypeScriptServer") + self._ts_server = AstroTypeScriptServer( + config=ts_config, + repository_root_path=self.repository_root_path, + solidlsp_settings=self._solidlsp_settings, + astro_plugin_path=astro_ts_plugin_path, + tsdk_path=self.tsdk_path, + ts_ls_executable_path=self._ts_ls_cmd, + ) + + log.info("Starting companion TypeScript server") + self._ts_server.start() + + log.info("Waiting for companion TypeScript server to be ready...") + if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT): + log.warning( + f"Timeout waiting for companion TypeScript server to be ready after {self.TS_SERVER_READY_TIMEOUT} seconds, proceeding anyway" + ) + self._ts_server.server_ready.set() + + self._ts_server_started = True + log.info("Companion TypeScript server ready") + except Exception as e: + log.error(f"Error starting TypeScript server: {e}") + self._ts_server = None + self._ts_server_started = False + raise + + def _cleanup_indexed_astro_files(self) -> None: + if not self._indexed_astro_file_uris or self._ts_server is None: + return + + log.debug(f"Cleaning up {len(self._indexed_astro_file_uris)} indexed Astro files") + for uri in self._indexed_astro_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] + log.debug(f"Closed indexed Astro file: {uri}") + except Exception as e: + log.debug(f"Error closing indexed Astro file {uri}: {e}") + + self._indexed_astro_file_uris.clear() + + def _stop_typescript_server(self) -> None: + if self._ts_server is not None: + try: + log.info("Stopping companion TypeScript server") + self._ts_server.stop() + except Exception as e: + log.warning(f"Error stopping TypeScript server: {e}") + finally: + self._ts_server = None + self._ts_server_started = False + + @override + def _start_server(self) -> None: + self._start_typescript_server() + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + return + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + message_text = msg.get("message", "") + if "initialized" in message_text.lower() or "ready" in message_text.lower(): + log.info("Astro language server ready signal detected") + self.server_ready.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + # The companion is already running. If anything below fails, our caller never received an + # initialised handle and therefore can't invoke stop() -- so we tear down the companion and + # the partially-started Astro process here to avoid leaking Node processes. + try: + log.info("Starting Astro server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Astro server: {init_response}") + + assert init_response["capabilities"]["textDocumentSync"] in [1, 2] + + self.server.notify.initialized({}) + + log.info("Waiting for Astro language server to be ready...") + if not self.server_ready.wait(timeout=self.ASTRO_SERVER_READY_TIMEOUT): + log.info("Timeout waiting for Astro server ready signal, proceeding anyway") + self.server_ready.set() + else: + log.info("Astro server initialization complete") + except Exception: + self._stop_typescript_server() + try: + self.server.stop() + except Exception as e: + log.warning("Error stopping Astro server during startup-failure cleanup: %s", e) + raise + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5.0 + + @override + def stop(self, shutdown_timeout: float = 5.0) -> None: + # serialize shutdown with operational warm-up + with self._ls_operational_lock: + self.server_started = False + self._ls_operational_ready_event.clear() + self._cleanup_indexed_astro_files() + self._stop_typescript_server() + + super().stop(shutdown_timeout) + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index e46106e2f5..8e23e4ecd8 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -100,6 +100,11 @@ class Language(str, Enum): LEAN4 = "lean4" GROOVY = "groovy" VUE = "vue" + ASTRO = "astro" + """Astro language server using @astrojs/language-server with a companion TypeScript LS. + Handles .astro Single File Components plus TypeScript and JavaScript files in Astro + projects. Requires Node.js and npm. + """ SVELTE = "svelte" """Svelte language server using svelte-language-server. Supports .svelte Single File Components plus TypeScript and JavaScript @@ -298,8 +303,8 @@ def get_priority(self) -> int: # 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/Svelte are supersets of TypeScript/JavaScript) - case self.VUE | self.SVELTE: + # languages that are supersets of others (Vue/Astro/Svelte are supersets of TypeScript/JavaScript) + case self.VUE | self.ASTRO | self.SVELTE: return 1 # regular languages case _: @@ -478,6 +483,13 @@ def get_source_fn_matcher(self) -> FilenameMatcher: for base_pattern in ["ts", "js"]: path_patterns.append(f".{prefix}{base_pattern}{postfix}") return FilenameMatcher(*path_patterns) + case self.ASTRO: + path_patterns = [".astro"] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + 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", ""]: @@ -593,6 +605,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.vue_language_server import VueLanguageServer return VueLanguageServer + case self.ASTRO: + from solidlsp.language_servers.astro_language_server import AstroLanguageServer + + return AstroLanguageServer case self.SVELTE: from solidlsp.language_servers.svelte_language_server import SvelteLanguageServer diff --git a/test/resources/repos/astro/test_repo/.gitignore b/test/resources/repos/astro/test_repo/.gitignore new file mode 100644 index 0000000000..e483be8ea6 --- /dev/null +++ b/test/resources/repos/astro/test_repo/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.astro/ +package-lock.json +.astro-install.lock diff --git a/test/resources/repos/astro/test_repo/astro.config.mjs b/test/resources/repos/astro/test_repo/astro.config.mjs new file mode 100644 index 0000000000..00dfbc84f7 --- /dev/null +++ b/test/resources/repos/astro/test_repo/astro.config.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + // Minimal config for testing +}); diff --git a/test/resources/repos/astro/test_repo/package.json b/test/resources/repos/astro/test_repo/package.json new file mode 100644 index 0000000000..5c64c3fa8e --- /dev/null +++ b/test/resources/repos/astro/test_repo/package.json @@ -0,0 +1,11 @@ +{ + "name": "astro-test-fixture", + "version": "1.0.0", + "type": "module", + "dependencies": { + "astro": "^5.16.6" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/test/resources/repos/astro/test_repo/src/components/Footer.astro b/test/resources/repos/astro/test_repo/src/components/Footer.astro new file mode 100644 index 0000000000..f16760058e --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/components/Footer.astro @@ -0,0 +1,7 @@ +--- +const year = new Date().getFullYear(); +--- + + diff --git a/test/resources/repos/astro/test_repo/src/components/Header.astro b/test/resources/repos/astro/test_repo/src/components/Header.astro new file mode 100644 index 0000000000..0b028aa1d5 --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/components/Header.astro @@ -0,0 +1,13 @@ +--- +interface Props { + siteName: string; +} + +const { siteName } = Astro.props; +--- + +
+ +
diff --git a/test/resources/repos/astro/test_repo/src/layouts/Layout.astro b/test/resources/repos/astro/test_repo/src/layouts/Layout.astro new file mode 100644 index 0000000000..e9dfce6038 --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/layouts/Layout.astro @@ -0,0 +1,19 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + {title} + + + + + diff --git a/test/resources/repos/astro/test_repo/src/pages/index.astro b/test/resources/repos/astro/test_repo/src/pages/index.astro new file mode 100644 index 0000000000..72e692eada --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/pages/index.astro @@ -0,0 +1,18 @@ +--- +import Layout from '../layouts/Layout.astro'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; +import { createCounter } from '../stores/counter'; +import { formatNumber } from '../utils/format'; + +const counter = createCounter(); +--- + + +
+
+

Welcome

+

Count: {formatNumber(counter.count)}

+
+