mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-11 03:13:51 +00:00
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
This commit is contained in:
1 parent
d764406ac6
commit
15502cee56
28 files changed
+1123
-23
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -297,6 +297,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
|
||||
_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
||||
(
|
||||
".al",
|
||||
".astro",
|
||||
".bash",
|
||||
".c",
|
||||
".clj",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
+5
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
package-lock.json
|
||||
.astro-install.lock
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
// Minimal config for testing
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "astro-test-repo",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"astro": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
---
|
||||
|
||||
<li class="link-card">
|
||||
<a href={href}>
|
||||
<h2>
|
||||
{formatTitle(title)}
|
||||
<span>→</span>
|
||||
</h2>
|
||||
<p>
|
||||
{body}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer>
|
||||
<p>© {year} Test Site</p>
|
||||
</footer>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
interface Props {
|
||||
siteName: string;
|
||||
}
|
||||
|
||||
const { siteName } = Astro.props;
|
||||
---
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">{siteName}</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
---
|
||||
|
||||
<Layout title={pageTitle}>
|
||||
<Header siteName="Test Site" />
|
||||
<main>
|
||||
<h1>{pageTitle}</h1>
|
||||
<p>Count: {formatNumber(counter.count)}</p>
|
||||
<Card href="https://astro.build" title="Docs" body="Documentation" />
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -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--;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "preserve",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for Astro language server."""
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 "<entry>"
|
||||
assert "<entry>" in names, f"entry workflow not found in main.nf symbols. Found: {names}"
|
||||
|
||||
Reference in new issue
Block a user