Coverage for fastblocks/adapters/templates/syntax_support.py: 33%

224 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-29 00:51 -0700

1"""FastBlocks syntax support and autocomplete system.""" 

2 

3import re 

4from contextlib import suppress 

5from dataclasses import dataclass, field 

6from pathlib import Path 

7from typing import Any 

8from uuid import UUID 

9 

10from acb.config import Settings 

11from acb.depends import depends 

12 

13 

14@dataclass 

15class CompletionItem: 

16 """Auto-completion item for FastBlocks syntax.""" 

17 

18 label: str 

19 kind: str # 'function', 'variable', 'filter', 'block', 'component' 

20 detail: str = "" 

21 documentation: str = "" 

22 insert_text: str = "" 

23 parameters: list[str] = field(default_factory=list) 

24 category: str = "general" 

25 priority: int = 0 

26 

27 

28@dataclass 

29class SyntaxError: 

30 """FastBlocks syntax error.""" 

31 

32 line: int 

33 column: int 

34 message: str 

35 severity: str = "error" # 'error', 'warning', 'info' 

36 code: str = "" 

37 fix_suggestion: str = "" 

38 

39 

40class FastBlocksSyntaxSettings(Settings): 

41 """Settings for FastBlocks syntax support.""" 

42 

43 # Required ACB 0.19.0+ metadata 

44 MODULE_ID: UUID = UUID("01937d87-1234-5678-9abc-123456789def") 

45 MODULE_STATUS: str = "stable" 

46 

47 # Completion settings 

48 max_completions: int = 50 

49 completion_timeout: float = 1.0 

50 enable_snippets: bool = True 

51 enable_parameter_hints: bool = True 

52 

53 # Syntax highlighting 

54 enable_highlighting: bool = True 

55 highlight_delimiters: bool = True 

56 highlight_filters: bool = True 

57 highlight_functions: bool = True 

58 

59 # Error checking 

60 enable_error_checking: bool = True 

61 check_template_syntax: bool = True 

62 check_filter_existence: bool = True 

63 check_function_calls: bool = True 

64 

65 # Template delimiters 

66 variable_start: str = "[[" 

67 variable_end: str = "]]" 

68 block_start: str = "[%" 

69 block_end: str = "%]" 

70 comment_start: str = "[#" 

71 comment_end: str = "#]" 

72 

73 

74class FastBlocksSyntaxSupport: 

75 """FastBlocks syntax support and autocomplete provider.""" 

76 

77 # Required ACB 0.19.0+ metadata 

78 MODULE_ID: UUID = UUID("01937d87-1234-5678-9abc-123456789def") 

79 MODULE_STATUS: str = "stable" 

80 

81 def __init__(self) -> None: 

82 """Initialize syntax support.""" 

83 self.settings: FastBlocksSyntaxSettings | None = None 

84 self._completions_cache: dict[str, list[CompletionItem]] = {} 

85 self._syntax_patterns: dict[str, re.Pattern[str]] = {} 

86 self._builtin_filters: set[str] = set() 

87 self._builtin_functions: set[str] = set() 

88 self._custom_components: set[str] = set() 

89 

90 # Register with ACB 

91 with suppress(Exception): 

92 depends.set(self) 

93 

94 self._initialize_patterns() 

95 self._load_builtin_definitions() 

96 

97 def _initialize_patterns(self) -> None: 

98 """Initialize regex patterns for syntax parsing.""" 

99 if not self.settings: 

100 self.settings = FastBlocksSyntaxSettings() 

101 

102 # Escape delimiters for regex 

103 var_start = re.escape(self.settings.variable_start) 

104 var_end = re.escape(self.settings.variable_end) 

105 block_start = re.escape(self.settings.block_start) 

106 block_end = re.escape(self.settings.block_end) 

107 comment_start = re.escape(self.settings.comment_start) 

108 comment_end = re.escape(self.settings.comment_end) 

109 

