Coverage for fastblocks/adapters/templates/_language_server.py: 33%
153 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
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): # type: ignore[misc]
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 diagnostic["data"] = {"fix": error.fix_suggestion} # type: ignore[typeddict-item]
235 diagnostics.append(diagnostic)
237 self._diagnostics[uri] = diagnostics
239 # In a real LSP implementation, you would send this to the client
240 # self.send_notification("textDocument/publishDiagnostics", {
241 # "uri": uri,
242 # "diagnostics": diagnostics
243 # })
245 def _completion_kind_to_lsp(self, kind: str) -> int:
246 """Convert completion kind to LSP constants."""
247 mapping = {
248 "function": 3, # Function
249 "variable": 6, # Variable
250 "filter": 12, # Value
251 "block": 14, # Keyword
252 "component": 9, # Module
253 "snippet": 15, # Snippet
254 }
255 return mapping.get(kind, 1) # Text
257 def _severity_to_lsp(self, severity: str) -> int:
258 """Convert severity to LSP constants."""
259 mapping = {"error": 1, "warning": 2, "info": 3, "hint": 4}
260 return mapping.get(severity, 1)
262 def get_current_diagnostics(self, uri: str) -> list[dict[str, Any]]:
263 """Get current diagnostics for a document."""
264 return self._diagnostics.get(uri, [])
266 async def shutdown(self) -> None:
267 """Handle server shutdown."""
268 self._documents.clear()
269 self._diagnostics.clear()
272class FastBlocksLanguageClient:
273 """Simple language client for testing and integration."""
275 def __init__(self) -> None:
276 """Initialize language client."""
277 self.server = FastBlocksLanguageServer()
278 self._initialized = False
280 async def initialize(self) -> None:
281 """Initialize the language server."""
282 if self._initialized:
283 return
285 await self.server.initialize(
286 {
287 "processId": None,
288 "clientInfo": {"name": "FastBlocks Client", "version": "1.0.0"},
289 "capabilities": {},
290 }
291 )
292 self._initialized = True
294 async def open_document(self, uri: str, content: str) -> None:
295 """Open a document."""
296 await self.initialize()
297 await self.server.text_document_did_open(
298 {
299 "textDocument": {
300 "uri": uri,
301 "languageId": "fastblocks",
302 "version": 1,
303 "text": content,
304 }
305 }
306 )
308 async def change_document(self, uri: str, content: str) -> None:
309 """Change document content."""
310 await self.server.text_document_did_change(
311 {
312 "textDocument": {"uri": uri, "version": 2},
313 "contentChanges": [{"text": content}],
314 }
315 )
317 async def get_completions(
318 self, uri: str, line: int, character: int
319 ) -> list[dict[str, Any]]:
320 """Get completions at position."""
321 result = await self.server.text_document_completion(
322 {
323 "textDocument": {"uri": uri},
324 "position": {"line": line, "character": character},
325 }
326 )
327 return t.cast(list[dict[str, Any]], result.get("items", []))
329 async def get_hover(
330 self, uri: str, line: int, character: int
331 ) -> dict[str, Any] | None:
332 """Get hover information."""
333 return await self.server.text_document_hover(
334 {
335 "textDocument": {"uri": uri},
336 "position": {"line": line, "character": character},
337 }
338 )
340 async def format_document(self, uri: str) -> list[dict[str, Any]]:
341 """Format document."""
342 return await self.server.text_document_formatting(
343 {"textDocument": {"uri": uri}}
344 )
346 def get_diagnostics(self, uri: str) -> list[dict[str, Any]]:
347 """Get current diagnostics."""
348 return self.server.get_current_diagnostics(uri)
351# VS Code extension configuration generator
352def generate_vscode_extension() -> dict[str, Any]:
353 """Generate VS Code extension configuration for FastBlocks."""
354 return {
355 "name": "fastblocks-language-support",
356 "displayName": "FastBlocks Language Support",
357 "description": "Language support for FastBlocks templates",
358 "version": "1.0.0",
359 "publisher": "fastblocks",
360 "engines": {"vscode": "^1.74.0"},
361 "categories": ["Programming Languages"],
362 "activationEvents": ["onLanguage:fastblocks"],
363 "main": "./out/extension.js",
364 "contributes": {
365 "languages": [
366 {
367 "id": "fastblocks",
368 "aliases": ["FastBlocks", "fastblocks"],
369 "extensions": [".fb.html", ".fastblocks"],
370 "configuration": "./language-configuration.json",
371 }
372 ],
373 "grammars": [
374 {
375 "language": "fastblocks",
376 "scopeName": "text.html.fastblocks",
377 "path": "./syntaxes/fastblocks.tmLanguage.json",
378 }
379 ],
380 "configuration": {
381 "type": "object",
382 "title": "FastBlocks",
383 "properties": {
384 "fastblocks.languageServer.enabled": {
385 "type": "boolean",
386 "default": True,
387 "description": "Enable FastBlocks language server",
388 },
389 "fastblocks.languageServer.port": {
390 "type": "number",
391 "default": 7777,
392 "description": "Language server port",
393 },
394 "fastblocks.completion.enabled": {
395 "type": "boolean",
396 "default": True,
397 "description": "Enable auto-completion",
398 },
399 "fastblocks.diagnostics.enabled": {
400 "type": "boolean",
401 "default": True,
402 "description": "Enable error checking",
403 },
404 },
405 },
406 },
407 "scripts": {
408 "vscode:prepublish": "npm run compile",
409 "compile": "tsc -p ./",
410 "watch": "tsc -watch -p ./",
411 },
412 "devDependencies": {
413 "@types/vscode": "^1.74.0",
414 "@typescript-eslint/eslint-plugin": "^5.45.0",
415 "@typescript-eslint/parser": "^5.45.0",
416 "eslint": "^8.28.0",
417 "typescript": "^4.9.4",
418 },
419 "dependencies": {"vscode-languageclient": "^8.1.0"},
420 }
423# TextMate grammar for syntax highlighting
424def generate_textmate_grammar() -> dict[str, Any]:
425 """Generate TextMate grammar for FastBlocks syntax highlighting."""
426 return {
427 "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
428 "name": "FastBlocks",
429 "scopeName": "text.html.fastblocks",
430 "patterns": [
431 {"include": "#fastblocks-variable"},
432 {"include": "#fastblocks-block"},
433 {"include": "#fastblocks-comment"},
434 {"include": "text.html.basic"},
435 ],
436 "repository": {
437 "fastblocks-variable": {
438 "name": "meta.tag.template.value.fastblocks",
439 "begin": r"\[\[",
440 "end": r"\]\]",
441 "beginCaptures": {
442 "0": {"name": "punctuation.definition.tag.begin.fastblocks"}
443 },
444 "endCaptures": {
445 "0": {"name": "punctuation.definition.tag.end.fastblocks"}
446 },
447 "patterns": [
448 {"include": "#fastblocks-filter"},
449 {"include": "#fastblocks-string"},
450 {"include": "#fastblocks-identifier"},
451 ],
452 },
453 "fastblocks-block": {
454 "name": "meta.tag.template.block.fastblocks",
455 "begin": r"\[%",
456 "end": r"%\]",
457 "beginCaptures": {
458 "0": {"name": "punctuation.definition.tag.begin.fastblocks"}
459 },
460 "endCaptures": {
461 "0": {"name": "punctuation.definition.tag.end.fastblocks"}
462 },
463 "patterns": [
464 {
465 "name": "keyword.control.fastblocks",
466 "match": r"\b(if|else|elif|endif|for|endfor|block|endblock|extends|include|set|macro|endmacro)\b",
467 },
468 {"include": "#fastblocks-string"},
469 {"include": "#fastblocks-identifier"},
470 ],
471 },
472 "fastblocks-comment": {
473 "name": "comment.block.fastblocks",
474 "begin": r"\[#",
475 "end": r"#\]",
476 "beginCaptures": {
477 "0": {"name": "punctuation.definition.comment.begin.fastblocks"}
478 },
479 "endCaptures": {
480 "0": {"name": "punctuation.definition.comment.end.fastblocks"}
481 },
482 },
483 "fastblocks-filter": {
484 "name": "support.function.filter.fastblocks",
485 "match": r"\|\s*(\w+)",
486 "captures": {"1": {"name": "entity.name.function.filter.fastblocks"}},
487 },
488 "fastblocks-string": {
489 "name": "string.quoted.double.fastblocks",
490 "begin": r'"',
491 "end": r'"',
492 "patterns": [
493 {"name": "constant.character.escape.fastblocks", "match": r"\\."}
494 ],
495 },
496 "fastblocks-identifier": {
497 "name": "variable.other.fastblocks",
498 "match": r"\b[a-zA-Z_][a-zA-Z0-9_]*\b",
499 },
500 },
501 }
504# ACB 0.19.0+ compatibility
505__all__ = [
506 "FastBlocksLanguageServer",
507 "FastBlocksLanguageClient",
508 "LanguageServerSettings",
509 "generate_vscode_extension",
510 "generate_textmate_grammar",
511]