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

1"""FastBlocks Language Server Protocol implementation.""" 

2 

3import asyncio 

4import typing as t 

5from contextlib import suppress 

6from typing import Any 

7from uuid import UUID 

8 

9from acb.config import Settings 

10from acb.depends import depends 

11 

12from ._syntax_support import FastBlocksSyntaxSupport 

13 

14 

15class LanguageServerSettings(Settings): # type: ignore[misc] 

16 """Settings for FastBlocks Language Server.""" 

17 

18 # Required ACB 0.19.0+ metadata 

19 MODULE_ID: UUID = UUID("01937d87-2345-6789-abcd-123456789def") 

20 MODULE_STATUS: str = "stable" 

21 

22 # Server settings 

23 port: int = 7777 

24 host: str = "localhost" 

25 enable_tcp: bool = False 

26 enable_stdio: bool = True 

27 

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 

34 

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 

40 

41 

42class FastBlocksLanguageServer: 

43 """Language Server Protocol implementation for FastBlocks templates.""" 

44 

45 # Required ACB 0.19.0+ metadata 

46 MODULE_ID: UUID = UUID("01937d87-2345-6789-abcd-123456789def") 

47 MODULE_STATUS: str = "stable" 

48 

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]]] = {} 

55 

56 # Register with ACB 

57 with suppress(Exception): 

58 depends.set(self) 

59 

60 # Initialize syntax support 

61 self.syntax_support = FastBlocksSyntaxSupport() 

62 

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() 

67 

68 capabilities: dict[str, Any] = { 

69 "textDocumentSync": { 

70 "openClose": True, 

71 "change": 1, # Full document sync 

72 "save": {"includeText": True}, 

73 } 

74 } 

75 

76 if self.settings.enable_completions: 

77 capabilities["completionProvider"] = { 

78 "triggerCharacters": self.settings.completion_trigger_characters, 

79 "resolveProvider": True, 

80 } 

81 

82 if self.settings.enable_hover: 

83 capabilities["hoverProvider"] = True 

84 

85 if self.settings.enable_formatting: 

86 capabilities["documentFormattingProvider"] = True 

87 

88 if self.settings.enable_signature_help: 

89 capabilities["signatureHelpProvider"] = { 

90 "triggerCharacters": self.settings.signature_trigger_characters 

91 } 

92 

93 return { 

94 "capabilities": capabilities, 

95 "serverInfo": {"name": "FastBlocks Language Server", "version": "1.0.0"}, 

96 } 

97 

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"] 

103 

104 self._documents[uri] = content 

105 

106 # Run diagnostics 

107 if self.settings and self.settings.enable_diagnostics: 

108 await self._run_diagnostics(uri, content) 

109 

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"] 

114 

115 # For full document sync 

116 if changes: 

117 self._documents[uri] = changes[0]["text"] 

118 

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]) 

123 

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": []} 

128 

129 uri = params["textDocument"]["uri"] 

130 position = params["position"] 

131 content = self._documents.get(uri, "") 

132 

133 if not self.syntax_support: 

134 return {"items": []} 

135 

136 # Get completions 

137 completions = self.syntax_support.get_completions( 

138 content, position["line"], position["character"] 

139 ) 

140 

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 } 

151 

152 # Add snippet support 

153 if completion.insert_text and "$" in completion.insert_text: 

154 item["insertTextFormat"] = 2 # Snippet 

155 

156 items.append(item) 

157 

158 return {"items": items} 

159 

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 

166 

167 uri = params["textDocument"]["uri"] 

168 position = params["position"] 

169 content = self._documents.get(uri, "") 

170 

171 if not self.syntax_support: 

172 return None 

173 

174 hover_info = self.syntax_support.get_hover_info( 

175 content, position["line"], position["character"] 

176 ) 

177 

178 if hover_info: 

179 return {"contents": {"kind": "markdown", "value": hover_info}} 

180 

181 return None 

182 

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 [] 

189 

190 uri = params["textDocument"]["uri"] 

191 content = self._documents.get(uri, "") 

192 

193 if not self.syntax_support: 

194 return [] 

195 

196 formatted = self.syntax_support.format_template(content) 

197 

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 ] 

209 

210 return [] 

211 

212 async def _run_diagnostics(self, uri: str, content: str) -> None: 

213 """Run diagnostics on document.""" 

214 if not self.syntax_support: 

215 return 

216 

217 errors = self.syntax_support.check_syntax(content) 

218 diagnostics = [] 

219 

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 } 

231 

232 if error.fix_suggestion: 

233 diagnostic["data"] = {"fix": error.fix_suggestion} # type: ignore[typeddict-item] 

234 

235 diagnostics.append(diagnostic) 

236 

237 self._diagnostics[uri] = diagnostics 

238 

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 # }) 

244 

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 

256 

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) 

261 

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, []) 

265 

266 async def shutdown(self) -> None: 

267 """Handle server shutdown.""" 

268 self._documents.clear() 

269 self._diagnostics.clear() 

270 

271 

272class FastBlocksLanguageClient: 

273 """Simple language client for testing and integration.""" 

274 

275 def __init__(self) -> None: 

276 """Initialize language client.""" 

277 self.server = FastBlocksLanguageServer() 

278 self._initialized = False 

279 

280 async def initialize(self) -> None: 

281 """Initialize the language server.""" 

282 if self._initialized: 

283 return 

284 

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 

293 

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 ) 

307 

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 ) 

316 

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", [])) 

328 

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 ) 

339 

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 ) 

345 

346 def get_diagnostics(self, uri: str) -> list[dict[str, Any]]: 

347 """Get current diagnostics.""" 

348 return self.server.get_current_diagnostics(uri) 

349 

350 

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 } 

421 

422 

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 } 

502 

503 

504# ACB 0.19.0+ compatibility 

505__all__ = [ 

506 "FastBlocksLanguageServer", 

507 "FastBlocksLanguageClient", 

508 "LanguageServerSettings", 

509 "generate_vscode_extension", 

510 "generate_textmate_grammar", 

511]