Fixed Syntax validation and autocompletion. Fixed unit tests
This commit is contained in:
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
50
src/myfasthtml/core/dsl/exceptions.py
Normal file
50
src/myfasthtml/core/dsl/exceptions.py
Normal 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 ""))
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user