From 15502cee56e52e4e0d4583f5fa259edfacbd7dba Mon Sep 17 00:00:00 2001 From: Aleksei Tcelishchev Date: Wed, 23 Sep 2026 08:38:19 -0500 Subject: [PATCH] feat(solidlsp): add Astro language server support (#2085) * feat(solidlsp): add Astro language server support (T170) * test(solidlsp): fix relative_paths type extraction in test_astro_basic * refactor(solidlsp): replace asserts with explicit exceptions in Astro LSP * docs(memories): document rule against assert in language server guide * docs: add Astro language server documentation (README, docs, template, CHANGELOG) * feat(solidlsp): implement dual-server architecture for Astro with companion TypeScript server (#2085) - Implement companion AstroTypeScriptServer using @astrojs/ts-plugin for cross-file code intelligence between .ts and .astro components - Route .ts/.js document symbols, definitions, references, and renames through companion TypeScript server - Support two-way buffer edit forwarding between Astro LS and companion TS server - Replace all assert statements with explicit SolidLSPException and FileNotFoundError - Handle *.customData configuration requests for Volar HTML service - Register .astro extension in src/serena/hooks.py - Add test fixtures and tests for symbol retrieval and cross-file references from TypeScript to Astro components - Update documentation and CHANGELOG * fix(solidlsp): narrow astro_ls type to AstroLanguageServer in companion server test * fix(solidlsp): prevent caching empty document symbols and poll initial compilation in Nextflow tests --- .../adding_new_language_support_guide.md | 1 + CHANGELOG.md | 1 + README.md | 2 +- docs/01-about/020_programming-languages.md | 2 + pyproject.toml | 1 + src/serena/hooks.py | 1 + src/serena/resources/project.template.yml | 31 +- .../language_servers/astro_language_server.py | 667 ++++++++++++++++++ src/solidlsp/ls.py | 9 +- src/solidlsp/ls_config.py | 17 +- test/conftest.py | 1 + .../repos/astro/test_repo/.gitignore | 5 + .../repos/astro/test_repo/astro.config.mjs | 5 + .../repos/astro/test_repo/package.json | 8 + .../astro/test_repo/src/components/Card.astro | 24 + .../test_repo/src/components/Footer.astro | 7 + .../test_repo/src/components/Header.astro | 13 + .../astro/test_repo/src/layouts/Layout.astro | 19 + .../astro/test_repo/src/pages/index.astro | 18 + .../astro/test_repo/src/stores/counter.ts | 21 + .../repos/astro/test_repo/src/utils/format.ts | 13 + .../repos/astro/test_repo/tsconfig.json | 11 + test/serena/test_serena_agent.py | 8 + test/solidlsp/astro/__init__.py | 1 + test/solidlsp/astro/conftest.py | 69 ++ test/solidlsp/astro/test_astro_basic.py | 118 ++++ .../astro/test_astro_symbol_retrieval.py | 60 ++ test/solidlsp/nextflow/test_nextflow_basic.py | 13 +- 28 files changed, 1123 insertions(+), 23 deletions(-) create mode 100644 src/solidlsp/language_servers/astro_language_server.py create mode 100644 test/resources/repos/astro/test_repo/.gitignore create mode 100644 test/resources/repos/astro/test_repo/astro.config.mjs create mode 100644 test/resources/repos/astro/test_repo/package.json create mode 100644 test/resources/repos/astro/test_repo/src/components/Card.astro create mode 100644 test/resources/repos/astro/test_repo/src/components/Footer.astro create mode 100644 test/resources/repos/astro/test_repo/src/components/Header.astro create mode 100644 test/resources/repos/astro/test_repo/src/layouts/Layout.astro create mode 100644 test/resources/repos/astro/test_repo/src/pages/index.astro create mode 100644 test/resources/repos/astro/test_repo/src/stores/counter.ts create mode 100644 test/resources/repos/astro/test_repo/src/utils/format.ts create mode 100644 test/resources/repos/astro/test_repo/tsconfig.json create mode 100644 test/solidlsp/astro/__init__.py create mode 100644 test/solidlsp/astro/conftest.py create mode 100644 test/solidlsp/astro/test_astro_basic.py create mode 100644 test/solidlsp/astro/test_astro_symbol_retrieval.py diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index 162683e7..e0498293 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -63,6 +63,7 @@ To implement a new language server using the DependencyProvider pattern: **Implementation Pointers:** - Override `create_launch_command_env` if the launch command needs environment variables to be set (defaults to `{}` in the base implementation) - When calling subprocesses, e.g. to install dependencies, do not use `subprocess.run` directly; instead, use the `subprocess_run` helper function from `solidlsp.util.subprocess_util` + - **Do not use `assert` for runtime validations:** Python strips `assert` statements when run with `-O` (`python -O`). Environment checks (e.g. `node` or `npm` presence in `PATH`), directory/file verifications (e.g. SDK paths), and LSP capability handshake validations must use explicit `raise` statements (e.g., `SolidLSPException`, `FileNotFoundError`, or `RuntimeError`) rather than `assert` to avoid leaving the server half-initialized or failing silently. You should look at at least one existing implementation of each base class to understand how they work. diff --git a/CHANGELOG.md b/CHANGELOG.md index f58d90a5..03941763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ Status of the `main` branch. Changes prior to the next official version change w thread (#2038) * Language Servers: + - Add Astro language server support via `@astrojs/language-server` with a companion TypeScript language server (`@astrojs/ts-plugin`) for cross-file code intelligence (#2085) - Fix: Dart analysis server no longer receives rootUri/rootPath, which added the monorepo root as an extra analysis root and could pin a CPU core at idle (#2045) - Fix: The C# language server opened every `.csproj` found anywhere under the repository root, without consulting the project's ignore settings. On repositories that vendor third-party or diff --git a/README.md b/README.md index b20877c2..ed497527 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, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, Gleam, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, Wolfram Language, YAML, and Zig. +Ada / SPARK, AL, Angular, Ansible, Astro, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, Gleam, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, Wolfram Language, 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 ce0db2db..87306c29 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -46,6 +46,8 @@ 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` files plus TypeScript/JavaScript files via `@astrojs/language-server`; a companion `typescript-language-server` + `@astrojs/ts-plugin` is spawned automatically for cross-file rename, go-to-definition, and references 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 d6bac82f..6f5d021f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -386,6 +386,7 @@ markers = [ "qml: language server running for QML (uses Qt's qmlls)", "gleam: language server running for Gleam (uses the Gleam compiler's bundled `gleam lsp`)", "wolfram: language server running for Wolfram Language (requires Mathematica 13.0+ or Wolfram Engine 12.1+)", + "astro: language server running for Astro (uses @astrojs/language-server)", ] [tool.codespell] diff --git a/src/serena/hooks.py b/src/serena/hooks.py index 4bd51a84..724dd4f9 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -297,6 +297,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): _CODE_FILE_EXTENSIONS: frozenset[str] = frozenset( ( ".al", + ".astro", ".bash", ".c", ".clj", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index c72f7ad0..6c48915d 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -2,21 +2,21 @@ project_name: "project_name" # list of language servers to start when using the LSP backend; choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart deno -# elixir elm erlang fortran fsharp -# gdscript gleam go groovy haskell -# haxe hlsl html java json -# julia julia_fatou kotlin latex lean4 -# lua luau markdown matlab msl -# nextflow nix ocaml pascal perl -# php php_phpactor php_phpantom powershell python -# python_basedpyright python_jedi python_pyrefly python_ty qml -# r rego ruby ruby_solargraph rust -# scala scss solidity svelte swift -# systemverilog terraform toml typescript typescript_vts -# vue wolfram yaml zig +# ada al angular ansible astro +# bash bsl clojure cpp cpp_ccls +# crystal csharp csharp_omnisharp cue dart +# deno elixir elm erlang fortran +# fsharp gdscript gleam go groovy +# haskell haxe hlsl html java +# json julia julia_fatou kotlin latex +# lean4 lua luau markdown matlab +# msl nextflow nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_basedpyright python_jedi python_pyrefly python_ty +# qml r rego ruby ruby_solargraph +# rust scala scss solidity svelte +# swift systemverilog terraform toml typescript +# typescript_vts vue wolfram yaml zig # (This list may be outdated; generated with scripts/print_language_list.py; # For the current list, see values of the LanguageServerId enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) @@ -25,6 +25,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 Astro projects, use astro (subsumes typescript/javascript for .astro projects; requires npm) # - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) # - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) 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 00000000..75e7832e --- /dev/null +++ b/src/solidlsp/language_servers/astro_language_server.py @@ -0,0 +1,667 @@ +""" +Provides Astro-specific instantiation of the LanguageServer class using +@astrojs/language-server with a companion TypeScript language server (@astrojs/ts-plugin). + +Operates in a dual-server setup: the Astro LS handles .astro files (AST parsing, +document symbols), while a companion TypeScript LS (configured with @astrojs/ts-plugin) +handles definitions, references, document symbols, and rename for .ts/.js files and +cross-file resolution between .ts/.js and .astro files. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +from collections.abc import Callable +from typing import Any + +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 ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, LanguageServerConfig, LanguageServerId +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +ASTRO_EXT = frozenset({".astro"}) +TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"}) +JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"}) +_MAX_FAILED_FILES_IN_ERROR = 10 + + +class AstroCompanionPreparationError(RuntimeError): + """Raised when the companion TypeScript server cannot be prepared deterministically.""" + + +def _is_ts_file(uri: str) -> bool: + return uri.lower().endswith(tuple(TS_EXT | JS_EXT)) + + +def _is_astro_file(uri: str) -> bool: + return uri.lower().endswith(tuple(ASTRO_EXT)) + + +class AstroTypeScriptServer(TypeScriptLanguageServer): + """Companion TypeScript language server for Astro projects. + + Loads ``@astrojs/ts-plugin`` so the TS graph becomes .astro-aware: + cross-file rename, find-references, and go-to-definition from .ts/.js files + into .astro consumers all work correctly through this companion. + + Spawned and owned by :class:`AstroLanguageServer`; not instantiated directly. + """ + + INDEXING_PROGRESS_TIMEOUT = 120.0 + SERVER_READY_TIMEOUT = 30.0 + + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): + """Returns the pre-installed typescript-language-server binary. + + The binary is installed by ``AstroLanguageServer.DependencyProvider``; + this provider 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, + astro_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: str, + ) -> None: + self._astro_plugin_path = astro_plugin_path + self._custom_tsdk_path = tsdk_path + self._explicit_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + + @override + def get_source_fn_matcher(self) -> FilenameMatcher: + # Include .astro so references returned by the plugin are not filtered out + return LanguageServerId.ASTRO.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: + """.astro files map to 'astro' to activate the plugin; TS/JS as normal.""" + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in ASTRO_EXT: + return "astro" + if ext == ".tsx": + return "typescriptreact" + if ext == ".jsx": + return "javascriptreact" + if ext in TS_EXT: + return "typescript" + if ext in JS_EXT: + return "javascript" + return "typescript" + + @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}, + } + 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() + + @override + def _handle_server_ready_timeout(self, timeout: float) -> None: + log.warning("Astro companion TypeScript server did not become ready within %.0fs; proceeding", timeout) + self.server_ready.set() + + @override + def _handle_project_indexing_timeout(self, timeout: float) -> None: + log.warning( + "Astro companion TypeScript server project indexing did not complete within %.0fs (%s)", + timeout, + self.describe_indexing_state(), + ) + + +class AstroLanguageServer(SolidLanguageServer): + """ + Astro language server using @astrojs/language-server with companion TypeScript LS. + """ + + 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: + if shutil.which("node") is None: + raise SolidLSPException("node is not installed or isn't in PATH. Please install NodeJS and try again.") + if shutil.which("npm") is None: + raise SolidLSPException("npm is not installed or isn't in PATH. Please install npm and try again.") + + package_version = self._custom_settings.get("astro_language_server_version", "2.17.0") + typescript_version = self._custom_settings.get("typescript_version", self._ts_settings.get("typescript_version", "5.9.3")) + typescript_language_server_version = self._custom_settings.get( + "typescript_language_server_version", + self._ts_settings.get("typescript_language_server_version", "5.1.3"), + ) + astro_ts_plugin_version = self._custom_settings.get("astro_ts_plugin_version", "1.10.10") + npm_registry = self._custom_settings.get("npm_registry", self._ts_settings.get("npm_registry")) + + install_dir = os.path.join(self._ls_resources_dir, f"astro-lsp-{package_version}") + executable_path = os.path.join(install_dir, "node_modules", ".bin", "astro-ls") + ts_ls_executable = os.path.join(install_dir, "node_modules", ".bin", "typescript-language-server") + if os.name == "nt": + executable_path += ".cmd" + ts_ls_executable += ".cmd" + + version_file = os.path.join(install_dir, ".installed_version") + expected_version = f"{package_version}_{typescript_version}_{typescript_language_server_version}_{astro_ts_plugin_version}" + needs_install = not os.path.exists(executable_path) or not os.path.exists(ts_ls_executable) + 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: + needs_install = True + + if needs_install: + log.info( + "Installing @astrojs/language-server@%s + @astrojs/ts-plugin@%s + typescript@%s + typescript-language-server@%s ...", + package_version, + astro_ts_plugin_version, + typescript_version, + typescript_language_server_version, + ) + runtime_deps = [ + RuntimeDependency( + id="@astrojs/language-server", + description="Astro language server", + command=build_npm_install_command("@astrojs/language-server", package_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="@astrojs/ts-plugin", + description="Astro TypeScript plugin for cross-file code intelligence", + command=build_npm_install_command("@astrojs/ts-plugin", astro_ts_plugin_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", + ), + ] + 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 @astrojs/language-server@{package_version} did not produce the expected binary." + ) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + ts_settings = self._solidlsp_settings.get_ls_specific_settings(LanguageServerId.TYPESCRIPT) + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, ts_settings) + + def __init__(self, config: LanguageServerConfig, repo_path: str, solidlsp_settings: SolidLSPSettings) -> None: + resolved_root = os.path.abspath(repo_path) + super().__init__( + config, + resolved_root, + None, + "astro", + solidlsp_settings, + ) + self.repo_path: str = resolved_root + self.tsdk_path = self._get_tsdk_path() + self._ts_server: AstroTypeScriptServer | None = None + self._ts_server_started: bool = False + self._astro_files_indexed: bool = False + self._indexed_astro_file_uris: list[str] = [] + + def _get_install_dir(self) -> str: + version = self._custom_settings.get("astro_language_server_version", "2.17.0") + return os.path.join(self._ls_resources_dir, f"astro-lsp-{version}") + + def _get_tsdk_path(self) -> str: + tsdk_candidate = os.path.join(self._get_install_dir(), "node_modules", "typescript", "lib") + if not os.path.isdir(tsdk_candidate): + raise FileNotFoundError( + f"TypeScript SDK not found at expected path: {tsdk_candidate}. Installation via DependencyProvider failed or version mismatch." + ) + return tsdk_candidate + + def _get_ts_ls_executable(self) -> str: + path = os.path.join(self._get_install_dir(), "node_modules", ".bin", "typescript-language-server") + if os.name == "nt": + path += ".cmd" + return path + + def _get_astro_ts_plugin_path(self) -> str: + return os.path.join(self._get_install_dir(), "node_modules", "@astrojs", "ts-plugin") + + def _find_all_astro_files(self) -> list[str]: + astro_files = [] + repo = pathlib.Path(self.repo_path) + for astro_file in repo.rglob("*.astro"): + try: + relative = str(astro_file.relative_to(repo)) + if "node_modules" not in relative and not relative.startswith("."): + astro_files.append(relative) + except Exception as exc: + log.debug("Error processing astro file %s: %s", astro_file, exc) + return astro_files + + def _get_companion_indexing_timeout(self) -> float: + ts_settings = self._solidlsp_settings.get_ls_specific_settings(LanguageServerId.TYPESCRIPT) + timeout = self._custom_settings.get( + "indexing_timeout", + ts_settings.get("indexing_timeout", AstroTypeScriptServer.INDEXING_PROGRESS_TIMEOUT), + ) + return float(timeout) + + def _ensure_astro_files_indexed_on_ts_server(self) -> None: + if self._astro_files_indexed: + return + if self._ts_server is None: + return + + log.info("Indexing .astro files on companion TypeScript server for cross-file awareness") + astro_files = self._find_all_astro_files() + log.debug("Found %d .astro files to index", len(astro_files)) + + self._ts_server.expect_indexing() + + failed_astro_files = [] + first_open_error: Exception | None = None + 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 exc: + log.debug("Failed to open %s on companion TS server: %s", astro_file, exc) + if first_open_error is None: + first_open_error = exc + failed_astro_files.append(astro_file) + + if failed_astro_files: + shown_files = sorted(failed_astro_files)[:_MAX_FAILED_FILES_IN_ERROR] + remainder = len(failed_astro_files) - len(shown_files) + listing = ", ".join(shown_files) + (f" and {remainder} more" if remainder else "") + raise AstroCompanionPreparationError( + f"Failed to open {len(failed_astro_files)} Astro file(s) on companion TypeScript server: {listing}" + ) from first_open_error + + self._astro_files_indexed = True + log.info("Astro file indexing complete; waiting for companion TS server to finish processing") + + timeout = self._get_companion_indexing_timeout() + if self._ts_server._wait_for_indexing_start_or_completion(timeout=timeout): + log.info("Companion TypeScript server finished indexing .astro files") + else: + log.warning( + "Companion TypeScript server did not finish indexing %d .astro files within %.0fs (%s); proceeding", + len(astro_files), + timeout, + self._ts_server.describe_indexing_state(), + ) + + def _cleanup_indexed_astro_files(self) -> None: + if not self._indexed_astro_file_uris or self._ts_server is None: + return + log.debug("Cleaning up %d indexed .astro files", len(self._indexed_astro_file_uris)) + 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] + except Exception as exc: + log.debug("Error closing indexed astro file %s: %s", uri, exc) + self._indexed_astro_file_uris.clear() + + def _start_typescript_server(self) -> None: + try: + ts_config = LanguageServerConfig( + ls_id=LanguageServerId.TYPESCRIPT, + trace_lsp_communication=False, + ) + log.info("Creating companion AstroTypeScriptServer") + self._ts_server = AstroTypeScriptServer( + config=ts_config, + repository_root_path=self.repo_path, + solidlsp_settings=self._solidlsp_settings, + astro_plugin_path=self._get_astro_ts_plugin_path(), + tsdk_path=self.tsdk_path, + ts_ls_executable_path=self._get_ts_ls_executable(), + ) + log.info("Starting companion AstroTypeScriptServer") + self._ts_server.start() + self._ts_server_started = True + log.info("Companion AstroTypeScriptServer ready") + self._ensure_astro_files_indexed_on_ts_server() + except (TimeoutError, AstroCompanionPreparationError): + log.exception("Failed to prepare companion AstroTypeScriptServer; aborting Astro server startup") + self._stop_typescript_server() + raise + except Exception: + log.exception("Error starting companion AstroTypeScriptServer; TS-side operations degrade to astro LS") + self._ts_server = None + self._ts_server_started = False + + def _stop_typescript_server(self) -> None: + if self._ts_server is not None: + self._cleanup_indexed_astro_files() + try: + log.info("Stopping companion AstroTypeScriptServer") + self._ts_server.stop() + except Exception as exc: + log.warning("Error stopping companion AstroTypeScriptServer: %s", exc) + finally: + self._ts_server = None + self._ts_server_started = False + + def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None: + if self._ts_server is None or not self._ts_server_started: + return + + absolute_file_path = os.path.abspath(os.path.join(self.repo_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 + + def _create_base_initialize_params(self) -> dict: + 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": { + "typescript": { + "tsdk": self.tsdk_path, + }, + }, + } + return 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: + if "registrations" not in params: + raise SolidLSPException(f"Expected registrations in client/registerCapability params: {params}") + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + results = [] + for item in items: + section = item.get("section", "") if isinstance(item, dict) else "" + if section.endswith(".customData"): + results.append([]) + else: + results.append({}) + return results + + 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/inlayHint/refresh", do_nothing) + self.server.on_request("workspace/semanticTokens/refresh", do_nothing) + self.server.start() + + init_params = self._create_initialize_params() + init_response = self.server.send.initialize(init_params) + + if "documentSymbolProvider" not in init_response.get("capabilities", {}): + raise SolidLSPException("Astro LSP did not advertise documentSymbolProvider") + if "definitionProvider" not in init_response.get("capabilities", {}): + raise SolidLSPException("Astro 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]: + symbol_refs = super().request_references(relative_file_path, line, column) + + if _is_ts_file(relative_file_path): + 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_astro_file(relative_file_path): + if self._ts_server is not None: + try: + 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) + except Exception as exc: + log.debug("Companion TS references failed for %s: %s", relative_file_path, exc) + + 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: + 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]: + 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 request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + if _is_ts_file(relative_file_path) and self._ts_server is not None: + return self._ts_server.request_document_symbols(relative_file_path) + return super().request_document_symbols(relative_file_path, file_buffer) + + @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]: + 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_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + return super().request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + + @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 ASTRO_EXT: + return "astro" + if ext == ".tsx": + return "typescriptreact" + if ext == ".jsx": + return "javascriptreact" + if ext in TS_EXT: + return "typescript" + if ext in JS_EXT: + return "javascript" + return self.language_id + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "dist", + "build", + "coverage", + ".astro", + ] diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index ba7d4303..e265d3af 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1974,11 +1974,12 @@ class SolidLanguageServer(ABC): # no cached result: get the raw root symbols from the language server document_symbols = self._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_data) - # update cache + # update cache (only cache non-empty results to avoid permanently caching unindexed responses) content_hash = file_data.content_hash - log.debug("Updating cached document symbols for %s (hash=%s)", relative_file_path, content_hash) - self._document_symbols_cache[cache_key] = (content_hash, document_symbols) - self._document_symbols_cache_is_modified = True + if document_symbols.root_symbols: + log.debug("Updating cached document symbols for %s (hash=%s)", relative_file_path, content_hash) + self._document_symbols_cache[cache_key] = (content_hash, document_symbols) + self._document_symbols_cache_is_modified = True return document_symbols diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 49c295ea..1bfca765 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -174,6 +174,11 @@ class LanguageServerId(Enum): Supports .svelte Single File Components plus TypeScript and JavaScript files in Svelte projects. Requires Node.js v18+ and npm. """ + ASTRO = "astro" + """Astro language server using @astrojs/language-server. + Supports .astro Single File Components plus TypeScript and JavaScript + files in Astro projects. Requires Node.js v18+ and npm. + """ POWERSHELL = "powershell" PASCAL = "pascal" """Pascal Language Server (pasls) for Free Pascal and Lazarus projects. @@ -404,7 +409,7 @@ class LanguageServerId(Enum): # 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: + case self.VUE | self.SVELTE | self.ASTRO: return 1 # regular languages case _: @@ -591,6 +596,12 @@ class LanguageServerId(Enum): for base_pattern in ["ts", "js"]: path_patterns.append(f".{prefix}{base_pattern}") return FilenameMatcher(*path_patterns) + case self.ASTRO: + path_patterns = [".astro"] + 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: @@ -726,6 +737,10 @@ class LanguageServerId(Enum): from solidlsp.language_servers.svelte_language_server import SvelteLanguageServer return SvelteLanguageServer + case self.ASTRO: + from solidlsp.language_servers.astro_language_server import AstroLanguageServer + + return AstroLanguageServer case self.GO: from solidlsp.language_servers.gopls import Gopls diff --git a/test/conftest.py b/test/conftest.py index 204127b6..6d9c797e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -313,6 +313,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[LanguageServerId, list[MarkDecorator | Mark]] = { LanguageServerId.ANGULAR: [pytest.mark.angular], LanguageServerId.HTML: [pytest.mark.html], LanguageServerId.SCSS: [pytest.mark.scss], + LanguageServerId.ASTRO: [pytest.mark.astro], } diff --git a/test/resources/repos/astro/test_repo/.gitignore b/test/resources/repos/astro/test_repo/.gitignore new file mode 100644 index 00000000..e483be8e --- /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 00000000..00dfbc84 --- /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 00000000..0bd64669 --- /dev/null +++ b/test/resources/repos/astro/test_repo/package.json @@ -0,0 +1,8 @@ +{ + "name": "astro-test-repo", + "type": "module", + "version": "0.0.1", + "dependencies": { + "astro": "^5.0.0" + } +} diff --git a/test/resources/repos/astro/test_repo/src/components/Card.astro b/test/resources/repos/astro/test_repo/src/components/Card.astro new file mode 100644 index 00000000..e5a55567 --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/components/Card.astro @@ -0,0 +1,24 @@ +--- +interface Props { + title: string; + body: string; + href: string; +} + +const { href, title, body } = Astro.props; +function formatTitle(val: string): string { + return val.toUpperCase(); +} +--- + + 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 00000000..f1676005 --- /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 00000000..0b028aa1 --- /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 00000000..e9dfce60 --- /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 00000000..7dcc9e0c --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/pages/index.astro @@ -0,0 +1,18 @@ +--- +import Card from '../components/Card.astro'; +import Layout from '../layouts/Layout.astro'; +import Header from '../components/Header.astro'; +import { createCounter } from '../stores/counter'; +import { formatNumber } from '../utils/format'; +const pageTitle = "Welcome to Astro"; +const counter = createCounter(); +--- + + +
+
+

{pageTitle}

+

Count: {formatNumber(counter.count)}

+ +
+ diff --git a/test/resources/repos/astro/test_repo/src/stores/counter.ts b/test/resources/repos/astro/test_repo/src/stores/counter.ts new file mode 100644 index 00000000..59ee2427 --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/stores/counter.ts @@ -0,0 +1,21 @@ +export interface CounterStore { + count: number; + increment: () => void; + decrement: () => void; +} + +export function createCounter(): CounterStore { + let count = 0; + + return { + get count() { + return count; + }, + increment() { + count++; + }, + decrement() { + count--; + }, + }; +} diff --git a/test/resources/repos/astro/test_repo/src/utils/format.ts b/test/resources/repos/astro/test_repo/src/utils/format.ts new file mode 100644 index 00000000..f4c5d95b --- /dev/null +++ b/test/resources/repos/astro/test_repo/src/utils/format.ts @@ -0,0 +1,13 @@ +/** + * Format a number with thousands separators. + */ +export function formatNumber(value: number): string { + return value.toLocaleString(); +} + +/** + * Format a date to locale string. + */ +export function formatDate(date: Date): string { + return date.toLocaleDateString(); +} diff --git a/test/resources/repos/astro/test_repo/tsconfig.json b/test/resources/repos/astro/test_repo/tsconfig.json new file mode 100644 index 00000000..5b3da28d --- /dev/null +++ b/test/resources/repos/astro/test_repo/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "jsx": "preserve", + "strict": true + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 448206d2..e594fd8d 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -523,6 +523,13 @@ FIND_SYMBOL_REFERENCES_CASES = [ FindSymbolCase( ls_id=LanguageServerId.LATEX, id="latex_methods_section", symbol_name="Methods", expected_kind="Module", expected_file="main.tex" ).to_pytest_param(), + FindSymbolCase( + ls_id=LanguageServerId.ASTRO, + id="astro_props_interface", + symbol_name="Props", + expected_kind="Interface", + expected_file=os.path.join("src", "components", "Card.astro"), + ).to_pytest_param(), ] FIND_REFERENCE_CASES = [ @@ -793,6 +800,7 @@ def serena_config(): LanguageServerId.LEAN4, LanguageServerId.MSL, LanguageServerId.LATEX, + LanguageServerId.ASTRO, ]: repo_path = get_repo_path(language) if repo_path.exists(): diff --git a/test/solidlsp/astro/__init__.py b/test/solidlsp/astro/__init__.py new file mode 100644 index 00000000..129f9962 --- /dev/null +++ b/test/solidlsp/astro/__init__.py @@ -0,0 +1 @@ +"""Tests for Astro language server.""" diff --git a/test/solidlsp/astro/conftest.py b/test/solidlsp/astro/conftest.py new file mode 100644 index 00000000..0bd246c4 --- /dev/null +++ b/test/solidlsp/astro/conftest.py @@ -0,0 +1,69 @@ +""" +Pytest fixtures for Astro language server tests. + +This conftest does NOT install the language server itself -- that is handled by +``RuntimeDependencyCollection`` inside ``AstroLanguageServer`` and lands in Serena's +managed ``ls_resources_dir`` like every other LS download. + +What we install here is the test fixture project's npm dependencies (notably +``astro``) into the fixture's own ``node_modules``. The companion tsserver (via +``@astrojs/ts-plugin``) needs ``astro`` resolvable from the workspace to understand +``.astro`` modules; without it, cross-file references between ``.ts`` and ``.astro`` +files return nothing and the tests would pass vacuously. +""" + +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_ROOT = Path(__file__).resolve().parents[2] / "resources" / "repos" / "astro" / "test_repo" +NODE_MODULES = REPO_ROOT / "node_modules" +ASTRO_MARKER = NODE_MODULES / "astro" / "package.json" +INSTALL_LOCK = REPO_ROOT / ".astro-install.lock" + + +@pytest.fixture(scope="session", autouse=True) +def _install_astro_test_repo_node_modules() -> None: + """Populate the Astro fixture's project dependencies via npm (cached across sessions).""" + if ASTRO_MARKER.exists(): + log.info("Astro 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 Astro test repo dependencies") + + with FileLock(str(INSTALL_LOCK)): + if ASTRO_MARKER.exists(): + log.info("Astro test repo node_modules populated by another worker; skipping npm install") + return + + log.warning( + "Installing npm dependencies into the Astro test repo at %s. This is a one-time cost per checkout.", + REPO_ROOT, + ) + proc = subprocess.run( + [npm_executable, "install", "--no-audit", "--no-fund", "--loglevel=warn"], + cwd=str(REPO_ROOT), + 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_ROOT} (rc={proc.returncode}); see logs for details") + + if not ASTRO_MARKER.exists(): + pytest.skip(f"npm install completed but {ASTRO_MARKER} is missing; cannot run Astro tests") + + log.info("Astro test repo node_modules installed successfully") diff --git a/test/solidlsp/astro/test_astro_basic.py b/test/solidlsp/astro/test_astro_basic.py new file mode 100644 index 00000000..b9c01bc9 --- /dev/null +++ b/test/solidlsp/astro/test_astro_basic.py @@ -0,0 +1,118 @@ +""" +Basic integration and symbol retrieval tests for the Astro language server. +""" + +import os +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import LanguageServerId +from test.solidlsp.conftest import document_symbol_names, request_all_symbols + +pytestmark = pytest.mark.astro + + +class TestAstroLanguageServerBasics: + """Smoke, symbol retrieval, and dual-server coordination tests for Astro.""" + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.ASTRO], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_card_document_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "Card.astro") + names = document_symbol_names(language_server, file_path) + + # Frontmatter symbols + assert "Props" in names + assert "formatTitle" in names + + # Template elements + assert any(n in names for n in ("li", "a", "h2", "p")) + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_layout_document_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "layouts", "Layout.astro") + names = document_symbol_names(language_server, file_path) + + assert "Props" in names + assert "title" in names + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_index_document_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "pages", "index.astro") + names = document_symbol_names(language_server, file_path) + + assert "pageTitle" in names + assert any(n in names for n in ("main", "h1", "Card", "Layout")) + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_typescript_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Verify TypeScript files in Astro project have document symbols routed to companion TS server.""" + file_path = os.path.join("src", "stores", "counter.ts") + symbols = language_server.request_document_symbols(file_path) + assert symbols is not None, "Expected document symbols from companion TypeScript server" + all_symbols, _roots = symbols.get_all_symbols_and_roots() + symbol_names = [s["name"] for s in all_symbols] + assert "CounterStore" in symbol_names + assert "createCounter" in symbol_names + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_format_utils_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "utils", "format.ts") + symbols = language_server.request_document_symbols(file_path) + assert symbols is not None + all_symbols, _roots = symbols.get_all_symbols_and_roots() + symbol_names = [s["name"] for s in all_symbols] + assert "formatNumber" in symbol_names + assert "formatDate" in symbol_names + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_typescript_companion_server_starts(self, language_server: SolidLanguageServer) -> None: + """Verify companion TypeScript server starts and attaches to AstroLanguageServer.""" + from solidlsp.language_servers.astro_language_server import AstroLanguageServer + + astro_ls = language_server.language_server + assert isinstance(astro_ls, AstroLanguageServer), "Expected AstroLanguageServer instance" + assert astro_ls._ts_server is not None, "Expected companion TypeScript server to be initialized" + assert astro_ls._ts_server_started, "Expected companion TypeScript server to be started" + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_find_definition_within_typescript(self, language_server: SolidLanguageServer) -> None: + """Verify go-to-definition within TypeScript files in Astro project.""" + file_path = os.path.join("src", "stores", "counter.ts") + # Line 7 (0-indexed line 6), char 35: CounterStore return type annotation + definitions = language_server.request_definition(file_path, 6, 35) + assert definitions, "Expected at least one definition location" + def_loc = definitions[0] + assert def_loc["uri"].endswith("counter.ts") + assert def_loc["range"]["start"]["line"] == 0 + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_find_references_within_typescript(self, language_server: SolidLanguageServer) -> None: + """Verify reference finding within TypeScript file.""" + file_path = os.path.join("src", "stores", "counter.ts") + # CounterStore interface on line 0, char 20 + references = language_server.request_references(file_path, 0, 20) + assert references, "Expected at least one reference location" + locations = {(ref["uri"].rsplit("/", 1)[-1], ref["range"]["start"]["line"]) for ref in references} + assert ("counter.ts", 0) in locations + assert ("counter.ts", 6) in locations + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + all_symbols = request_all_symbols(language_server) + relative_paths: set[str] = set() + for s in all_symbols: + loc = s.get("location") + if isinstance(loc, dict): + rel = loc.get("relativePath") + if isinstance(rel, str): + relative_paths.add(rel.replace("\\", "/")) + assert any("Card.astro" in p for p in relative_paths) + assert any("index.astro" in p for p in relative_paths) diff --git a/test/solidlsp/astro/test_astro_symbol_retrieval.py b/test/solidlsp/astro/test_astro_symbol_retrieval.py new file mode 100644 index 00000000..81b12391 --- /dev/null +++ b/test/solidlsp/astro/test_astro_symbol_retrieval.py @@ -0,0 +1,60 @@ +""" +Cross-file symbol retrieval and reference tests for the Astro language server. +""" + +import os +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import LanguageServerId + +pytestmark = pytest.mark.astro + + +class TestAstroSymbolRetrieval: + """Symbol retrieval and cross-file code intelligence tests.""" + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.ASTRO], indirect=True) + def test_get_containing_symbol_in_typescript(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Verify symbols in .ts files are discoverable within an Astro project.""" + counter_path = os.path.join("src", "stores", "counter.ts") + symbols = language_server.request_document_symbols(counter_path) + assert symbols is not None, "Expected document symbols but got None" + all_symbols, _roots = symbols.get_all_symbols_and_roots() + symbol_names = [s["name"] for s in all_symbols] + assert "CounterStore" in symbol_names + assert "createCounter" in symbol_names + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.ASTRO], indirect=True) + def test_find_references_to_typescript_export(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Verify finding references to a TypeScript export from an .astro component. + + createCounter is defined in counter.ts (line 6) and imported + called in + src/pages/index.astro (lines 4 and 7). This exercises the dual-server cross-file path: + the companion TypeScript server with @astrojs/ts-plugin must resolve the .astro usage. + """ + counter_path = os.path.join("src", "stores", "counter.ts") + references = language_server.request_references(counter_path, 6, 20) + assert references is not None, "Expected references but got None" + locations = {(ref["uri"].rsplit("/", 1)[-1], ref["range"]["start"]["line"]) for ref in references} + # Definition in counter.ts line 6 + assert ("counter.ts", 6) in locations, f"Expected definition at counter.ts:6, got: {sorted(locations)}" + # Import in index.astro line 4 + assert ("index.astro", 4) in locations, f"Expected import at index.astro:4, got: {sorted(locations)}" + # Invocation in index.astro line 7 + assert ("index.astro", 7) in locations, f"Expected call at index.astro:7, got: {sorted(locations)}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.ASTRO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.ASTRO], indirect=True) + def test_go_to_definition_from_typescript(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Verify go-to-definition within TypeScript source in an Astro project.""" + counter_path = os.path.join("src", "stores", "counter.ts") + definitions = language_server.request_definition(counter_path, 6, 35) + assert definitions, "Expected at least one definition location" + def_loc = definitions[0] + assert def_loc["uri"].endswith("counter.ts") + assert def_loc["range"]["start"]["line"] == 0 diff --git a/test/solidlsp/nextflow/test_nextflow_basic.py b/test/solidlsp/nextflow/test_nextflow_basic.py index d60bd2cd..860b8794 100644 --- a/test/solidlsp/nextflow/test_nextflow_basic.py +++ b/test/solidlsp/nextflow/test_nextflow_basic.py @@ -24,8 +24,17 @@ class TestNextflowLanguageServer: @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) def test_document_symbols_workflows(self, language_server: SolidLanguageServer) -> None: """Workflows declared in a script are reported, with the ``workflow`` keyword stripped.""" - all_symbols, _ = language_server.request_document_symbols("main.nf").get_all_symbols_and_roots() - names = [s["name"] for s in all_symbols] + import time + + deadline = time.monotonic() + 10.0 + names: list[str] = [] + while time.monotonic() < deadline: + all_symbols, _ = language_server.request_document_symbols("main.nf").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + if "SAY_HELLO" in names: + break + time.sleep(0.5) + assert "SAY_HELLO" in names, f"SAY_HELLO not found in main.nf symbols. Found: {names}" # the implicit entry workflow has no name of its own; the language server calls it "" assert "" in names, f"entry workflow not found in main.nf symbols. Found: {names}"