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
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -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):
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(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 }
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",
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 }
139 self._builtin_functions = {
140 # Jinja2 built-in functions
141 "range", "lipsum", "dict", "cycler", "joiner", "namespace",
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 }
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()
162 # Find current context
163 current_line = content.split("\n")[line] if line < len(content.split("\n")) else ""
164 prefix = current_line[:column]
166 completions: list[CompletionItem] = []
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))
181 # Sort by priority and limit results
182 completions.sort(key=lambda x: (-x.priority, x.label))
183 return completions[:self.settings.max_completions]
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]
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]
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("|")
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]
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 ]
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 ]
291 def _get_filter_completions(self, prefix: str) -> list[CompletionItem]:
292 """Get completions for filter context."""
293 completions = []
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 ))
305 return completions
307 def _get_function_completions(self, prefix: str) -> list[CompletionItem]:
308 """Get completions for function context."""
309 completions = []
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)
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 ))
324 return completions
326 def _get_general_completions(self, prefix: str) -> list[CompletionItem]:
327 """Get general completions."""
328 if not self.settings:
329 self.settings = FastBlocksSyntaxSettings()
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 ]
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")
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")
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, [])
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 []
424 errors: list[SyntaxError] = []
425 lines = content.split("\n")
427 for line_num, line in enumerate(lines):
428 # Check delimiter balance
429 errors.extend(self._check_delimiter_balance(line, line_num))
431 # Check filter existence
432 if self.settings.check_filter_existence:
433 errors.extend(self._check_filter_existence(line, line_num))
435 # Check function calls
436 if self.settings.check_function_calls:
437 errors.extend(self._check_function_calls(line, line_num))
439 return errors
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 []
446 errors = []
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 ))
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 ))
474 return errors
476 def _check_filter_existence(self, line: str, line_num: int) -> list[SyntaxError]:
477 """Check if filters exist."""
478 errors = []
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 ))
493 return errors
495 def _check_function_calls(self, line: str, line_num: int) -> list[SyntaxError]:
496 """Check function calls."""
497 errors = []
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 ))
512 return errors
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
520 current_line = lines[line]
522 # Find word at position
523 word_start = column
524 word_end = column
526 while word_start > 0 and current_line[word_start - 1].isalnum():
527 word_start -= 1
529 while word_end < len(current_line) and current_line[word_end].isalnum():
530 word_end += 1
532 word = current_line[word_start:word_end]
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)}"
541 return None
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
550 for line in lines:
551 stripped = line.strip()
553 # Decrease indent for end blocks
554 if stripped.startswith("[% end") or stripped.startswith("[% else"):
555 indent_level = max(0, indent_level - 1)
557 # Add formatted line
558 if stripped:
559 formatted_lines.append(" " * indent_level + stripped)
560 else:
561 formatted_lines.append("")
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
569 return "\n".join(formatted_lines)
572# Template filter registration for FastBlocks
573def register_syntax_filters(env: Any) -> None:
574 """Register syntax support filters for Jinja2 templates."""
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
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 []
604# ACB 0.19.0+ compatibility
605__all__ = ["FastBlocksSyntaxSupport", "FastBlocksSyntaxSettings", "CompletionItem", "SyntaxError", "register_syntax_filters"]