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:
Aleksei Tcelishchev authored and GitHub committed 2026-09-23 15:38:19 +02:00
1 parent d764406ac6
commit 15502cee56
28 files changed
+1123 -23

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Tests for Astro language server."""
+69
View File
@@ -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")
+118
View File
@@ -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
+11 -2
View File
@@ -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}"