110 self._syntax_patterns = { 

111 "variable": re.compile(rf"{var_start}\s*([^{var_end}]*?)\s*{var_end}"), 

112 "block": re.compile(rf"{block_start}\s*([^{block_end}]*?)\s*{block_end}"), 

113 "comment": re.compile(rf"{comment_start}\s*([^{comment_end}]*?)\s*{comment_end}"), 

114 "filter": re.compile(r"\|\s*(\w+)(?:\([^)]*\))?"), 

115 "function": re.compile(r"(\w+)\s*\("), 

116 "component": re.compile(r"render_component\s*\(\s*[\"']([^\"']+)[\"']"), 

117 "string": re.compile(r'["\']([^"\']*)["\']'), 

118 "identifier": re.compile(r"\b(\w+)\b"), 

119 } 

120 

121 def _load_builtin_definitions(self) -> None: 

122 """Load built-in Jinja2 filters and functions.""" 

123 self._builtin_filters = { 

124 # Jinja2 built-in filters 

125 "abs", "attr", "batch", "capitalize", "center", "default", "dictsort", 

126 "escape", "filesizeformat", "first", "float", "forceescape", "format", 

127 "groupby", "indent", "int", "join", "last", "length", "list", "lower", 

128 "map", "max", "min", "pprint", "random", "reject", "rejectattr", "replace", 

129 "reverse", "round", "safe", "select", "selectattr", "slice", "sort", 

130 "string", "striptags", "sum", "title", "tojson", "trim", "truncate", 

131 "unique", "upper", "urlencode", "urlize", "wordcount", "wordwrap", "xmlattr", 

132 

133 # FastBlocks custom filters 

134 "img", "icon", "stylesheet", "component_class", "duotone", "interactive", 

135 "button_icon", "ph_icon", "ph_class", "hero_icon", "remix_icon", "material_icon", 

136 "webawesome_class", "kelp_class", "cloudflare_img", "twicpics_img", 

137 } 

138 

139 self._builtin_functions = { 

140 # Jinja2 built-in functions 

141 "range", "lipsum", "dict", "cycler", "joiner", "namespace", 

142 

143 # FastBlocks custom functions 

144 "render_component", "get_adapter", "include_template", "extend_template", 

145 "load_static", "url_for", "csrf_token", "flash_messages", "get_config", 

146 "phosphor_stylesheet_links", "ph_duotone", "ph_interactive", "ph_button_icon", 

147 "heroicons_stylesheet_links", "remixicon_stylesheet_links", "material_icons_links", 

148 "webawesome_stylesheet_links", "kelpui_stylesheet_links", 

149 } 

150 

151 def get_completions( 

152 self, 

153 content: str, 

154 line: int, 

155 column: int, 

156 context: str = "" 

157 ) -> list[CompletionItem]: 

158 """Get auto-completion suggestions for given position.""" 

159 if not self.settings: 

160 self.settings = FastBlocksSyntaxSettings() 

161 

162 # Find current context 

163 current_line = content.split("\n")[line] if line < len(content.split("\n")) else "" 

164 prefix = current_line[:column] 

165 

166 completions: list[CompletionItem] = [] 

167 

168 # Determine completion context 

169 if self._is_in_variable_context(prefix): 

170 completions.extend(self._get_variable_completions(prefix)) 

171 elif self._is_in_block_context(prefix): 

172 completions.extend(self._get_block_completions(prefix)) 

173 elif self._is_in_filter_context(prefix): 

174 completions.extend(self._get_filter_completions(prefix)) 

175 elif self._is_in_function_context(prefix): 

176 completions.extend(self._get_function_completions(prefix)) 

177 else: 

178 # General completions 

179 completions.extend(self._get_general_completions(prefix)) 

180 

181 # Sort by priority and limit results 

182 completions.sort(key=lambda x: (-x.priority, x.label)) 

183 return completions[:self.settings.max_completions] 

184 

185 def _is_in_variable_context(self, prefix: str) -> bool: 

