xml-pipeline/xml_pipeline/console/lsp/asls_client.py
dullfig e653d63bc1 Rename agentserver to xml_pipeline, add console example
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>
2026-01-19 21:41:19 -08:00

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')}")