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
@@ -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>&rarr;</span>
</h2>
<p>
{body}
</p>
</a>
</li>
@@ -0,0 +1,7 @@
---
const year = new Date().getFullYear();
---
<footer>
<p>&copy; {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"]
}
+8
View File
@@ -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():
+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}"