186 """Check if cursor is in variable context.""" 

187 if not self.settings: 

188 return False 

189 return self.settings.variable_start in prefix and self.settings.variable_end not in prefix.split(self.settings.variable_start)[-1] 

190 

191 def _is_in_block_context(self, prefix: str) -> bool: 

192 """Check if cursor is in block context.""" 

193 if not self.settings: 

194 return False 

195 return self.settings.block_start in prefix and self.settings.block_end not in prefix.split(self.settings.block_start)[-1] 

196 

197 def _is_in_filter_context(self, prefix: str) -> bool: 

198 """Check if cursor is after a pipe for filter completion.""" 

199 return "|" in prefix and not prefix.rstrip().endswith("|") 

200 

201 def _is_in_function_context(self, prefix: str) -> bool: 

202 """Check if cursor is in function call context.""" 

203 return "(" in prefix and ")" not in prefix.split("(")[-1] 

204 

205 def _get_variable_completions(self, prefix: str) -> list[CompletionItem]: 

206 """Get completions for variable context.""" 

207 return [ 

208 CompletionItem( 

209 label="request", 

210 kind="variable", 

211 detail="Current request object", 

212 documentation="The current HTTP request with all headers and data", 

213 priority=10 

214 ), 

215 CompletionItem( 

216 label="user", 

217 kind="variable", 

218 detail="Current user object", 

219 documentation="The authenticated user (if available)", 

220 priority=9 

221 ), 

222 CompletionItem( 

223 label="config", 

224 kind="variable", 

225 detail="Application configuration", 

226 documentation="FastBlocks configuration settings", 

227 priority=8 

228 ), 

229 CompletionItem( 

230 label="now", 

231 kind="variable", 

232 detail="Current datetime", 

233 documentation="Current datetime object", 

234 priority=7 

235 ), 

236 ] 

237 

238 def _get_block_completions(self, prefix: str) -> list[CompletionItem]: 

239 """Get completions for block context.""" 

240 return [ 

241 CompletionItem( 

242 label="if", 

243 kind="block", 

244 detail="Conditional block", 

245 insert_text="if condition %]\n content\n[% endif", 

246 documentation="Conditional rendering block", 

247 priority=10 

248 ), 

249 CompletionItem( 

250 label="for", 

251 kind="block", 

252 detail="Loop block", 

253 insert_text="for item in items %]\n [[ item ]]\n[% endfor", 

254 documentation="Iteration loop block", 

255 priority=10 

256 ), 

257 CompletionItem( 

258 label="block", 

259 kind="block", 

260 detail="Template inheritance block", 

261 insert_text="block name %]\n content\n[% endblock", 

262 documentation="Template inheritance block", 

263 priority=9 

264 ), 

265 CompletionItem( 

266 label="extends", 

267 kind="block", 

268 detail="Template inheritance", 

269 insert_text="extends \"base.html\"", 

270 documentation="Extend a parent template", 

271 priority=9 

272 ), 

273 CompletionItem( 

274 label="include", 

275 kind="block", 

276 detail="Include template", 

277 insert_text="include \"partial.html\"", 

278 documentation="Include another template", 

279 priority=8 

280 ), 

281 CompletionItem( 

282 label="set", 

283 kind="block", 

284 detail="Variable assignment", 

285 insert_text="set variable = value", 

286 documentation="Set a template variable", 

287 priority=8 

288 ), 

289 ] 

290 

291 def _get_filter_completions(self, prefix: str) -> list[CompletionItem]: 

292 """Get completions for filter context.""" 

293 completions = [] 

294 

295 for filter_name in sorted(self._builtin_filters): 

296 documentation = self._get_filter_documentation(filter_name) 

297 completions.append(CompletionItem( 

298 label=filter_name, 

299 kind="filter", 

300 detail=f"Filter: {filter_name}", 

301 documentation=documentation, 

302 priority=8 if filter_name.startswith(("ph_", "img", "icon")) else 5 

303 )) 

