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

@@ -10,6 +10,9 @@ from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import icons, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine
from myfasthtml.core.formula.dsl.parser import FormulaParser
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
@@ -37,11 +40,11 @@ class Commands(BaseCommands):
self._owner,
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
def update_column(self, col_id):
def save_column_details(self, col_id):
return Command(f"UpdateColumn",
f"Update column {col_id}",
self._owner,
self._owner.update_column,
self._owner.save_column_details,
kwargs={"col_id": col_id}
).htmx(target=f"#{self._id}", swap="innerHTML")
@@ -64,41 +67,36 @@ class DataGridColumnsManager(MultipleInstance):
self.commands = Commands(self)
self._new_column = False
conf = DslEditorConf(save_button=False, placeholder="{Column} * {OtherColumn}", line_numbers=False)
completion_engine = FormulaCompletionEngine(
self._parent._parent,
self._parent.get_table_name(),
)
conf = DslEditorConf(save_button=False, line_numbers=False, engine_id=completion_engine.get_id())
self._formula_editor = DataGridFormulaEditor(self, conf=conf, _id=f"{self._id}-formula-editor")
DslsManager.register(completion_engine, FormulaParser())
@property
def columns(self):
return self._parent.get_state().columns
def _get_col_def_from_col_id(self, col_id, updates=None, copy=True):
def _get_col_def_from_col_id(self, col_id, copy=True):
"""
Retrieves a column definition from the column ID, with optional updates
made using information provided by the client response.
:param col_id: The unique identifier of the column to retrieve.
:param updates: Optional dictionary containing updated values for
the column attributes provided by the client. If specified, the
attributes of the column definition will be updated accordingly.
:return: A copy of the column definition object with any updates applied,
or None if no column matching the provided ID is found.
:rtype: ColumnDefinition | None
"""
if updates:
updates["visible"] = "visible" in updates and updates["visible"] == "on"
cols_defs = [c for c in self.columns if c.col_id == col_id]
if not cols_defs:
col_def = DataGridColumnState(col_id, -1)
else:
col_def = cols_defs[0].copy() if copy else cols_defs[0]
return None
if updates:
return cols_defs[0].copy() if copy else cols_defs[0]
def _get_updated_col_def_from_col_id(self, col_id, updates=None, copy=True):
col_def = self._get_col_def_from_col_id(col_id, copy=copy)
if col_def is None:
col_def = DataGridColumnState(col_id, -1)
if updates is not None:
updates["visible"] = "visible" in updates and updates["visible"] == "on"
for k, v in [(k, v) for k, v in updates.items() if hasattr(col_def, k)]:
if k == "visible":
col_def.visible = v == "on"
elif k == "type":
if k == "type":
col_def.type = ColumnType(v)
elif k == "width":
col_def.width = int(v)
@@ -112,7 +110,7 @@ class DataGridColumnsManager(MultipleInstance):
def toggle_column(self, col_id):
logger.debug(f"toggle_column {col_id=}")
col_def = self._get_col_def_from_col_id(col_id)
col_def = self._get_col_def_from_col_id(col_id, copy=False)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
return Div(f"Column '{col_id}' not found")
@@ -123,7 +121,7 @@ class DataGridColumnsManager(MultipleInstance):
def show_column_details(self, col_id):
logger.debug(f"show_column_details {col_id=}")
col_def = self._get_col_def_from_col_id(col_id)
col_def = self._get_updated_col_def_from_col_id(col_id)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
return Div(f"Column '{col_id}' not found")
@@ -133,25 +131,21 @@ class DataGridColumnsManager(MultipleInstance):
def show_all_columns(self):
return self._mk_inner_content()
def update_column(self, col_id, client_response):
logger.debug(f"update_column {col_id=}, {client_response=}")
col_def = self._get_col_def_from_col_id(col_id, client_response, copy=False)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
else:
# save the new values
self._parent.save_state()
def save_column_details(self, col_id, client_response):
logger.debug(f"save_column_details {col_id=}, {client_response=}")
self._get_updated_col_def_from_col_id(col_id, client_response, copy=False)
self._parent.save_state()
return self._mk_inner_content()
def on_new_column(self):
self._new_column = True
col_def = self._get_col_def_from_col_id("__new__")
col_def = self._get_updated_col_def_from_col_id("__new__")
return self.mk_column_details(col_def)
def on_column_type_changed(self, col_id, client_response):
logger.debug(f"on_column_type_changed {col_id=}, {client_response=}")
col_def = self._get_col_def_from_col_id(col_id, client_response)
col_def = self._get_updated_col_def_from_col_id(col_id, client_response)
return self.mk_column_details(col_def)
def _register_formula(self, col_def) -> None:
@@ -240,7 +234,7 @@ class DataGridColumnsManager(MultipleInstance):
legend="Column details",
cls="fieldset border-base-300 rounded-box"
),
mk.dialog_buttons(on_ok=self.commands.update_column(col_def.col_id),
mk.dialog_buttons(on_ok=self.commands.save_column_details(col_def.col_id),
on_cancel=self.commands.show_all_columns()),
cls="mb-1",
),

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