OSS restructuring for open-core model: - Rename package from agentserver/ to xml_pipeline/ - Update all imports (44 Python files, 31 docs/configs) - Update pyproject.toml for OSS distribution (v0.3.0) - Move prompt_toolkit from core to optional [console] extra - Remove auth/server/lsp from core optional deps (-> Nextra) New console example in examples/console/: - Self-contained demo with handlers and config - Uses prompt_toolkit (optional, falls back to input()) - No password auth, no TUI, no LSP — just the basics - Shows how to use xml-pipeline as a library Import changes: - from agentserver.* -> from xml_pipeline.* - CLI entry points updated: xml_pipeline.cli:main Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
527 lines
16 KiB
Python
527 lines
16 KiB
Python
"""
|
|
AssemblyScript Language Server Protocol client.
|
|
|
|
Wraps communication with asls (AssemblyScript Language Server) for:
|
|
- Autocompletion for AgentServer SDK types
|
|
- Type checking and diagnostics
|
|
- Hover documentation
|
|
|
|
Install: npm install -g assemblyscript-lsp
|
|
|
|
Used for editing WASM listener source files written in AssemblyScript.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shutil
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional, Any
|
|
|
|
from .client import (
|
|
LSPCompletion,
|
|
LSPDiagnostic,
|
|
LSPHover,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _check_asls() -> bool:
|
|
"""Check if asls (AssemblyScript Language Server) is installed."""
|
|
return shutil.which("asls") is not None
|
|
|
|
|
|
def is_asls_available() -> tuple[bool, str]:
|
|
"""
|
|
Check if AssemblyScript LSP support is available.
|
|
|
|
Returns (available, reason) tuple.
|
|
"""
|
|
if not _check_asls():
|
|
return False, "asls not found (npm install -g assemblyscript-lsp)"
|
|
|
|
return True, "AssemblyScript LSP available"
|
|
|
|
|
|
# File extensions handled by ASLS
|
|
ASSEMBLYSCRIPT_EXTENSIONS = {".ts", ".as"}
|
|
|
|
|
|
def is_assemblyscript_file(path: str | Path) -> bool:
|
|
"""Check if a file should use the AssemblyScript LSP."""
|
|
return Path(path).suffix.lower() in ASSEMBLYSCRIPT_EXTENSIONS
|
|
|
|
|
|
@dataclass
|
|
class ASLSConfig:
|
|
"""
|
|
Configuration for the AssemblyScript Language Server.
|
|
|
|
These settings are passed during initialization.
|
|
"""
|
|
|
|
# Path to asconfig.json (AssemblyScript project config)
|
|
asconfig_path: Optional[str] = None
|
|
|
|
# Path to AgentServer SDK type definitions
|
|
sdk_types_path: Optional[str] = None
|
|
|
|
# Enable strict null checks
|
|
strict_null_checks: bool = True
|
|
|
|
# Enable additional diagnostics
|
|
verbose_diagnostics: bool = False
|
|
|
|
|
|
class ASLSClient:
|
|
"""
|
|
Client for communicating with the AssemblyScript Language Server.
|
|
|
|
Uses stdio for communication with the language server process.
|
|
|
|
Usage:
|
|
client = ASLSClient()
|
|
if await client.start():
|
|
await client.did_open(uri, content)
|
|
completions = await client.completion(uri, line, col)
|
|
await client.stop()
|
|
"""
|
|
|
|
def __init__(self, config: Optional[ASLSConfig] = None):
|
|
"""
|
|
Initialize the ASLS client.
|
|
|
|
Args:
|
|
config: Optional ASLS configuration
|
|
"""
|
|
self.config = config or ASLSConfig()
|
|
self._process: Optional[asyncio.subprocess.Process] = None
|
|
self._reader_task: Optional[asyncio.Task] = None
|
|
self._request_id = 0
|
|
self._pending_requests: dict[int, asyncio.Future] = {}
|
|
self._diagnostics: dict[str, list[LSPDiagnostic]] = {}
|
|
self._initialized = False
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def start(self) -> bool:
|
|
"""
|
|
Start the AssemblyScript language server.
|
|
|
|
Returns True if started successfully.
|
|
"""
|
|
available, reason = is_asls_available()
|
|
if not available:
|
|
logger.warning(f"ASLS not available: {reason}")
|
|
return False
|
|
|
|
try:
|
|
self._process = await asyncio.create_subprocess_exec(
|
|
"asls", "--stdio",
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
# Start reader task
|
|
self._reader_task = asyncio.create_task(self._read_messages())
|
|
|
|
# Initialize LSP
|
|
await self._initialize()
|
|
self._initialized = True
|
|
logger.info("AssemblyScript language server started")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to start asls: {e}")
|
|
await self.stop()
|
|
return False
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the language server."""
|
|
self._initialized = False
|
|
|
|
if self._reader_task:
|
|
self._reader_task.cancel()
|
|
try:
|
|
await self._reader_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._reader_task = None
|
|
|
|
if self._process:
|
|
self._process.terminate()
|
|
try:
|
|
await asyncio.wait_for(self._process.wait(), timeout=2)
|
|
except asyncio.TimeoutError:
|
|
self._process.kill()
|
|
self._process = None
|
|
|
|
# Cancel pending requests
|
|
for future in self._pending_requests.values():
|
|
if not future.done():
|
|
future.cancel()
|
|
self._pending_requests.clear()
|
|
|
|
async def _initialize(self) -> None:
|
|
"""Send LSP initialize request."""
|
|
init_options: dict[str, Any] = {}
|
|
|
|
if self.config.asconfig_path:
|
|
init_options["asconfigPath"] = self.config.asconfig_path
|
|
|
|
if self.config.sdk_types_path:
|
|
init_options["sdkTypesPath"] = self.config.sdk_types_path
|
|
|
|
result = await self._request(
|
|
"initialize",
|
|
{
|
|
"processId": None,
|
|
"rootUri": None,
|
|
"capabilities": {
|
|
"textDocument": {
|
|
"completion": {
|
|
"completionItem": {
|
|
"snippetSupport": True,
|
|
"documentationFormat": ["markdown", "plaintext"],
|
|
}
|
|
},
|
|
"hover": {
|
|
"contentFormat": ["markdown", "plaintext"],
|
|
},
|
|
"publishDiagnostics": {
|
|
"relatedInformation": True,
|
|
},
|
|
"signatureHelp": {
|
|
"signatureInformation": {
|
|
"documentationFormat": ["markdown", "plaintext"],
|
|
}
|
|
},
|
|
}
|
|
},
|
|
"initializationOptions": init_options,
|
|
},
|
|
)
|
|
logger.debug(f"ASLS initialized: {result}")
|
|
|
|
# Send initialized notification
|
|
await self._notify("initialized", {})
|
|
|
|
async def did_open(self, uri: str, content: str) -> None:
|
|
"""Notify server that a document was opened."""
|
|
if not self._initialized:
|
|
return
|
|
|
|
# Determine language ID based on extension
|
|
language_id = "assemblyscript"
|
|
if uri.endswith(".ts"):
|
|
language_id = "typescript" # ASLS may prefer this
|
|
|
|
await self._notify(
|
|
"textDocument/didOpen",
|
|
{
|
|
"textDocument": {
|
|
"uri": uri,
|
|
"languageId": language_id,
|
|
"version": 1,
|
|
"text": content,
|
|
}
|
|
},
|
|
)
|
|
|
|
async def did_change(
|
|
self, uri: str, content: str, version: int = 1
|
|
) -> list[LSPDiagnostic]:
|
|
"""
|
|
Notify server of document change.
|
|
|
|
Returns current diagnostics for the document.
|
|
"""
|
|
if not self._initialized:
|
|
return []
|
|
|
|
await self._notify(
|
|
"textDocument/didChange",
|
|
{
|
|
"textDocument": {"uri": uri, "version": version},
|
|
"contentChanges": [{"text": content}],
|
|
},
|
|
)
|
|
|
|
# Wait briefly for diagnostics
|
|
await asyncio.sleep(0.2) # ASLS may need more time than YAML
|
|
|
|
return self._diagnostics.get(uri, [])
|
|
|
|
async def did_close(self, uri: str) -> None:
|
|
"""Notify server that a document was closed."""
|
|
if not self._initialized:
|
|
return
|
|
|
|
await self._notify(
|
|
"textDocument/didClose",
|
|
{"textDocument": {"uri": uri}},
|
|
)
|
|
|
|
# Clear diagnostics
|
|
self._diagnostics.pop(uri, None)
|
|
|
|
async def completion(
|
|
self, uri: str, line: int, column: int
|
|
) -> list[LSPCompletion]:
|
|
"""
|
|
Request completions at a position.
|
|
|
|
Args:
|
|
uri: Document URI
|
|
line: 0-indexed line number
|
|
column: 0-indexed column number
|
|
|
|
Returns list of completion items.
|
|
"""
|
|
if not self._initialized:
|
|
return []
|
|
|
|
try:
|
|
result = await self._request(
|
|
"textDocument/completion",
|
|
{
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": line, "character": column},
|
|
},
|
|
)
|
|
|
|
if result is None:
|
|
return []
|
|
|
|
items = result.get("items", []) if isinstance(result, dict) else result
|
|
return [LSPCompletion.from_lsp(item) for item in items]
|
|
|
|
except Exception as e:
|
|
logger.debug(f"ASLS completion request failed: {e}")
|
|
return []
|
|
|
|
async def hover(self, uri: str, line: int, column: int) -> Optional[LSPHover]:
|
|
"""
|
|
Request hover information at a position.
|
|
|
|
Args:
|
|
uri: Document URI
|
|
line: 0-indexed line number
|
|
column: 0-indexed column number
|
|
"""
|
|
if not self._initialized:
|
|
return None
|
|
|
|
try:
|
|
result = await self._request(
|
|
"textDocument/hover",
|
|
{
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": line, "character": column},
|
|
},
|
|
)
|
|
|
|
return LSPHover.from_lsp(result) if result else None
|
|
|
|
except Exception as e:
|
|
logger.debug(f"ASLS hover request failed: {e}")
|
|
return None
|
|
|
|
async def signature_help(
|
|
self, uri: str, line: int, column: int
|
|
) -> Optional[dict[str, Any]]:
|
|
"""
|
|
Request signature help at a position.
|
|
|
|
Useful when typing function arguments.
|
|
"""
|
|
if not self._initialized:
|
|
return None
|
|
|
|
try:
|
|
result = await self._request(
|
|
"textDocument/signatureHelp",
|
|
{
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": line, "character": column},
|
|
},
|
|
)
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.debug(f"ASLS signature help request failed: {e}")
|
|
return None
|
|
|
|
async def go_to_definition(
|
|
self, uri: str, line: int, column: int
|
|
) -> Optional[list[dict[str, Any]]]:
|
|
"""
|
|
Request go-to-definition at a position.
|
|
|
|
Returns list of location objects.
|
|
"""
|
|
if not self._initialized:
|
|
return None
|
|
|
|
try:
|
|
result = await self._request(
|
|
"textDocument/definition",
|
|
{
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": line, "character": column},
|
|
},
|
|
)
|
|
|
|
if result is None:
|
|
return None
|
|
|
|
# Normalize to list
|
|
if isinstance(result, dict):
|
|
return [result]
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.debug(f"ASLS go-to-definition failed: {e}")
|
|
return None
|
|
|
|
def get_diagnostics(self, uri: str) -> list[LSPDiagnostic]:
|
|
"""Get current diagnostics for a document."""
|
|
return self._diagnostics.get(uri, [])
|
|
|
|
# -------------------------------------------------------------------------
|
|
# LSP Protocol Implementation (shared pattern with YAMLLSPClient)
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def _request(self, method: str, params: dict[str, Any]) -> Any:
|
|
"""Send a request and wait for response."""
|
|
async with self._lock:
|
|
self._request_id += 1
|
|
req_id = self._request_id
|
|
|
|
message = {
|
|
"jsonrpc": "2.0",
|
|
"id": req_id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
|
|
future: asyncio.Future = asyncio.Future()
|
|
self._pending_requests[req_id] = future
|
|
|
|
try:
|
|
await self._send_message(message)
|
|
return await asyncio.wait_for(future, timeout=10.0) # Longer timeout for ASLS
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"ASLS request timed out: {method}")
|
|
return None
|
|
finally:
|
|
self._pending_requests.pop(req_id, None)
|
|
|
|
async def _notify(self, method: str, params: dict[str, Any]) -> None:
|
|
"""Send a notification (no response expected)."""
|
|
message = {
|
|
"jsonrpc": "2.0",
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
await self._send_message(message)
|
|
|
|
async def _send_message(self, message: dict[str, Any]) -> None:
|
|
"""Send a JSON-RPC message to the server."""
|
|
if not self._process or not self._process.stdin:
|
|
return
|
|
|
|
import json
|
|
content = json.dumps(message)
|
|
header = f"Content-Length: {len(content)}\r\n\r\n"
|
|
|
|
try:
|
|
self._process.stdin.write(header.encode())
|
|
self._process.stdin.write(content.encode())
|
|
await self._process.stdin.drain()
|
|
except (BrokenPipeError, OSError, ConnectionResetError) as e:
|
|
logger.error(f"Failed to send ASLS message: {e}")
|
|
|
|
async def _read_messages(self) -> None:
|
|
"""Read messages from the server."""
|
|
if not self._process or not self._process.stdout:
|
|
return
|
|
|
|
import json
|
|
|
|
try:
|
|
while True:
|
|
# Read header
|
|
header = b""
|
|
while b"\r\n\r\n" not in header:
|
|
chunk = await self._process.stdout.read(1)
|
|
if not chunk:
|
|
return # EOF
|
|
header += chunk
|
|
|
|
# Parse content length
|
|
content_length = 0
|
|
for line in header.decode().split("\r\n"):
|
|
if line.startswith("Content-Length:"):
|
|
content_length = int(line.split(":")[1].strip())
|
|
break
|
|
|
|
if content_length == 0:
|
|
continue
|
|
|
|
# Read content
|
|
content = await self._process.stdout.read(content_length)
|
|
|
|
if not content:
|
|
return
|
|
|
|
# Parse and handle message
|
|
try:
|
|
message = json.loads(content.decode())
|
|
await self._handle_message(message)
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Failed to parse ASLS message: {e}")
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"ASLS reader error: {e}")
|
|
|
|
async def _handle_message(self, message: dict[str, Any]) -> None:
|
|
"""Handle an incoming LSP message."""
|
|
if "id" in message and "result" in message:
|
|
# Response to a request
|
|
req_id = message["id"]
|
|
if req_id in self._pending_requests:
|
|
future = self._pending_requests[req_id]
|
|
if not future.done():
|
|
future.set_result(message.get("result"))
|
|
|
|
elif "id" in message and "error" in message:
|
|
# Error response
|
|
req_id = message["id"]
|
|
if req_id in self._pending_requests:
|
|
future = self._pending_requests[req_id]
|
|
if not future.done():
|
|
error = message["error"]
|
|
future.set_exception(
|
|
Exception(f"ASLS error: {error.get('message', error)}")
|
|
)
|
|
|
|
elif message.get("method") == "textDocument/publishDiagnostics":
|
|
# Diagnostics notification
|
|
params = message.get("params", {})
|
|
uri = params.get("uri", "")
|
|
diagnostics = [
|
|
LSPDiagnostic.from_lsp(d)
|
|
for d in params.get("diagnostics", [])
|
|
]
|
|
self._diagnostics[uri] = diagnostics
|
|
logger.debug(f"ASLS: {len(diagnostics)} diagnostics for {uri}")
|
|
|
|
elif "method" in message:
|
|
# Other notification
|
|
logger.debug(f"ASLS notification: {message.get('method')}")
|