mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 18:16:10 +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
@@ -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