Coverage for fastblocks/adapters/templates/_syntax_support.py: 33%
224 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""FastBlocks syntax support and autocomplete system."""
3import re
4from contextlib import suppress
5from dataclasses import dataclass, field
6from pathlib import Path
7from typing import Any
8from uuid import UUID
10from acb.config import Settings
11from acb.depends import depends
14@dataclass
15class CompletionItem:
16 """Auto-completion item for FastBlocks syntax."""
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
28@dataclass
29class SyntaxError:
30 """FastBlocks syntax error."""
32 line: int
33 column: int
34 message: str
35 severity: str = "error" # 'error', 'warning', 'info'
36 code: str = ""
37 fix_suggestion: str = ""
40class FastBlocksSyntaxSettings(Settings): # type: ignore[misc]
41 """Settings for FastBlocks syntax support."""
43 # Required ACB 0.19.0+ metadata
44 MODULE_ID: UUID = UUID("01937d87-1234-5678-9abc-123456789def")
45 MODULE_STATUS: str = "stable"
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
53 # Syntax highlighting
54 enable_highlighting: bool = True
55 highlight_delimiters: bool = True
56 highlight_filters: bool = True
57 highlight_functions: bool = True
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
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 = "#]"
74class FastBlocksSyntaxSupport:
75 """FastBlocks syntax support and autocomplete provider."""
77 # Required ACB 0.19.0+ metadata
78 MODULE_ID: UUID = UUID("01937d87-1234-5678-9abc-123456789def")
79 MODULE_STATUS: str = "stable"
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()
90 # Register with ACB
91 with suppress(Exception):
92 depends.set(self)
94 self._initialize_patterns()
95 self._load_builtin_definitions()
97 def _initialize_patterns(self) -> None:
98 """Initialize regex patterns for syntax parsing."""
99 if not self.settings:
100 self.settings = FastBlocksSyntaxSettings()
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)
110 self._syntax_patterns = {
111 "variable": re.compile(
112 rf"{var_start}\s*([^{var_end}]*?)\s*{var_end}"
113 ), # REGEX OK: template variable syntax
114 "block": re.compile(
115 rf"{block_start}\s*([^{block_end}]*?)\s*{block_end}"
116 ), # REGEX OK: template block syntax
117 "comment": re.compile( # REGEX OK: template comment syntax
118 rf"{comment_start}\s*([^{comment_end}]*?)\s*{comment_end}"
119 ),
120 "filter": re.compile(
121 r"\|\s*(\w+)(?:\([^)]*\))?"
122 ), # REGEX OK: template filter syntax
123 "function": re.compile(r"(\w+)\s*\("), # REGEX OK: template function calls
124 "component": re.compile(
125 r"render_component\s*\(\s*[\"']([^\"']+)[\"']"
126 ), # REGEX OK: component rendering syntax
127 "string": re.compile(
128 r'["\']([^"\']*)["\']'
129 ), # REGEX OK: string literals in templates
130 "identifier": re.compile(r"\b(\w+)\b"), # REGEX OK: variable identifiers
131 }
133 def _load_builtin_definitions(self) -> None:
134 """Load built-in Jinja2 filters and functions."""
135 self._builtin_filters = {
136 # Jinja2 built-in filters
137 "abs",
138 "attr",
139 "batch",
140 "capitalize",
141 "center",
142 "default",
143 "dictsort",
144 "escape",
145 "filesizeformat",
146 "first",
147 "float",
148 "forceescape",
149 "format",
150 "groupby",
151 "indent",
152 "int",
153 "join",
154 "last",
155 "length",
156 "list",
157 "lower",
158 "map",
159 "max",
160 "min",
161 "pprint",
162 "random",
163 "reject",
164 "rejectattr",
165 "replace",
166 "reverse",
167 "round",
168 "safe",
169 "select",
170 "selectattr",
171 "slice",
172 "sort",
173 "string",
174 "striptags",
175 "sum",
176 "title",
177 "tojson",
178 "trim",
179 "truncate",
180 "unique",
181 "upper",
182 "urlencode",
183 "urlize",
184 "wordcount",
185 "wordwrap",
186 "xmlattr",
187 # FastBlocks custom filters
188 "img",
189 "icon",
190 "stylesheet",
191 "component_class",
192 "duotone",
193 "interactive",
194 "button_icon",
195 "ph_icon",
196 "ph_class",
197 "hero_icon",
198 "remix_icon",
199 "material_icon",
200 "webawesome_class",
201 "kelp_class",
202 "cloudflare_img",
203 "twicpics_img",
204 }
206 self._builtin_functions = {
207 # Jinja2 built-in functions
208 "range",
209 "lipsum",
210 "dict",
211 "cycler",
212 "joiner",
213 "namespace",
214 # FastBlocks custom functions
215 "render_component",
216 "get_adapter",
217 "include_template",
218 "extend_template",
219 "load_static",
220 "url_for",
221 "csrf_token",
222 "flash_messages",
223 "get_config",
224 "phosphor_stylesheet_links",
225 "ph_duotone",
226 "ph_interactive",
227 "ph_button_icon",
228 "heroicons_stylesheet_links",
229 "remixicon_stylesheet_links",
230 "material_icons_links",
231 "webawesome_stylesheet_links",
232 "kelpui_stylesheet_links",
233 }
235 def get_completions(
236 self, content: str, line: int, column: int, context: str = ""
237 ) -> list[CompletionItem]:
238 """Get auto-completion suggestions for given position."""
239 if not self.settings:
240 self.settings = FastBlocksSyntaxSettings()
242 # Find current context
243 current_line = (
244 content.split("\n")[line] if line < len(content.split("\n")) else ""
245 )
246 prefix = current_line[:column]
248 completions: list[CompletionItem] = []
250 # Determine completion context
251 if self._is_in_variable_context(prefix):
252 completions.extend(self._get_variable_completions(prefix))
253 elif self._is_in_block_context(prefix):
254 completions.extend(self._get_block_completions(prefix))
255 elif self._is_in_filter_context(prefix):
256 completions.extend(self._get_filter_completions(prefix))
257 elif self._is_in_function_context(prefix):
258 completions.extend(self._get_function_completions(prefix))
259 else:
260 # General completions
261 completions.extend(self._get_general_completions(prefix))
263 # Sort by priority and limit results
264 completions.sort(key=lambda x: (-x.priority, x.label))
265 return completions[: self.settings.max_completions]
267 def _is_in_variable_context(self, prefix: str) -> bool:
268 """Check if cursor is in variable context."""
269 if not self.settings:
270 return False
271 return (
272 self.settings.variable_start in prefix
273 and self.settings.variable_end
274 not in prefix.split(self.settings.variable_start)[-1]
275 )
277 def _is_in_block_context(self, prefix: str) -> bool:
278 """Check if cursor is in block context."""
279 if not self.settings:
280 return False
281 return (
282 self.settings.block_start in prefix
283 and self.settings.block_end
284 not in prefix.split(self.settings.block_start)[-1]
285 )
287 def _is_in_filter_context(self, prefix: str) -> bool:
288 """Check if cursor is after a pipe for filter completion."""
289 return "|" in prefix and not prefix.rstrip().endswith("|")
291 def _is_in_function_context(self, prefix: str) -> bool:
292 """Check if cursor is in function call context."""
293 return "(" in prefix and ")" not in prefix.split("(")[-1]
295 def _get_variable_completions(self, prefix: str) -> list[CompletionItem]:
296 """Get completions for variable context."""
297 return [
298 CompletionItem(
299 label="request",
300 kind="variable",
301 detail="Current request object",
302 documentation="The current HTTP request with all headers and data",
303 priority=10,
304 ),
305 CompletionItem(
306 label="user",
307 kind="variable",
308 detail="Current user object",
309 documentation="The authenticated user (if available)",
310 priority=9,
311 ),
312 CompletionItem(
313 label="config",
314 kind="variable",
315 detail="Application configuration",
316 documentation="FastBlocks configuration settings",
317 priority=8,
318 ),
319 CompletionItem(
320 label="now",
321 kind="variable",
322 detail="Current datetime",
323 documentation="Current datetime object",
324 priority=7,
325 ),
326 ]
328 def _get_block_completions(self, prefix: str) -> list[CompletionItem]:
329 """Get completions for block context."""
330 return [
331 CompletionItem(
332 label="if",
333 kind="block",
334 detail="Conditional block",
335 insert_text="if condition %]\n content\n[% endif",
336 documentation="Conditional rendering block",
337 priority=10,
338 ),
339 CompletionItem(
340 label="for",
341 kind="block",
342 detail="Loop block",
343 insert_text="for item in items %]\n [[ item ]]\n[% endfor",
344 documentation="Iteration loop block",
345 priority=10,
346 ),
347 CompletionItem(
348 label="block",
349 kind="block",
350 detail="Template inheritance block",
351 insert_text="block name %]\n content\n[% endblock",
352 documentation="Template inheritance block",
353 priority=9,
354 ),
355 CompletionItem(
356 label="extends",
357 kind="block",
358 detail="Template inheritance",
359 insert_text='extends "base.html"',
360 documentation="Extend a parent template",
361 priority=9,
362 ),
363 CompletionItem(
364 label="include",
365 kind="block",
366 detail="Include template",
367 insert_text='include "partial.html"',
368 documentation="Include another template",
369 priority=8,
370 ),
371 CompletionItem(
372 label="set",
373 kind="block",
374 detail="Variable assignment",
375 insert_text="set variable = value",
376 documentation="Set a template variable",
377 priority=8,
378 ),
379 ]
381 def _get_filter_completions(self, prefix: str) -> list[CompletionItem]:
382 """Get completions for filter context."""
383 completions = []
385 for filter_name in sorted(self._builtin_filters):
386 documentation = self._get_filter_documentation(filter_name)
387 completions.append(
388 CompletionItem(
389 label=filter_name,
390 kind="filter",
391 detail=f"Filter: {filter_name}",
392 documentation=documentation,
393 priority=8 if filter_name.startswith(("ph_", "img", "icon")) else 5,
394 )
395 )
397 return completions
399 def _get_function_completions(self, prefix: str) -> list[CompletionItem]:
400 """Get completions for function context."""
401 completions = []
403 for func_name in sorted(self._builtin_functions):
404 documentation = self._get_function_documentation(func_name)
405 parameters = self._get_function_parameters(func_name)
407 completions.append(
408 CompletionItem(
409 label=func_name,
410 kind="function",
411 detail=f"Function: {func_name}",
412 documentation=documentation,
413 parameters=parameters,
414 priority=8 if func_name.startswith("render_") else 5,
415 )
416 )
418 return completions
420 def _get_general_completions(self, prefix: str) -> list[CompletionItem]:
421 """Get general completions."""
422 if not self.settings:
423 self.settings = FastBlocksSyntaxSettings()
425 return [
426 CompletionItem(
427 label=f"{self.settings.variable_start} {self.settings.variable_end}",
428 kind="snippet",
429 detail="Variable output",
430 insert_text=f"{self.settings.variable_start} variable {self.settings.variable_end}",
431 documentation="Output a variable value",
432 priority=10,
433 ),
434 CompletionItem(
435 label=f"{self.settings.block_start} {self.settings.block_end}",
436 kind="snippet",
437 detail="Block statement",
438 insert_text=f"{self.settings.block_start} block {self.settings.block_end}",
439 documentation="Template logic block",
440 priority=10,
441 ),
442 CompletionItem(
443 label=f"{self.settings.comment_start} {self.settings.comment_end}",
444 kind="snippet",
445 detail="Comment",
446 insert_text=f"{self.settings.comment_start} comment {self.settings.comment_end}",
447 documentation="Template comment",
448 priority=5,
449 ),
450 ]
452 def _get_filter_documentation(self, filter_name: str) -> str:
453 """Get documentation for a filter."""
454 docs = {
455 "img": "Generate image tag with adapter support",
456 "icon": "Generate icon tag with variant support",
457 "ph_icon": "Generate Phosphor icon with customization",
458 "ph_class": "Get Phosphor icon CSS class",
459 "hero_icon": "Generate Heroicons icon",
460 "remix_icon": "Generate Remix icon",
461 "material_icon": "Generate Material Design icon",
462 "cloudflare_img": "Cloudflare Images optimization",
463 "twicpics_img": "TwicPics image transformation",
464 "default": "Provide default value if variable is undefined",
465 "length": "Get length of sequence or mapping",
466 "upper": "Convert string to uppercase",
467 "lower": "Convert string to lowercase",
468 "title": "Convert string to title case",
469 "capitalize": "Capitalize first letter",
470 "truncate": "Truncate string to specified length",
471 "join": "Join sequence elements with separator",
472 "replace": "Replace substring with another string",
473 "safe": "Mark string as safe HTML",
474 "escape": "Escape HTML characters",
475 "tojson": "Convert value to JSON string",
476 }
477 return docs.get(filter_name, f"Jinja2 {filter_name} filter")
479 def _get_function_documentation(self, func_name: str) -> str:
480 """Get documentation for a function."""
481 docs = {
482 "render_component": "Render HTMY component with context",
483 "get_adapter": "Get adapter instance by name",
484 "include_template": "Include template with context",
485 "url_for": "Generate URL for route",
486 "csrf_token": "Generate CSRF token",
487 "flash_messages": "Get flash messages",
488 "get_config": "Get configuration value",
489 "ph_duotone": "Generate duotone Phosphor icon",
490 "ph_interactive": "Generate interactive Phosphor icon",
491 "ph_button_icon": "Generate button with Phosphor icon",
492 "range": "Generate range of numbers",
493 "dict": "Create dictionary from arguments",
494 "lipsum": "Generate Lorem Ipsum text",
495 }
496 return docs.get(func_name, f"Jinja2 {func_name} function")
498 def _get_function_parameters(self, func_name: str) -> list[str]:
499 """Get function parameters."""
500 params = {
501 "render_component": ["component_name", "context={}"],
502 "get_adapter": ["adapter_name"],
503 "include_template": ["template_name", "**context"],
504 "url_for": ["route_name", "**params"],
505 "ph_duotone": ["icon_name", "primary_color=None", "secondary_color=None"],
506 "ph_interactive": ["icon_name", "variant='regular'", "action=None"],
507 "ph_button_icon": ["icon_name", "text=None", "variant='regular'"],
508 "range": ["start", "stop=None", "step=1"],
509 "dict": ["**items"],
510 }
511 return params.get(func_name, [])
513 def check_syntax(
514 self, content: str, template_path: Path | None = None
515 ) -> list[SyntaxError]:
516 """Check template syntax and return errors."""
517 if not self.settings or not self.settings.enable_error_checking:
518 return []
520 errors: list[SyntaxError] = []
521 lines = content.split("\n")
523 for line_num, line in enumerate(lines):
524 # Check delimiter balance
525 errors.extend(self._check_delimiter_balance(line, line_num))
527 # Check filter existence
528 if self.settings.check_filter_existence:
529 errors.extend(self._check_filter_existence(line, line_num))
531 # Check function calls
532 if self.settings.check_function_calls:
533 errors.extend(self._check_function_calls(line, line_num))
535 return errors
537 def _check_delimiter_balance(self, line: str, line_num: int) -> list[SyntaxError]:
538 """Check if delimiters are balanced."""
539 if not self.settings:
540 return []
542 errors = []
544 # Check variable delimiters
545 var_opens = line.count(self.settings.variable_start)
546 var_closes = line.count(self.settings.variable_end)
547 if var_opens != var_closes:
548 errors.append(
549 SyntaxError(
550 line=line_num,
551 column=0,
552 message=f"Unbalanced variable delimiters: {var_opens} opens, {var_closes} closes",
553 severity="error",
554 code="unbalanced_delimiters",
555 fix_suggestion=f"Add missing {self.settings.variable_end if var_opens > var_closes else self.settings.variable_start}",
556 )
557 )
559 # Check block delimiters
560 block_opens = line.count(self.settings.block_start)
561 block_closes = line.count(self.settings.block_end)
562 if block_opens != block_closes:
563 errors.append(
564 SyntaxError(
565 line=line_num,
566 column=0,
567 message=f"Unbalanced block delimiters: {block_opens} opens, {block_closes} closes",
568 severity="error",
569 code="unbalanced_delimiters",
570 fix_suggestion=f"Add missing {self.settings.block_end if block_opens > block_closes else self.settings.block_start}",
571 )
572 )
574 return errors
576 def _check_filter_existence(self, line: str, line_num: int) -> list[SyntaxError]:
577 """Check if filters exist."""
578 errors = []
580 if "filter" in self._syntax_patterns:
581 for match in self._syntax_patterns["filter"].finditer(line):
582 filter_name = match.group(1)
583 if filter_name not in self._builtin_filters:
584 errors.append(
585 SyntaxError(
586 line=line_num,
587 column=match.start(),
588 message=f"Unknown filter: {filter_name}",
589 severity="warning",
590 code="unknown_filter",
591 fix_suggestion="Check filter name or register custom filter",
592 )
593 )
595 return errors
597 def _check_function_calls(self, line: str, line_num: int) -> list[SyntaxError]:
598 """Check function calls."""
599 errors = []
601 if "function" in self._syntax_patterns:
602 for match in self._syntax_patterns["function"].finditer(line):
603 func_name = match.group(1)
604 if (
605 func_name not in self._builtin_functions
606 and not func_name.startswith("_")
607 ):
608 errors.append(
609 SyntaxError(
610 line=line_num,
611 column=match.start(),
612 message=f"Unknown function: {func_name}",
613 severity="warning",
614 code="unknown_function",
615 fix_suggestion="Check function name or import if custom",
616 )
617 )
619 return errors
621 def get_hover_info(self, content: str, line: int, column: int) -> str | None:
622 """Get hover information for symbol at position."""
623 lines = content.split("\n")
624 if line >= len(lines):
625 return None
627 current_line = lines[line]
629 # Find word at position
630 word_start = column
631 word_end = column
633 while word_start > 0 and current_line[word_start - 1].isalnum():
634 word_start -= 1
636 while word_end < len(current_line) and current_line[word_end].isalnum():
637 word_end += 1
639 word = current_line[word_start:word_end]
641 if word in self._builtin_filters:
642 return f"**Filter: {word}**\n\n{self._get_filter_documentation(word)}"
643 elif word in self._builtin_functions:
644 params = self._get_function_parameters(word)
645 param_str = ", ".join(params) if params else ""
646 return f"**Function: {word}({param_str})**\n\n{self._get_function_documentation(word)}"
648 return None
650 def format_template(self, content: str) -> str:
651 """Format template content."""
652 # Simple formatting - in a real implementation this would be more sophisticated
653 lines = content.split("\n")
654 formatted_lines = []
655 indent_level = 0
657 for line in lines:
658 stripped = line.strip()
660 # Decrease indent for end blocks
661 if stripped.startswith(("[% end", "[% else")):
662 indent_level = max(0, indent_level - 1)
664 # Add formatted line
665 if stripped:
666 formatted_lines.append(" " * indent_level + stripped)
667 else:
668 formatted_lines.append("")
670 # Increase indent for start blocks
671 if any(
672 stripped.startswith(f"[% {block}")
673 for block in ("if", "for", "block", "macro")
674 ):
675 indent_level += 1
676 elif stripped.startswith("[% else"):
677 indent_level += 1
679 return "\n".join(formatted_lines)
682# Template filter registration for FastBlocks
683def register_syntax_filters(env: Any) -> None:
684 """Register syntax support filters for Jinja2 templates."""
686 @env.filter("format_template") # type: ignore[misc]
687 def format_template_filter(content: str) -> str:
688 """Template filter for formatting FastBlocks templates."""
689 syntax_support = depends.get("syntax_support")
690 if isinstance(syntax_support, FastBlocksSyntaxSupport):
691 return syntax_support.format_template(content)
692 return content
694 @env.global_("syntax_check") # type: ignore[misc]
695 def syntax_check_global(content: str) -> list[dict[str, Any]]:
696 """Global function for syntax checking."""
697 syntax_support = depends.get("syntax_support")
698 if isinstance(syntax_support, FastBlocksSyntaxSupport):
699 errors = syntax_support.check_syntax(content)
700 return [
701 {
702 "line": error.line,
703 "column": error.column,
704 "message": error.message,
705 "severity": error.severity,
706 "code": error.code,
707 "fix": error.fix_suggestion,
708 }
709 for error in errors
710 ]
711 return []
714# ACB 0.19.0+ compatibility
715__all__ = [
716 "FastBlocksSyntaxSupport",
717 "FastBlocksSyntaxSettings",
718 "CompletionItem",
719 "SyntaxError",
720 "register_syntax_filters",
721]