Coverage for fastblocks / adapters / templates / _language_server.py: 33%
153 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
1"""FastBlocks Language Server Protocol implementation."""
3import asyncio
4import typing as t
5from contextlib import suppress
6from typing import Any
7from uuid import UUID
9from acb.config import Settings
10from acb.depends import depends
12from ._syntax_support import FastBlocksSyntaxSupport
15class LanguageServerSettings(Settings):
16 """Settings for FastBlocks Language Server."""
18 # Required ACB 0.19.0+ metadata
19 MODULE_ID: UUID = UUID("01937d87-2345-6789-abcd-123456789def")
20 MODULE_STATUS: str = "stable"
22 # Server settings
23 port: int = 7777
24 host: str = "localhost"
25 enable_tcp: bool = False
26 enable_stdio: bool = True
28 # Feature flags
29 enable_completions: bool = True
30 enable_hover: bool = True
31 enable_diagnostics: bool = True
32 enable_formatting: bool = True
33 enable_signature_help: bool = True
35 # Performance settings
36 completion_trigger_characters: list[str] = ["[", "|", ".", "("]
37 signature_trigger_characters: list[str] = ["(", ","]
38 diagnostic_delay_ms: int = 500
39 completion_timeout_ms: int = 1000
42class FastBlocksLanguageServer:
43 """Language Server Protocol implementation for FastBlocks templates."""
45 # Required ACB 0.19.0+ metadata
46 MODULE_ID: UUID = UUID("01937d87-2345-6789-abcd-123456789def")
47 MODULE_STATUS: str = "stable"
49 def __init__(self) -> None:
50 """Initialize language server."""
51 self.settings: LanguageServerSettings | None = None
52 self.syntax_support: FastBlocksSyntaxSupport | None = None
53 self._documents: dict[str, str] = {}
54 self._diagnostics: dict[str, list[dict[str, Any]]] = {}
56 # Register with ACB
57 with suppress(Exception):
58 depends.set(self)
60 # Initialize syntax support
61 self.syntax_support = FastBlocksSyntaxSupport()
63 async def initialize(self, params: dict[str, Any]) -> dict[str, Any]:
64 """Handle LSP initialize request."""
65 if not self.settings:
66 self.settings = LanguageServerSettings()
68 capabilities: dict[str, Any] = {
69 "textDocumentSync": {
70 "openClose": True,
71 "change": 1, # Full document sync
72 "save": {"includeText": True},
73 }
74 }
76 if self.settings.enable_completions:
77 capabilities["completionProvider"] = {
78 "triggerCharacters": self.settings.completion_trigger_characters,
79 "resolveProvider": True,
80 }
82 if self.settings.enable_hover:
83 capabilities["hoverProvider"] = True
85 if self.settings.enable_formatting:
86 capabilities["documentFormattingProvider"] = True
88 if self.settings.enable_signature_help:
89 capabilities["signatureHelpProvider"] = {
90 "triggerCharacters": self.settings.signature_trigger_characters
91 }
93 return {
94 "capabilities": capabilities,
95 "serverInfo": {"name": "FastBlocks Language Server", "version": "1.0.0"},
96 }
98 async def text_document_did_open(self, params: dict[str, Any]) -> None:
99 """Handle document open event."""
100 doc = params["textDocument"]
101 uri = doc["uri"]
102 content = doc["text"]
104 self._documents[uri] = content
106 # Run diagnostics
107 if self.settings and self.settings.enable_diagnostics:
108 await self._run_diagnostics(uri, content)
110 async def text_document_did_change(self, params: dict[str, Any]) -> None:
111 """Handle document change event."""
112 uri = params["textDocument"]["uri"]
113 changes = params["contentChanges"]
115 # For full document sync
116 if changes:
117 self._documents[uri] = changes[0]["text"]
119 # Delayed diagnostics
120 if self.settings and self.settings.enable_diagnostics:
121 await asyncio.sleep(self.settings.diagnostic_delay_ms / 1000)
122 await self._run_diagnostics(uri, self._documents[uri])
124 async def text_document_completion(self, params: dict[str, Any]) -> dict[str, Any]:
125 """Handle completion request."""
126 if not self.settings or not self.settings.enable_completions:
127 return {"items": []}
129 uri = params["textDocument"]["uri"]
130 position = params["position"]
131 content = self._documents.get(uri, "")
133 if not self.syntax_support:
134 return {"items": []}
136 # Get completions
137 completions = self.syntax_support.get_completions(
138 content, position["line"], position["character"]
139 )
141 items = []
142 for completion in completions:
143 item = {
144 "label": completion.label,
145 "kind": self._completion_kind_to_lsp(completion.kind),
146 "detail": completion.detail,
147 "documentation": completion.documentation,
148 "insertText": completion.insert_text or completion.label,
149 "sortText": f"{100 - completion.priority:03d}_{completion.label}",
150 }
152 # Add snippet support
153 if completion.insert_text and "$" in completion.insert_text:
154 item["insertTextFormat"] = 2 # Snippet
156 items.append(item)
158 return {"items": items}
160 async def text_document_hover(
161 self, params: dict[str, Any]
162 ) -> dict[str, Any] | None:
163 """Handle hover request."""
164 if not self.settings or not self.settings.enable_hover:
165 return None
167 uri = params["textDocument"]["uri"]
168 position = params["position"]
169 content = self._documents.get(uri, "")
171 if not self.syntax_support:
172 return None
174 hover_info = self.syntax_support.get_hover_info(
175 content, position["line"], position["character"]
176 )
178 if hover_info:
179 return {"contents": {"kind": "markdown", "value": hover_info}}
181 return None
183 async def text_document_formatting(
184 self, params: dict[str, Any]
185 ) -> list[dict[str, Any]]:
186 """Handle formatting request."""
187 if not self.settings or not self.settings.enable_formatting:
188 return []
190 uri = params["textDocument"]["uri"]
191 content = self._documents.get(uri, "")
193 if not self.syntax_support:
194 return []
196 formatted = self.syntax_support.format_template(content)
198 if formatted != content:
199 lines = content.split("\n")
200 return [
201 {
202 "range": {
203 "start": {"line": 0, "character": 0},
204 "end": {"line": len(lines), "character": 0},
205 },
206 "newText": formatted,
207 }
208 ]
210 return []
212 async def _run_diagnostics(self, uri: str, content: str) -> None:
213 """Run diagnostics on document."""
214 if not self.syntax_support:
215 return
217 errors = self.syntax_support.check_syntax(content)
218 diagnostics = []
220 for error in errors:
221 diagnostic = {
222 "range": {
223 "start": {"line": error.line, "character": error.column},
224 "end": {"line": error.line, "character": error.column + 10},
225 },
226 "severity": self._severity_to_lsp(error.severity),
227 "code": error.code,
228 "source": "FastBlocks",
229 "message": error.message,
230 }
232 if error.fix_suggestion:
233 t.cast(dict[str, t.Any], diagnostic)["data"] = {
234 "fix": error.fix_suggestion
235 }
237 diagnostics.append(diagnostic)
239 self._diagnostics[uri] = diagnostics
241 # In a real LSP implementation, you would send this to the client
242 # self.send_notification("textDocument/publishDiagnostics", {
243 # "uri": uri,
244 # "diagnostics": diagnostics
245 # })
247 def _completion_kind_to_lsp(self, kind: str) -> int:
248 """Convert completion kind to LSP constants."""
249 mapping = {
250 "function": 3, # Function
251 "variable": 6, # Variable
252 "filter": 12, # Value
253 "block": 14, # Keyword
254 "component": 9, # Module
255 "snippet": 15, # Snippet
256 }
257 return mapping.get(kind, 1) # Text
259 def _severity_to_lsp(self, severity: str) -> int:
260 """Convert severity to LSP constants."""
261 mapping = {"error": 1, "warning": 2, "info": 3, "hint": 4}
262 return mapping.get(severity, 1)
264 def get_current_diagnostics(self, uri: str) -> list[dict[str, Any]]:
265 """Get current diagnostics for a document."""
266 return self._diagnostics.get(uri, [])
268 async def shutdown(self) -> None:
269 """Handle server shutdown."""
270 self._documents.clear()
271 self._diagnostics.clear()
274class FastBlocksLanguageClient:
275 """Simple language client for testing and integration."""
277 def __init__(self) -> None:
278 """Initialize language client."""
279 self.server = FastBlocksLanguageServer()
280 self._initialized = False
282 async def initialize(self) -> None:
283 """Initialize the language server."""
284 if self._initialized:
285 return
287 await self.server.initialize(
288 {
289 "processId": None,
290 "clientInfo": {"name": "FastBlocks Client", "version": "1.0.0"},
291 "capabilities": {},
292 }
293 )
294 self._initialized = True
296 async def open_document(self, uri: str, content: str) -> None:
297 """Open a document."""
298 await self.initialize()
299 await self.server.text_document_did_open(
300 {
301 "textDocument": {
302 "uri": uri,
303 "languageId": "fastblocks",
304 "version": 1,
305 "text": content,
306 }
307 }
308 )
310 async def change_document(self, uri: str, content: str) -> None:
311 """Change document content."""
312 await self.server.text_document_did_change(
313 {
314 "textDocument": {"uri": uri, "version": 2},
315 "contentChanges": [{"text": content}],
316 }
317 )
319 async def get_completions(
320 self, uri: str, line: int, character: int
321 ) -> list[dict[str, Any]]:
322 """Get completions at position."""
323 result = await self.server.text_document_completion(
324 {
325 "textDocument": {"uri": uri},
326 "position": {"line": line, "character": character},
327 }
328 )
329 return t.cast(list[dict[str, Any]], result.get("items", []))
331 async def get_hover(
332 self, uri: str, line: int, character: int
333 ) -> dict[str, Any] | None:
334 """Get hover information."""
335 return await self.server.text_document_hover(
336 {
337 "textDocument": {"uri": uri},
338 "position": {"line": line, "character": character},
339 }
340 )
342 async def format_document(self, uri: str) -> list[dict[str, Any]]:
343 """Format document."""
344 return await self.server.text_document_formatting(
345 {"textDocument": {"uri": uri}}
346 )
348 def get_diagnostics(self, uri: str) -> list[dict[str, Any]]:
349 """Get current diagnostics."""
350 return self.server.get_current_diagnostics(uri)
353# VS Code extension configuration generator
354def generate_vscode_extension() -> dict[str, Any]:
355 """Generate VS Code extension configuration for FastBlocks."""
356 return {
357 "name": "fastblocks-language-support",
358 "displayName": "FastBlocks Language Support",
359 "description": "Language support for FastBlocks templates",
360 "version": "1.0.0",
361 "publisher": "fastblocks",
362 "engines": {"vscode": "^1.74.0"},
363 "categories": ["Programming Languages"],
364 "activationEvents": ["onLanguage:fastblocks"],
365 "main": "./out/extension.js",
366 "contributes": {
367 "languages": [
368 {
369 "id": "fastblocks",
370 "aliases": ["FastBlocks", "fastblocks"],
371 "extensions": [".fb.html", ".fastblocks"],
372 "configuration": "./language-configuration.json",
373 }
374 ],
375 "grammars": [
376 {
377 "language": "fastblocks",
378 "scopeName": "text.html.fastblocks",
379 "path": "./syntaxes/fastblocks.tmLanguage.json",
380 }
381 ],
382 "configuration": {
383 "type": "object",
384 "title": "FastBlocks",
385 "properties": {
386 "fastblocks.languageServer.enabled": {
387 "type": "boolean",
388 "default": True,
389 "description": "Enable FastBlocks language server",
390 },
391 "fastblocks.languageServer.port": {
392 "type": "number",
393 "default": 7777,
394 "description": "Language server port",
395 },
396 "fastblocks.completion.enabled": {
397 "type": "boolean",
398 "default": True,
399 "description": "Enable auto-completion",
400 },
401 "fastblocks.diagnostics.enabled": {
402 "type": "boolean",
403 "default": True,
404 "description": "Enable error checking",
405 },
406 },
407 },
408 },
409 "scripts": {
410 "vscode:prepublish": "npm run compile",
411 "compile": "tsc -p ./",
412 "watch": "tsc -watch -p ./",
413 },
414 "devDependencies": {
415 "@types/vscode": "^1.74.0",
416 "@typescript-eslint/eslint-plugin": "^5.45.0",
417 "@typescript-eslint/parser": "^5.45.0",
418 "eslint": "^8.28.0",
419 "typescript": "^4.9.4",
420 },
421 "dependencies": {"vscode-languageclient": "^8.1.0"},
422 }
425# TextMate grammar for syntax highlighting
426def generate_textmate_grammar() -> dict[str, Any]:
427 """Generate TextMate grammar for FastBlocks syntax highlighting."""
428 return {
429 "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
430 "name": "FastBlocks",
431 "scopeName": "text.html.fastblocks",
432 "patterns": [
433 {"include": "#fastblocks-variable"},
434 {"include": "#fastblocks-block"},
435 {"include": "#fastblocks-comment"},
436 {"include": "text.html.basic"},
437 ],
438 "repository": {
439 "fastblocks-variable": {
440 "name": "meta.tag.template.value.fastblocks",
441 "begin": r"\[\[",
442 "end": r"\]\]",
443 "beginCaptures": {
444 "0": {"name": "punctuation.definition.tag.begin.fastblocks"}
445 },
446 "endCaptures": {
447 "0": {"name": "punctuation.definition.tag.end.fastblocks"}
448 },
449 "patterns": [
450 {"include": "#fastblocks-filter"},
451 {"include": "#fastblocks-string"},
452 {"include": "#fastblocks-identifier"},
453 ],
454 },
455 "fastblocks-block": {
456 "name": "meta.tag.template.block.fastblocks",
457 "begin": r"\[%",
458 "end": r"%\]",
459 "beginCaptures": {
460 "0": {"name": "punctuation.definition.tag.begin.fastblocks"}
461 },
462 "endCaptures": {
463 "0": {"name": "punctuation.definition.tag.end.fastblocks"}
464 },
465 "patterns": [
466 {
467 "name": "keyword.control.fastblocks",
468 "match": r"\b(if|else|elif|endif|for|endfor|block|endblock|extends|include|set|macro|endmacro)\b",
469 },
470 {"include": "#fastblocks-string"},
471 {"include": "#fastblocks-identifier"},
472 ],
473 },
474 "fastblocks-comment": {
475 "name": "comment.block.fastblocks",
476 "begin": r"\[#",
477 "end": r"#\]",
478 "beginCaptures": {
479 "0": {"name": "punctuation.definition.comment.begin.fastblocks"}
480 },
481 "endCaptures": {
482 "0": {"name": "punctuation.definition.comment.end.fastblocks"}
483 },
484 },
485 "fastblocks-filter": {
486 "name": "support.function.filter.fastblocks",
487 "match": r"\|\s*(\w+)",
488 "captures": {"1": {"name": "entity.name.function.filter.fastblocks"}},
489 },
490 "fastblocks-string": {
491 "name": "string.quoted.double.fastblocks",
492 "begin": r'"',
493 "end": r'"',
494 "patterns": [
495 {"name": "constant.character.escape.fastblocks", "match": r"\\."}
496 ],
497 },
498 "fastblocks-identifier": {
499 "name": "variable.other.fastblocks",
500 "match": r"\b[a-zA-Z_][a-zA-Z0-9_]*\b",
501 },
502 },
503 }
506# ACB 0.19.0+ compatibility
507__all__ = [
508 "FastBlocksLanguageServer",
509 "FastBlocksLanguageClient",
510 "LanguageServerSettings",
511 "generate_vscode_extension",
512 "generate_textmate_grammar",
513]