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