Fixed Syntax validation and autocompletion. Fixed unit tests

This commit is contained in:
2026-02-15 18:32:34 +01:00
parent 789c06b842
commit 27f12b2c32
13 changed files with 532 additions and 640 deletions

View File

@@ -128,8 +128,8 @@ class BaseCompletionEngine(ABC):
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
context: The detected completion context (from the parser)
scope: The detected scope (table, column, row, cell...)
prefix: The current word prefix (for filtering)
Returns:

View File

@@ -0,0 +1,50 @@
"""
Common DSL exceptions shared across all DSL implementations.
"""
class DSLError(Exception):
"""Base exception for DSL errors."""
pass
class DSLSyntaxError(DSLError):
"""
Raised when a DSL input has syntax errors.
Attributes:
message: Error description
line: Line number where error occurred (1-based)
column: Column number where error occurred (1-based)
context: The problematic line or snippet
"""
def __init__(self, message: str, line: int = None, column: int = None, context: str = None):
self.message = message
self.line = line
self.column = column
self.context = context
super().__init__(self._format_message())
def _format_message(self) -> str:
parts = [self.message]
if self.line is not None:
parts.append(f"at line {self.line}")
if self.column is not None:
parts[1] = f"at line {self.line}, column {self.column}"
if self.context:
parts.append(f"\n {self.context}")
if self.column is not None:
parts.append(f"\n {' ' * (self.column - 1)}^")
return " ".join(parts[:2]) + "".join(parts[2:])
class DSLValidationError(DSLError):
"""
Raised when a DSL is syntactically correct but semantically invalid.
"""
def __init__(self, message: str, line: int = None):
self.message = message
self.line = line
super().__init__(f"{message}" + (f" at line {line}" if line else ""))

View File

@@ -1,55 +1,6 @@
"""
DSL-specific exceptions.
DSL-specific exceptions — re-exported from the common location.
"""
from myfasthtml.core.dsl.exceptions import DSLError, DSLSyntaxError, DSLValidationError
class DSLError(Exception):
"""Base exception for DSL errors."""
pass
class DSLSyntaxError(DSLError):
"""
Raised when the DSL input has syntax errors.
Attributes:
message: Error description
line: Line number where error occurred (1-based)
column: Column number where error occurred (1-based)
context: The problematic line or snippet
"""
def __init__(self, message: str, line: int = None, column: int = None, context: str = None):
self.message = message
self.line = line
self.column = column
self.context = context
super().__init__(self._format_message())
def _format_message(self) -> str:
parts = [self.message]
if self.line is not None:
parts.append(f"at line {self.line}")
if self.column is not None:
parts[1] = f"at line {self.line}, column {self.column}"
if self.context:
parts.append(f"\n {self.context}")
if self.column is not None:
parts.append(f"\n {' ' * (self.column - 1)}^")
return " ".join(parts[:2]) + "".join(parts[2:])
class DSLValidationError(DSLError):
"""
Raised when the DSL is syntactically correct but semantically invalid.
Examples:
- Unknown preset name
- Invalid parameter for formatter type
- Missing required parameter
"""
def __init__(self, message: str, line: int = None):
self.message = message
self.line = line
super().__init__(f"{message}" + (f" at line {line}" if line else ""))
__all__ = ["DSLError", "DSLSyntaxError", "DSLValidationError"]

View File