304 

305 return completions 

306 

307 def _get_function_completions(self, prefix: str) -> list[CompletionItem]: 

308 """Get completions for function context.""" 

309 completions = [] 

310 

311 for func_name in sorted(self._builtin_functions): 

312 documentation = self._get_function_documentation(func_name) 

313 parameters = self._get_function_parameters(func_name) 

314 

315 completions.append(CompletionItem( 

316 label=func_name, 

317 kind="function", 

318 detail=f"Function: {func_name}", 

319 documentation=documentation, 

320 parameters=parameters, 

321 priority=8 if func_name.startswith("render_") else 5 

322 )) 

323 

324 return completions 

325 

326 def _get_general_completions(self, prefix: str) -> list[CompletionItem]: 

327 """Get general completions.""" 

328 if not self.settings: 

329 self.settings = FastBlocksSyntaxSettings() 

330 

331 return [ 

332 CompletionItem( 

333 label=f"{self.settings.variable_start} {self.settings.variable_end}", 

334 kind="snippet", 

335 detail="Variable output", 

336 insert_text=f"{self.settings.variable_start} variable {self.settings.variable_end}", 

337 documentation="Output a variable value", 

338 priority=10 

339 ), 

340 CompletionItem( 

341 label=f"{self.settings.block_start} {self.settings.block_end}", 

342 kind="snippet", 

343 detail="Block statement", 

344 insert_text=f"{self.settings.block_start} block {self.settings.block_end}", 

345 documentation="Template logic block", 

346 priority=10 

347 ), 

348 CompletionItem( 

349 label=f"{self.settings.comment_start} {self.settings.comment_end}", 

350 kind="snippet", 

351 detail="Comment", 

352 insert_text=f"{self.settings.comment_start} comment {self.settings.comment_end}", 

353 documentation="Template comment", 

354 priority=5 

355 ), 

356 ] 

357 

358 def _get_filter_documentation(self, filter_name: str) -> str: 

359 """Get documentation for a filter.""" 

360 docs = { 

361 "img": "Generate image tag with adapter support", 

362 "icon": "Generate icon tag with variant support", 

363 "ph_icon": "Generate Phosphor icon with customization", 

364 "ph_class": "Get Phosphor icon CSS class", 

365 "hero_icon": "Generate Heroicons icon", 

366 "remix_icon": "Generate Remix icon", 

367 "material_icon": "Generate Material Design icon", 

368 "cloudflare_img": "Cloudflare Images optimization", 

369 "twicpics_img": "TwicPics image transformation", 

370 "default": "Provide default value if variable is undefined", 

371 "length": "Get length of sequence or mapping", 

372 "upper": "Convert string to uppercase", 

373 "lower": "Convert string to lowercase", 

374 "title": "Convert string to title case", 

375 "capitalize": "Capitalize first letter", 

376 "truncate": "Truncate string to specified length", 

377 "join": "Join sequence elements with separator", 

378 "replace": "Replace substring with another string", 

379 "safe": "Mark string as safe HTML", 

380 "escape": "Escape HTML characters", 

381 "tojson": "Convert value to JSON string", 

382 } 

383 return docs.get(filter_name, f"Jinja2 {filter_name} filter") 

384 

385 def _get_function_documentation(self, func_name: str) -> str: 

386 """Get documentation for a function.""" 

387 docs = { 

388 "render_component": "Render HTMY component with context", 

389 "get_adapter": "Get adapter instance by name", 

390 "include_template": "Include template with context", 

391 "url_for": "Generate URL for route", 

392 "csrf_token": "Generate CSRF token", 

393 "flash_messages": "Get flash messages", 

394 "get_config": "Get configuration value", 

395 "ph_duotone": "Generate duotone Phosphor icon", 

396 "ph_interactive": "Generate interactive Phosphor icon", 

397 "ph_button_icon": "Generate button with Phosphor icon", 

398 "range": "Generate range of numbers", 

399 "dict": "Create dictionary from arguments", 

400 "lipsum": "Generate Lorem Ipsum text", 

401 } 

