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

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

2 

3import asyncio 

4import json 

5from contextlib import suppress 

6from pathlib import Path 

7from typing import Any 

8from uuid import UUID 

9 

10from acb.config import Settings 

11from acb.depends import depends 

12 

13from .syntax_support import FastBlocksSyntaxSupport, CompletionItem, SyntaxError 

14 

15 

16class LanguageServerSettings(Settings): 

17 """Settings for FastBlocks Language Server.""" 

18 

19 # Required ACB 0.19.0+ metadata 

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

21 MODULE_STATUS: str = "stable" 

22 

23 # Server settings 

24 port: int = 7777 

25 host: str = "localhost" 

26 enable_tcp: bool = False 

27 enable_stdio: bool = True 

28 

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 

35 

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 

41 

42 

43class FastBlocksLanguageServer: 

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

45 

46 # Required ACB 0.19.0+ metadata 

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

48 MODULE_STATUS: str = "stable" 

49 

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

56 

57 # Register with ACB 

58 with suppress(Exception): 

59 depends.set(self) 

60 

61 # Initialize syntax support 

62 self.syntax_support = FastBlocksSyntaxSupport() 

63 

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

68 

69 capabilities = { 

70 "textDocumentSync": { 

71 "openClose": True, 

72 "change": 1, # Full document sync 

73 "save": {"includeText": True} 

74 } 

75 } 

76 

77 if self.settings.enable_completions: 

78 capabilities["completionProvider"] = { 

79 "triggerCharacters": self.settings.completion_trigger_characters, 

80 "resolveProvider": True 

81 } 

82 

83 if self.settings.enable_hover: 

84 capabilities["hoverProvider"] = True 

85 

86 if self.settings.enable_formatting: 

87 capabilities["documentFormattingProvider"] = True 

88 

89 if self.settings.enable_signature_help: 

90 capabilities["signatureHelpProvider"] = { 

91 "triggerCharacters": self.settings.signature_trigger_characters 

92 } 

93 

94 return { 

95 "capabilities": capabilities, 

96 "serverInfo": { 

97 "name": "FastBlocks Language Server", 

98 "version": "1.0.0" 

99 } 

100 } 

101 

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

107 

108 self._documents[uri] = content 

109 

110 # Run diagnostics 

111 if self.settings and self.settings.enable_diagnostics: 

112 await self._run_diagnostics(uri, content) 

113 

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

118 

119 # For full document sync 

120 if changes: 

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

122 

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

127 

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

132 

133 uri = params["textDocument"]["uri"] 

134 position = params["position"] 

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

136 

137 if not self.syntax_support: 

138 return {"items": []} 

139 

140 # Get completions 

141 completions = self.syntax_support.get_completions( 

142 content, 

143 position["line"], 

144 position["character"] 

145 ) 

146 

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 } 

157 

158 # Add snippet support 

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

160 item["insertTextFormat"] = 2 # Snippet 

161 

162 items.append(item) 

163 

164 return {"items": items} 

165 

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 

170 

171 uri = params["textDocument"]["uri"] 

172 position = params["position"] 

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

174 

175 if not self.syntax_support: 

176 return None 

177 

178 hover_info = self.syntax_support.get_hover_info( 

179 content, 

180 position["line"], 

181 position["character"] 

182 ) 

183 

184 if hover_info: 

185 return { 

186 "contents": { 

187 "kind": "markdown", 

188 "value": hover_info 

189 } 

190 } 

191 

192 return None 

193 

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

198 

199 uri = params["textDocument"]["uri"] 

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

201 

202 if not self.syntax_support: 

203 return [] 

204 

205 formatted = self.syntax_support.format_template(content) 

206 

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

216 

217 return [] 

218 

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

220 """Run diagnostics on document.""" 

221 if not self.syntax_support: 

222 return 

223 

224 errors = self.syntax_support.check_syntax(content) 

225 diagnostics = [] 

226 

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 } 

238 

239 if error.fix_suggestion: 

240 diagnostic["data"] = {"fix": error.fix_suggestion} 

241 

242 diagnostics.append(diagnostic) 

243 

244 self._diagnostics[uri] = diagnostics 

245 

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

251 

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 

263 

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) 

273 

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

277 

278 async def shutdown(self) -> None: 

279 """Handle server shutdown.""" 

280 self._documents.clear() 

281 self._diagnostics.clear() 

282 

283 

284class FastBlocksLanguageClient: 

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

286 

287 def __init__(self) -> None: 

288 """Initialize language client.""" 

289 self.server = FastBlocksLanguageServer() 

290 self._initialized = False 

291 

292 async def initialize(self) -> None: 

293 """Initialize the language server.""" 

294 if self._initialized: 

295 return 

296 

297 await self.server.initialize({ 

298 "processId": None, 

299 "clientInfo": {"name": "FastBlocks Client", "version": "1.0.0"}, 

300 "capabilities": {} 

301 }) 

302 self._initialized = True 

303 

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

315 

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

322 

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

330 

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

337 

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

343 

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

345 """Get current diagnostics.""" 

346 return self.server.get_current_diagnostics(uri) 

347 

348 

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 } 

417 

418 

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 } 

503 

504 

505# ACB 0.19.0+ compatibility 

506__all__ = [ 

507 "FastBlocksLanguageServer", 

508 "FastBlocksLanguageClient", 

509 "LanguageServerSettings", 

510 "generate_vscode_extension", 

511 "generate_textmate_grammar" 

512]