@@ -8,6 +8,7 @@ Provides context-aware suggestions for:
- Keywords: ``if``, ``else``, ``and``, ``or``, ``not``, ``WHERE``
"""
import re
from dataclasses import dataclass, field
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.types import Position, Suggestion
@@ -22,6 +23,21 @@ FORMULA_KEYWORDS = [
]
@dataclass
class FormulaContext:
"""
Completion context for a formula expression.
Attributes:
kind: One of ``"column_ref"``, ``"cross_table"``,
``"function_or_keyword"``, ``"general"``.
cross_table_name: Table name extracted from ``{TableName.`` syntax.
Only relevant when ``kind == "cross_table"``.
"""
kind: str
cross_table_name: str = field(default="")
class FormulaCompletionEngine(BaseCompletionEngine):
"""
Context-aware completion engine for formula expressions.
@@ -42,7 +58,7 @@ class FormulaCompletionEngine(BaseCompletionEngine):
"""Formula has no scope — always the same single-expression scope."""
return None
def detect_context(self, text: str, cursor: Position, scope):
def detect_context(self, text: str, cursor: Position, scope) -> FormulaContext:
"""
Detect completion context based on cursor position in formula text.
@@ -52,129 +68,80 @@ class FormulaCompletionEngine(BaseCompletionEngine):
scope: Unused (formulas have no scopes).
Returns:
Context string: ``"column_ref"``, ``"cross_table"``,
``"function"``, ``"keyword"``, or ``"general"``.
FormulaContext describing the kind of completion expected.
"""
# Get text up to cursor
lines = text.split("\n")
line_idx = min(cursor.line, len(lines) - 1)
line_text = lines[line_idx]
line_text = lines[min(cursor.line, len(lines) - 1)]
text_before = line_text[:cursor.ch]
# Check if we are inside a { ... } reference
last_brace = text_before.rfind("{")
if last_brace >= 0:
inside = text_before[last_brace + 1:]
if "}" not in inside:
if "." in inside:
return "cross_table"
return "column_ref"
cross_table_name = inside[:inside.rfind(".")]
return FormulaContext("cross_table", cross_table_name)
return FormulaContext("column_ref")
# Check if we are typing a function name (alphanumeric at word start)
word_match = re.search(r"[a-z_][a-z0-9_]*$", text_before, re.IGNORECASE)
if word_match:
return "function_or_keyword"
if re.search(r"[a-z_][a-z0-9_]*$", text_before, re.IGNORECASE):
return FormulaContext("function_or_keyword")
return "general"
return FormulaContext("general")
def get_suggestions(self, text: str, cursor: Position, scope, context) -> list:
def get_suggestions(self, context: FormulaContext, scope, prefix: str) -> list[Suggestion]:
"""
Generate suggestions based on the detected context.
Args:
text: The full formula text.
cursor: Cursor position.
scope: Unused.
context: String from ``detect_context``.
context: FormulaContext from ``detect_context``.
scope: Unused (formulas have no scopes).
prefix: Current word prefix (filtering handled by base class).
Returns:
List of Suggestion objects.
"""
suggestions = []
if context == "column_ref":
# Suggest columns from the current table
suggestions += self._column_suggestions(self.table_name)
elif context == "cross_table":
# Get the table name prefix from text_before
lines = text.split("\n")
line_text = lines[min(cursor.line, len(lines) - 1)]
text_before = line_text[:cursor.ch]
last_brace = text_before.rfind("{")
inside = text_before[last_brace + 1:] if last_brace >= 0 else ""
dot_pos = inside.rfind(".")
table_prefix = inside[:dot_pos] if dot_pos >= 0 else ""
match context.kind:
case "column_ref":
return self._column_suggestions(self.table_name)
# Suggest columns from the referenced table
if table_prefix:
suggestions += self._column_suggestions(table_prefix)
else:
suggestions += self._table_suggestions()
elif context == "function_or_keyword":
suggestions += self._function_suggestions()
suggestions += self._keyword_suggestions()
else: # general
suggestions += self._function_suggestions()
suggestions += self._keyword_suggestions()
suggestions += [
Suggestion(
label="{",
detail="Column reference",
insert_text="{",
)
]
return suggestions
case "cross_table":
return self._column_suggestions(context.cross_table_name)
case "function_or_keyword":
return self._function_suggestions() + self._keyword_suggestions()
case _: # general
return (
self._function_suggestions()
+ self._keyword_suggestions()
+ [Suggestion("{", "Column reference", "keyword")]
)
# ==================== Private helpers ====================
def _column_suggestions(self, table_name: str) -> list:
def _column_suggestions(self, table_name: str) -> list[Suggestion]:
"""Get column name suggestions for a table."""
try:
columns = self.provider.list_columns(table_name)
return [
Suggestion(
label=col,
detail=f"Column from {table_name}",
insert_text=col,
)
for col in (columns or [])
]
return [Suggestion(col, f"Column from {table_name}", "column") for col in (columns or [])]
except Exception:
return []
def _table_suggestions(self) -> list:
def _table_suggestions(self) -> list[Suggestion]:
"""Get table name suggestions."""
try:
tables = self.provider.list_tables()
return [
Suggestion(
label=t,
detail="Table",
insert_text=t,
)
for t in (tables or [])
]
return [Suggestion(t, "Table", "table") for t in (tables or [])]
except Exception:
return []
def _function_suggestions(self) -> list:
def _function_suggestions(self) -> list[Suggestion]:
"""Get built-in function name suggestions."""
return [
Suggestion(
label=name,
detail="Function",
insert_text=f"{name}(",
)
Suggestion(f"{name}(", name, "function")
for name in sorted(BUILTIN_FUNCTIONS.keys())
]
def _keyword_suggestions(self) -> list:
def _keyword_suggestions(self) -> list[Suggestion]:
"""Get keyword suggestions."""
return [
Suggestion(label=kw, detail="Keyword", insert_text=kw)
for kw in FORMULA_KEYWORDS
]
return [Suggestion(kw, "Keyword", "keyword") for kw in FORMULA_KEYWORDS]

View File

@@ -3,25 +3,6 @@ class FormulaError(Exception):
pass
class FormulaSyntaxError(FormulaError):
"""Raised when the formula has syntax errors."""
def __init__(self, message, line=None, column=None, context=None):
self.message = message
self.line = line
self.column = column
self.context = context
super().__init__(self._format_message())
def _format_message(self):
parts = [self.message]
if self.line is not None:
parts.append(f"at line {self.line}")
if self.column is not None:
parts.append(f"col {self.column}")
return " ".join(parts)
class FormulaValidationError(FormulaError):
"""Raised when the formula is syntactically correct but semantically invalid."""
pass
@@ -29,7 +10,7 @@ class FormulaValidationError(FormulaError):
class FormulaCycleError(FormulaError):
"""Raised when formula dependencies contain a cycle."""
def __init__(self, cycle_nodes):
self.cycle_nodes = cycle_nodes
super().__init__(f"Circular dependency detected involving: {', '.join(cycle_nodes)}")

View File

@@ -6,7 +6,7 @@ No indentation handling needed — formulas are single-line expressions.
"""
from lark import Lark, UnexpectedInput
from .exceptions import FormulaSyntaxError
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from .grammar import FORMULA_GRAMMAR
@@ -39,7 +39,7 @@ class FormulaParser:
lark.Tree: The parsed AST.
Raises:
FormulaSyntaxError: If the text has syntax errors.
DSLSyntaxError: If the text has syntax errors.
"""
text = text.strip()
if not text:
@@ -52,7 +52,7 @@ class FormulaParser:
if hasattr(e, "get_context"):
context = e.get_context(text)
raise FormulaSyntaxError(
raise DSLSyntaxError(
message=self._format_error_message(e),
line=getattr(e, "line", None),
column=getattr(e, "column", None),

View File

@@ -34,7 +34,7 @@ def parse_formula(text: str) -> FormulaDefinition | None:
FormulaDefinition on success, None if text is empty.
Raises:
FormulaSyntaxError: If the formula text is syntactically invalid.
DSLSyntaxError: If the formula text is syntactically invalid.
"""
text = text.strip() if text else ""
if not text:
@@ -80,7 +80,7 @@ class FormulaEngine:
formula_text: The formula expression string.
Raises:
FormulaSyntaxError: If the formula is syntactically invalid.
DSLSyntaxError: If the formula is syntactically invalid.
FormulaCycleError: If the formula would create a circular dependency.
"""
formula_text = formula_text.strip() if formula_text else ""

View File

@@ -12,7 +12,7 @@ from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.dsl.types import Position
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl import DSLSyntaxError
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app()