402 return docs.get(func_name, f"Jinja2 {func_name} function") 

403 

404 def _get_function_parameters(self, func_name: str) -> list[str]: 

405 """Get function parameters.""" 

406 params = { 

407 "render_component": ["component_name", "context={}"], 

408 "get_adapter": ["adapter_name"], 

409 "include_template": ["template_name", "**context"], 

410 "url_for": ["route_name", "**params"], 

411 "ph_duotone": ["icon_name", "primary_color=None", "secondary_color=None"], 

412 "ph_interactive": ["icon_name", "variant='regular'", "action=None"], 

413 "ph_button_icon": ["icon_name", "text=None", "variant='regular'"], 

414 "range": ["start", "stop=None", "step=1"], 

415 "dict": ["**items"], 

416 } 

417 return params.get(func_name, []) 

418 

419 def check_syntax(self, content: str, template_path: Path | None = None) -> list[SyntaxError]: 

420 """Check template syntax and return errors.""" 

421 if not self.settings or not self.settings.enable_error_checking: 

422 return [] 

423 

424 errors: list[SyntaxError] = [] 

425 lines = content.split("\n") 

426 

427 for line_num, line in enumerate(lines): 

428 # Check delimiter balance 

429 errors.extend(self._check_delimiter_balance(line, line_num)) 

430 

431 # Check filter existence 

432 if self.settings.check_filter_existence: 

433 errors.extend(self._check_filter_existence(line, line_num)) 

434 

435 # Check function calls 

436 if self.settings.check_function_calls: 

437 errors.extend(self._check_function_calls(line, line_num)) 

438 

439 return errors 

440 

441 def _check_delimiter_balance(self, line: str, line_num: int) -> list[SyntaxError]: 

442 """Check if delimiters are balanced.""" 

443 if not self.settings: 

444 return [] 

445 

446 errors = [] 

447 

448 # Check variable delimiters 

449 var_opens = line.count(self.settings.variable_start) 

450 var_closes = line.count(self.settings.variable_end) 

451 if var_opens != var_closes: 

452 errors.append(SyntaxError( 

453 line=line_num, 

454 column=0, 

455 message=f"Unbalanced variable delimiters: {var_opens} opens, {var_closes} closes", 

456 severity="error", 

457 code="unbalanced_delimiters", 

458 fix_suggestion=f"Add missing {self.settings.variable_end if var_opens > var_closes else self.settings.variable_start}" 

459 )) 

460 

461 # Check block delimiters 

462 block_opens = line.count(self.settings.block_start) 

463 block_closes = line.count(self.settings.block_end) 

464 if block_opens != block_closes: 

465 errors.append(SyntaxError( 

466 line=line_num, 

467 column=0, 

468 message=f"Unbalanced block delimiters: {block_opens} opens, {block_closes} closes", 

469 severity="error", 

470 code="unbalanced_delimiters", 

471 fix_suggestion=f"Add missing {self.settings.block_end if block_opens > block_closes else self.settings.block_start}" 

472 )) 

473 

474 return errors 

475 

476 def _check_filter_existence(self, line: str, line_num: int) -> list[SyntaxError]: 

477 """Check if filters exist.""" 

478 errors = [] 

479 

480 if "filter" in self._syntax_patterns: 

481 for match in self._syntax_patterns["filter"].finditer(line): 

482 filter_name = match.group(1) 

483 if filter_name not in self._builtin_filters: 

484 errors.append(SyntaxError( 

485 line=line_num, 

486 column=match.start(), 

487 message=f"Unknown filter: {filter_name}", 

488 severity="warning", 

489 code="unknown_filter", 

490 fix_suggestion=f"Check filter name or register custom filter" 

491 )) 

492 

493 return errors 

494 

495 def _check_function_calls(self, line: str, line_num: int) -> list[SyntaxError]: 

496 """Check function calls.""" 

497 errors = [] 

498 

499 if "function" in self._syntax_patterns: 

500 for match in self._syntax_patterns["function"].finditer(line): 

501 func_name = match.group(1) 

502 if func_name not in self._builtin_functions and not func_name.startswith("_"): 

503 errors.append(SyntaxError( 

504 line=line_num, 

505 column=match.start(), 

506 message=f"Unknown function: {func_name}", 

507 severity="warning", 

508 code="unknown_function", 

509 fix_suggestion="Check function name or import if custom" 

510 )) 

511 

512 return errors 

513 

514 def get_hover_info(self, content: str, line: int, column: int) -> str | None: 

515 """Get hover information for symbol at position.""" 

516 lines = content.split("\n") 

517 if line >= len(lines): 

518 return None 

519 

520 current_line = lines[line] 

521 

522 # Find word at position 

523 word_start = column 

524 word_end = column 

525 

526 while word_start > 0 and current_line[word_start - 1].isalnum(): 

527 word_start -= 1 

528 

529 while word_end < len(current_line) and current_line[word_end].isalnum(): 

530 word_end += 1 

531 

532 word = current_line[word_start:word_end] 

533 

534 if word in self._builtin_filters: 

535 return f"**Filter: {word}**\n\n{self._get_filter_documentation(word)}" 

536 elif word in self._builtin_functions: 

537 params = self._get_function_parameters(word) 

538 param_str = ", ".join(params) if params else "" 

539 return f"**Function: {word}({param_str})**\n\n{self._get_function_documentation(word)}" 

540 

541 return None 

542 

543 def format_template(self, content: str) -> str: 

544 """Format template content.""" 

545 # Simple formatting - in a real implementation this would be more sophisticated 

546 lines = content.split("\n") 

547 formatted_lines = [] 

548 indent_level = 0 

549 

550 for line in lines: 

551 stripped = line.strip() 

552 

553 # Decrease indent for end blocks 

554 if stripped.startswith("[% end") or stripped.startswith("[% else"): 

555 indent_level = max(0, indent_level - 1) 

556 

557 # Add formatted line 

558 if stripped: 

559 formatted_lines.append(" " * indent_level + stripped) 

560 else: 

561 formatted_lines.append("") 

562 

563 # Increase indent for start blocks 

564 if any(stripped.startswith(f"[% {block}") for block in ["if", "for", "block", "macro"]): 

565 indent_level += 1 

566 elif stripped.startswith("[% else"): 

567 indent_level += 1 

568 

569 return "\n".join(formatted_lines) 

570 

571 

572# Template filter registration for FastBlocks 

573def register_syntax_filters(env: Any) -> None: 

574 """Register syntax support filters for Jinja2 templates.""" 

575 

576 @env.filter("format_template") 

577 def format_template_filter(content: str) -> str: 

578 """Template filter for formatting FastBlocks templates.""" 

579 syntax_support = depends.get("syntax_support") 

580 if isinstance(syntax_support, FastBlocksSyntaxSupport): 

581 return syntax_support.format_template(content) 

582 return content 

583 

584 @env.global_("syntax_check") 

585 def syntax_check_global(content: str) -> list[dict[str, Any]]: 

586 """Global function for syntax checking.""" 

587 syntax_support = depends.get("syntax_support") 

588 if isinstance(syntax_support, FastBlocksSyntaxSupport): 

589 errors = syntax_support.check_syntax(content) 

590 return [ 

591 { 

592 "line": error.line, 

593 "column": error.column, 

594 "message": error.message, 

595 "severity": error.severity, 

596 "code": error.code, 

597 "fix": error.fix_suggestion, 

598 } 

599 for error in errors 

600 ] 

601 return [] 

602 

603 

604# ACB 0.19.0+ compatibility 

605__all__ = ["FastBlocksSyntaxSupport", "FastBlocksSyntaxSettings", "CompletionItem", "SyntaxError", "register_syntax_filters"]