Working on Formating DSL completion

This commit is contained in:
2026-01-31 19:09:14 +01:00
parent 778e5ac69d
commit d7ec99c3d9
77 changed files with 7563 additions and 63 deletions

View File

@@ -0,0 +1,442 @@
# Developer Control Mode
You are now in **Developer Control Mode** - specialized mode for developing UI controls in the MyFastHtml project.
## Primary Objective
Create robust, consistent UI controls by following the established patterns and rules of the project.
## Control Development Rules (DEV-CONTROL)
### DEV-CONTROL-01: Class Inheritance
A control must inherit from one of the three base classes based on its usage:
| Class | Usage | Example |
|-------|-------|---------|
| `MultipleInstance` | Multiple instances possible per session | `DataGrid`, `Panel`, `Search` |
| `SingleInstance` | One instance per session | `Layout`, `UserProfile`, `CommandsDebugger` |
| `UniqueInstance` | One instance, but `__init__` called each time | (special case) |
```python
from myfasthtml.core.instances import MultipleInstance
class MyControl(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
```
---
### DEV-CONTROL-02: Nested Commands Class
Each interactive control must define a `Commands` class inheriting from `BaseCommands`:
```python
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.core.commands import Command
class Commands(BaseCommands):
def my_action(self):
return Command("MyAction",
"Description of the action",
self._owner,
self._owner.my_action_handler
).htmx(target=f"#{self._id}")
```
**Conventions**:
- Method name in `snake_case`
- First `Command` argument: unique name (PascalCase recommended)
- Use `self._owner` to reference the parent control
- Use `self._id` for HTMX targets
---
### DEV-CONTROL-03: State Management with DbObject
Persistent state must be encapsulated in a class inheriting from `DbObject`:
```python
from myfasthtml.core.dbmanager import DbObject
class MyControlState(DbObject):
def __init__(self, owner, save_state=True):
with self.initializing():
super().__init__(owner, save_state=save_state)
# Persisted attributes
self.visible: bool = True
self.width: int = 250
# NOT persisted (ns_ prefix)
self.ns_temporary_data = None
# NOT saved but evaluated (ne_ prefix)
self.ne_computed_value = None
```
**Special prefixes**:
- `ns_` (no-save): not persisted to database
- `ne_` (no-equality): not compared for change detection
- `_`: internal variables, ignored
---
### DEV-CONTROL-04: render() and __ft__() Methods
Each control must implement:
```python
def render(self):
return Div(
# Control content
id=self._id,
cls="mf-my-control"
)
def __ft__(self):
return self.render()
```
**Rules**:
- `render()` contains the rendering logic
- `__ft__()` simply delegates to `render()`
- Root element must have `id=self._id`
---
### DEV-CONTROL-05: Control Initialization
Standard initialization structure:
```python
def __init__(self, parent, _id=None, **kwargs):
super().__init__(parent, _id=_id)
# 1. State
self._state = MyControlState(self)
# 2. Commands
self.commands = Commands(self)
# 3. Sub-components
self._panel = Panel(self, _id="-panel")
self._search = Search(self, _id="-search")
# 4. Command bindings
self._search.bind_command("Search", self.commands.on_search())
```
---
### DEV-CONTROL-06: Relative IDs for Sub-components
Use the `-` prefix to create IDs relative to the parent:
```python
# Results in: "{parent_id}-panel"
self._panel = Panel(self, _id="-panel")
# Results in: "{parent_id}-search"
self._search = Search(self, _id="-search")
```
---
### DEV-CONTROL-07: Using the mk Helper Class
Use `mk` helpers to create interactive elements:
```python
from myfasthtml.controls.helpers import mk
# Button with command
mk.button("Click me", command=self.commands.my_action())
# Icon with command and tooltip
mk.icon(my_icon, command=self.commands.toggle(), tooltip="Toggle")
# Label with icon
mk.label("Title", icon=my_icon, size="sm")
# Generic wrapper
mk.mk(Input(...), command=self.commands.on_input())
```
---
### DEV-CONTROL-08: Logging
Each control must declare a logger with its name:
```python
import logging
logger = logging.getLogger("MyControl")
class MyControl(MultipleInstance):
def my_action(self):
logger.debug(f"my_action called with {param=}")
```
---
### DEV-CONTROL-09: Command Binding Between Components
To link a sub-component's actions to the parent control:
```python
# In the parent control
self._child = ChildControl(self, _id="-child")
self._child.bind_command("ChildAction", self.commands.on_child_action())
```
---
### DEV-CONTROL-10: Keyboard and Mouse Composition
For interactive controls, compose `Keyboard` and `Mouse`:
```python
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
def render(self):
return Div(
self._mk_content(),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
id=self._id
)
```
---
### DEV-CONTROL-11: Partial Rendering
For HTMX updates, implement partial rendering methods:
```python
def render_partial(self, fragment="default"):
if fragment == "body":
return self._mk_body()
elif fragment == "header":
return self._mk_header()
return self._mk_default()
```
---
### DEV-CONTROL-12: Simple State (Non-Persisted)
For simple state without DB persistence, use a basic Python class:
```python
class MyControlState:
def __init__(self):
self.opened = False
self.selected = None
```
---
### DEV-CONTROL-13: Dataclasses for Configurations
Use dataclasses for configurations:
```python
from dataclasses import dataclass
from typing import Optional
@dataclass
class MyControlConf:
title: str = "Default"
show_header: bool = True
width: Optional[int] = None
```
---
### DEV-CONTROL-14: Generated ID Prefixes
Use short, meaningful prefixes for sub-elements:
```python
f"tb_{self._id}" # table body
f"th_{self._id}" # table header
f"sn_{self._id}" # sheet name
f"fi_{self._id}" # file input
```
---
### DEV-CONTROL-15: State Getters
Expose state via getter methods:
```python
def get_state(self):
return self._state
def get_selected(self):
return self._state.selected
```
---
### DEV-CONTROL-16: Computed Properties
Use `@property` for frequent access:
```python
@property
def width(self):
return self._state.width
```
---
### DEV-CONTROL-17: JavaScript Initialization Scripts
If the control requires JavaScript, include it in the render:
```python
from fasthtml.xtend import Script
def render(self):
return Div(
self._mk_content(),
Script(f"initMyControl('{self._id}');"),
id=self._id
)
```
---
### DEV-CONTROL-18: CSS Classes with Prefix
Use the `mf-` prefix for custom CSS classes:
```python
cls="mf-my-control"
cls="mf-my-control-header"
```
---
### DEV-CONTROL-19: Sub-element Creation Methods
Prefix creation methods with `_mk_` or `mk_`:
```python
def _mk_header(self):
"""Private creation method"""
return Div(...)
def mk_content(self):
"""Public creation method (reusable)"""
return Div(...)
```
---
## Complete Control Template
```python
import logging
from dataclasses import dataclass
from typing import Optional
from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("MyControl")
@dataclass
class MyControlConf:
title: str = "Default"
show_header: bool = True
class MyControlState(DbObject):
def __init__(self, owner, save_state=True):
with self.initializing():
super().__init__(owner, save_state=save_state)
self.visible: bool = True
self.ns_temp_data = None
class Commands(BaseCommands):
def toggle(self):
return Command("Toggle",
"Toggle visibility",
self._owner,
self._owner.toggle
).htmx(target=f"#{self._id}")
class MyControl(MultipleInstance):
def __init__(self, parent, conf: Optional[MyControlConf] = None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or MyControlConf()
self._state = MyControlState(self)
self.commands = Commands(self)
logger.debug(f"MyControl created with id={self._id}")
def toggle(self):
self._state.visible = not self._state.visible
return self
def _mk_header(self):
return Div(
mk.label(self.conf.title),
mk.icon(toggle_icon, command=self.commands.toggle()),
cls="mf-my-control-header"
)
def _mk_content(self):
if not self._state.visible:
return None
return Div("Content here", cls="mf-my-control-content")
def render(self):
return Div(
self._mk_header() if self.conf.show_header else None,
self._mk_content(),
Script(f"initMyControl('{self._id}');"),
id=self._id,
cls="mf-my-control"
)
def __ft__(self):
return self.render()
```
---
## Managing Rules
To disable a specific rule, the user can say:
- "Disable DEV-CONTROL-08" (do not apply the logging rule)
- "Enable DEV-CONTROL-08" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/developer` to switch to general development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/unit-tester` to switch to unit testing mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -237,6 +237,7 @@ For detailed architecture and patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/developer-control` to switch to control development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/unit-tester` to switch unit testing mode
- Use `/unit-tester` to switch to unit testing mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -10,4 +10,6 @@ Refer to CLAUDE.md for project-specific architecture and patterns.
You can switch to specialized modes:
- `/developer` - Full development mode with validation workflow
- `/developer-control` - Control development mode with DEV-CONTROL rules
- `/technical-writer` - User documentation writing mode
- `/unit-tester` - Unit testing mode

View File

@@ -337,5 +337,6 @@ For detailed architecture and component patterns, refer to `CLAUDE.md` in the pr
## Other Personas
- Use `/developer` to switch to development mode
- Use `/developer-control` to switch to control development mode
- Use `/unit-tester` to switch to unit testing mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -819,5 +819,6 @@ For detailed architecture and testing patterns, refer to CLAUDE.md in the projec
## Other Personas
- Use `/developer` to switch to development mode
- Use `/developer-control` to switch to control development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -108,6 +108,17 @@ Activates the full development workflow with:
- Strict PEP 8 compliance
- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns
### `/developer-control` - Control Development Mode
**Use for:** Developing UI controls in the controls directory
Specialized mode with rules for:
- Control class inheritance (`MultipleInstance`, `SingleInstance`, `UniqueInstance`)
- Commands class pattern with `BaseCommands`
- State management with `DbObject`
- Rendering with `render()` and `__ft__()`
- Helper usage (`mk.button`, `mk.icon`, `mk.label`)
- Sub-component composition
### `/technical-writer` - Documentation Mode
**Use for:** Writing user-facing documentation

View File

@@ -53,7 +53,7 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)
```
@@ -86,7 +86,7 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)
```
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@ dependencies = [
"uvloop",
"watchfiles",
"websockets",
"lark",
]
[project.urls]

View File

@@ -33,6 +33,7 @@ jaraco.context==6.0.1
jaraco.functools==4.3.0
jeepney==0.9.0
keyring==25.6.0
lark==1.3.1
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
@@ -80,6 +81,7 @@ soupsieve==2.8
starlette==0.48.0
twine==6.2.0
typer==0.20.0
types-pytz==2025.2.0.20251108
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.2

View File

@@ -17,6 +17,7 @@ from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p2 import key_command16_regular
@@ -38,22 +39,6 @@ app, rt = create_app(protect_routes=True,
base_url="http://localhost:5003")
class DataFrameHandler(BaseRefHandler):
def is_eligible_for(self, obj):
return isinstance(obj, pd.DataFrame)
def tag(self):
return "DataFrame"
def serialize_to_bytes(self, df) -> bytes:
from io import BytesIO
import pickle
return pickle.dumps(df)
def deserialize_from_bytes(self, data: bytes):
import pickle
return pickle.loads(data)
def create_sample_treeview(parent):
"""

View File

@@ -0,0 +1,11 @@
# Commands used
```
cd src/myfasthtml/assets
# codemirror version 5 . Attenntion the version number is the url is misleading !
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/placeholder.min.js
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2114,6 +2114,175 @@ function moveColumn(table, sourceColId, targetColId) {
}, ANIMATION_DURATION);
}
/**
* Initialize DslEditor with CodeMirror 5
*
* Features:
* - DSL-based autocompletion
* - Line numbers
* - Readonly support
* - Placeholder support
* - Textarea synchronization
* - Debounced HTMX server update via updateCommandId
*
* Required CodeMirror addons:
* - addon/hint/show-hint.js
* - addon/hint/show-hint.css
* - addon/display/placeholder.js
*
* Requires:
* - htmx loaded globally
*
* @param {Object} config
*/
function initDslEditor(config) {
const {
elementId,
textareaId,
lineNumbers,
autocompletion,
placeholder,
readonly,
updateCommandId,
dsl
} = config;
const wrapper = document.getElementById(elementId);
const textarea = document.getElementById(textareaId);
const editorContainer = document.getElementById(`cm_${elementId}`);
if (!wrapper || !textarea || !editorContainer) {
console.error(`DslEditor: Missing elements for ${elementId}`);
return;
}
if (typeof CodeMirror === "undefined") {
console.error("DslEditor: CodeMirror 5 not loaded");
return;
}
/* --------------------------------------------------
* Build completion list from DSL config
* -------------------------------------------------- */
const completionItems = [];
if (dsl && dsl.completions) {
const pushAll = (items) => {
if (!Array.isArray(items)) return;
items.forEach(item => completionItems.push(item));
};
pushAll(dsl.completions.keywords);
pushAll(dsl.completions.operators);
pushAll(dsl.completions.functions);
pushAll(dsl.completions.types);
pushAll(dsl.completions.literals);
}
/* --------------------------------------------------
* DSL autocompletion hint
* -------------------------------------------------- */
function dslHint(cm) {
const cursor = cm.getCursor();
const line = cm.getLine(cursor.line);
const ch = cursor.ch;
let start = ch;
while (start > 0 && /\w/.test(line.charAt(start - 1))) {
start--;
}
const word = line.slice(start, ch);
const matches = completionItems.filter(item =>
item.startsWith(word)
);
return {
list: matches,
from: CodeMirror.Pos(cursor.line, start),
to: CodeMirror.Pos(cursor.line, ch)
};
}
/* --------------------------------------------------
* Create CodeMirror editor
* -------------------------------------------------- */
const editor = CodeMirror(editorContainer, {
value: textarea.value || "",
lineNumbers: !!lineNumbers,
readOnly: !!readonly,
placeholder: placeholder || "",
extraKeys: autocompletion ? {
"Ctrl-Space": "autocomplete"
} : {},
hintOptions: autocompletion ? {
hint: dslHint,
completeSingle: false
} : undefined
});
/* --------------------------------------------------
* Debounced update + HTMX transport
* -------------------------------------------------- */
let debounceTimer = null;
const DEBOUNCE_DELAY = 300;
editor.on("change", function (cm) {
const value = cm.getValue();
textarea.value = value;
if (!updateCommandId) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
wrapper.dispatchEvent(
new CustomEvent("dsl-editor-update", {
detail: {
commandId: updateCommandId,
value: value
}
})
);
}, DEBOUNCE_DELAY);
});
/* --------------------------------------------------
* HTMX listener (LOCAL to wrapper)
* -------------------------------------------------- */
if (updateCommandId && typeof htmx !== "undefined") {
wrapper.addEventListener("dsl-editor-update", function (e) {
htmx.ajax("POST", "/myfasthtml/commands", {
target: wrapper,
swap: "none",
values: {
c_id: e.detail.commandId,
content: e.detail.value
}
});
});
}
/* --------------------------------------------------
* Public API
* -------------------------------------------------- */
wrapper._dslEditor = {
editor: editor,
getContent: () => editor.getValue(),
setContent: (content) => editor.setValue(content)
};
console.debug(`DslEditor initialized (CM5 + HTMX): ${elementId} with ${dsl?.name || "DSL"}`);
}
function updateDatagridSelection(datagridId) {
const selectionManager = document.getElementById(`tsm_${datagridId}`);
if (!selectionManager) return;

View File

@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(r){function n(e){e.state.placeholder&&(e.state.placeholder.parentNode.removeChild(e.state.placeholder),e.state.placeholder=null)}function i(e){n(e);var o=e.state.placeholder=document.createElement("pre"),t=(o.style.cssText="height: 0; overflow: visible",o.style.direction=e.getOption("direction"),o.className="CodeMirror-placeholder CodeMirror-line-like",e.getOption("placeholder"));"string"==typeof t&&(t=document.createTextNode(t)),o.appendChild(t),e.display.lineSpace.insertBefore(o,e.display.lineSpace.firstChild)}function l(e){c(e)&&i(e)}function a(e){var o=e.getWrapperElement(),t=c(e);o.className=o.className.replace(" CodeMirror-empty","")+(t?" CodeMirror-empty":""),(t?i:n)(e)}function c(e){return 1===e.lineCount()&&""===e.getLine(0)}r.defineOption("placeholder","",function(e,o,t){var t=t&&t!=r.Init;o&&!t?(e.on("blur",l),e.on("change",a),e.on("swapDoc",a),r.on(e.getInputField(),"compositionupdate",e.state.placeholderCompose=function(){var t;t=e,setTimeout(function(){var e,o=!1;((o=1==t.lineCount()?"TEXTAREA"==(e=t.getInputField()).nodeName?!t.getLine(0).length:!/[^\u200b]/.test(e.querySelector(".CodeMirror-line").textContent):o)?i:n)(t)},20)}),a(e)):!o&&t&&(e.off("blur",l),e.off("change",a),e.off("swapDoc",a),r.off(e.getInputField(),"compositionupdate",e.state.placeholderCompose),n(e),(t=e.getWrapperElement()).className=t.className.replace(" CodeMirror-empty","")),o&&!e.hasFocus()&&l(e)})});

View File

@@ -0,0 +1 @@
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import CommandsManager
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
from myfasthtml.core.vis_network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):

View File

@@ -14,6 +14,7 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
@@ -23,6 +24,7 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.formatting.dataclasses import FormatRule, Style, Condition, ConstantFormatter
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv
@@ -54,12 +56,13 @@ def _mk_bool_cached(_value):
class DatagridConf:
namespace: Optional[str] = None
name: Optional[str] = None
id: Optional[str] = None
class DatagridState(DbObject):
def __init__(self, owner, save_state):
with self.initializing():
super().__init__(owner, name=f"{owner.get_full_id()}#state", save_state=save_state)
super().__init__(owner, name=f"{owner.get_id()}#state", save_state=save_state)
self.sidebar_visible: bool = False
self.selected_view: str = None
self.row_index: bool = True
@@ -82,7 +85,7 @@ class DatagridState(DbObject):
class DatagridSettings(DbObject):
def __init__(self, owner, save_state, name, namespace):
with self.initializing():
super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state)
super().__init__(owner, name=f"{owner.get_id()}#settings", save_state=save_state)
self.save_state = save_state is True
self.namespace: Optional[str] = namespace
self.name: Optional[str] = name
@@ -146,11 +149,18 @@ class Commands(BaseCommands):
def toggle_columns_manager(self):
return Command("ToggleColumnsManager",
"Toggle Columns Manager",
"Hide/Show Columns Manager",
self._owner,
self._owner.toggle_columns_manager
).htmx(target=None)
def toggle_formatting_editor(self):
return Command("ToggleFormattingEditor",
"Hide/Show Formatting Editor",
self._owner,
self._owner.toggle_formatting_editor
).htmx(target=None)
def on_column_changed(self):
return Command("OnColumnChanged",
"Column definition changed",
@@ -170,9 +180,10 @@ class DataGrid(MultipleInstance):
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
# add Panel
self._panel = Panel(self, conf=PanelConf(right_title="Columns", show_display_right=False), _id="-panel")
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="-panel")
self._panel.set_side_visible("right", False) # the right Panel always starts closed
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
# add DataGridQuery
self._datagrid_filter = DataGridQuery(self)
@@ -195,6 +206,8 @@ class DataGrid(MultipleInstance):
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
self._formatting_editor = DslEditor(self, dsl=FormattingDSL())
# other definitions
self._mouse_support = {
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
@@ -254,12 +267,14 @@ class DataGrid(MultipleInstance):
return df
def _get_element_id_from_pos(self, selection_mode, pos):
# pos => (column, row)
if pos is None or pos == (None, None):
return None
elif selection_mode == "row":
return f"trow_{self._id}-{pos[0]}"
return f"trow_{self._id}-{pos[1]}"
elif selection_mode == "column":
return f"tcol_{self._id}-{pos[1]}"
return f"tcol_{self._id}-{pos[0]}"
else:
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
@@ -388,7 +403,7 @@ class DataGrid(MultipleInstance):
FormatRule(condition=Condition(operator="isnan"), formatter=ConstantFormatter(value="-")),
]
cell_id = self._get_element_id_from_pos("cell", (row_index, col_pos))
cell_id = self._get_element_id_from_pos("cell", (col_pos, row_index))
if cell_id in self._state.cell_formats:
return self._state.cell_formats[cell_id]
@@ -471,14 +486,23 @@ class DataGrid(MultipleInstance):
def toggle_columns_manager(self):
logger.debug(f"toggle_columns_manager")
self._panel.set_title(side="right", title="Columns")
self._panel.set_right(self._columns_manager)
def toggle_formatting_editor(self):
logger.debug(f"toggle_formatting_editor")
self._panel.set_title(side="right", title="Formatting")
self._panel.set_right(self._formatting_editor)
def save_state(self):
self._state.save()
def get_state(self):
return self._state
def get_settings(self):
return self._settings
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column()
@@ -590,7 +614,7 @@ class DataGrid(MultipleInstance):
data_col=col_def.col_id,
data_tooltip=str(value),
style=f"width:{col_def.width}px;",
id=self._get_element_id_from_pos("cell", (row_index, col_pos)),
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
cls="dt2-cell")
def mk_body_content_page(self, page_index: int):
@@ -776,7 +800,12 @@ class DataGrid(MultipleInstance):
Div(self._datagrid_filter,
Div(
self._selection_mode_selector,
mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
mk.icon(settings16_regular,
command=self.commands.toggle_columns_manager(),
tooltip="Show column manager"),
mk.icon(settings16_regular,
command=self.commands.toggle_formatting_editor(),
tooltip="Show formatting editor"),
cls="flex"),
cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table_wrapper()),
@@ -814,5 +843,17 @@ class DataGrid(MultipleInstance):
return tuple(res)
def dispose(self):
pass
def delete(self):
"""
remove DBEngine entries
:return:
"""
# self._state.delete()
# self._settings.delete()
pass
def __ft__(self):
return self.render()

View File

@@ -137,16 +137,28 @@ class DataGridColumnsManager(MultipleInstance):
value=col_def.col_id,
readonly=True),
Label("Title"),
Input(name="title",
cls=f"input input-{size}",
value=col_def.title),
Div(
Div(
Label("Visible"),
Input(name="visible",
type="checkbox",
cls=f"checkbox checkbox-{size}",
checked="true" if col_def.visible else None),
),
Div(
Label("Width"),
Input(name="width",
type="number",
cls=f"input input-{size}",
value=col_def.width),
),
cls="flex",
),
Label("Title"),
Input(name="title",
cls=f"input input-{size}",
value=col_def.title),
Label("type"),
Select(
@@ -156,12 +168,6 @@ class DataGridColumnsManager(MultipleInstance):
value=col_def.title,
),
Label("Width"),
Input(name="width",
type="number",
cls=f"input input-{size}",
value=col_def.width),
legend="Column details",
cls="fieldset border-base-300 rounded-box"
),

View File

@@ -11,10 +11,11 @@ from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.icons.fluent_p1 import table_add20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular
@@ -71,7 +72,7 @@ class Commands(BaseCommands):
key="SelectNode")
class DataGridsManager(MultipleInstance):
class DataGridsManager(SingleInstance):
def __init__(self, parent, _id=None):
if not getattr(self, "_is_new_instance", False):
@@ -83,6 +84,7 @@ class DataGridsManager(MultipleInstance):
self._tree = self._mk_tree()
self._tree.bind_command("SelectNode", self.commands.show_document())
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
self._registry = DataGridsRegistry(parent)
# Global presets shared across all DataGrids
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()

View File

@@ -0,0 +1,196 @@
"""
DslEditor control - A CodeMirror wrapper for DSL editing.
Provides syntax highlighting, line numbers, and autocompletion
for domain-specific languages defined with Lark grammars.
"""
import json
import logging
from dataclasses import dataclass
from typing import Optional
from fasthtml.common import Script
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dsl.base import DSLDefinition
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("DslEditor")
@dataclass
class DslEditorConf:
"""Configuration for DslEditor."""
line_numbers: bool = True
autocompletion: bool = True
placeholder: str = ""
readonly: bool = False
class DslEditorState:
"""Non-persisted state for DslEditor."""
def __init__(self):
self.content: str = ""
self.auto_save: bool = True
class Commands(BaseCommands):
"""Commands for DslEditor interactions."""
def update_content(self):
"""Command to update content from CodeMirror."""
return Command(
"UpdateContent",
"Update editor content",
self._owner,
self._owner.update_content,
).htmx(target=f"#{self._id}", swap="none")
def toggle_auto_save(self):
return Command("ToggleAutoSave",
"Toggle auto save",
self._owner,
self._owner.toggle_auto_save).htmx(target=f"#as_{self._id}", trigger="click")
def on_content_changed(self):
return Command("OnContentChanged",
"On content changed",
self._owner,
self._owner.on_content_changed
).htmx(target=None)
class DslEditor(MultipleInstance):
"""
CodeMirror wrapper for editing DSL code.
Provides:
- Syntax highlighting based on DSL grammar
- Line numbers
- Autocompletion from grammar keywords/operators
Args:
parent: Parent instance.
dsl: DSL definition providing grammar and completions.
conf: Editor configuration.
_id: Optional custom ID.
"""
def __init__(
self,
parent,
dsl: DSLDefinition,
conf: Optional[DslEditorConf] = None,
_id: Optional[str] = None,
):
super().__init__(parent, _id=_id)
self._dsl = dsl
self.conf = conf or DslEditorConf()
self._state = DslEditorState()
self.commands = Commands(self)
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
def set_content(self, content: str):
"""Set the editor content programmatically."""
self._state.content = content
return self
def get_content(self) -> str:
"""Get the current editor content."""
return self._state.content
def update_content(self, content: str = "") -> None:
"""Handler for content update from CodeMirror."""
self._state.content = content
if self._state.auto_save:
self.on_content_changed()
logger.debug(f"Content updated: {len(content)} chars")
def toggle_auto_save(self) -> None:
self._state.auto_save = not self._state.auto_save
self._mk_auto_save()
def on_content_changed(self) -> None:
pass
def _get_editor_config(self) -> dict:
"""Build the JavaScript configuration object."""
config = {
"elementId": str(self._id),
"textareaId": f"ta_{self._id}",
"lineNumbers": self.conf.line_numbers,
"autocompletion": self.conf.autocompletion,
"placeholder": self.conf.placeholder,
"readonly": self.conf.readonly,
"updateCommandId": str(self.commands.update_content().id),
"dsl": {
"name": self._dsl.name,
"completions": self._dsl.completions,
},
}
return config
def _mk_textarea(self):
"""Create the hidden textarea for form submission."""
return Textarea(
self._state.content,
id=f"ta_{self._id}",
name=f"ta_{self._id}",
cls="hidden",
)
def _mk_editor_container(self):
"""Create the container where CodeMirror will be mounted."""
return Div(
id=f"cm_{self._id}",
cls="mf-dsl-editor",
)
def _mk_init_script(self):
"""Create the initialization script."""
config = self._get_editor_config()
config_json = json.dumps(config)
return Script(f"initDslEditor({config_json});")
def _mk_auto_save(self):
return Div(
Label(
mk.mk(
Input(type="checkbox",
checked="on" if self._state.auto_save else None,
cls="toggle toggle-xs"),
command=self.commands.toggle_auto_save()
),
"Auto Save",
cls="text-xs",
),
mk.button("Save",
cls="btn btn-xs btn-primary",
disabled="disabled" if self._state.auto_save else None,
command=self.commands.update_content()),
cls="flex justify-between items-center p-2",
id=f"as_{self._id}",
),
def render(self):
"""Render the DslEditor component."""
return Div(
self._mk_auto_save(),
self._mk_textarea(),
self._mk_editor_container(),
self._mk_init_script(),
id=self._id,
cls="mf-dsl-editor-wrapper",
)
def __ft__(self):
"""FastHTML magic method for rendering."""
return self.render()

View File

@@ -3,7 +3,7 @@ from myfasthtml.controls.Properties import Properties
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
from myfasthtml.core.vis_network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance):

View File

@@ -147,6 +147,14 @@ class Panel(MultipleInstance):
self._left = left
return Div(self._left, id=self._ids.left)
def set_title(self, side, title):
if side == "left":
self.conf.left_title = title
else:
self.conf.right_title = title
return self._mk_panel(side)
def _mk_panel(self, side: Literal["left", "right"]):
enabled = self.conf.left if side == "left" else self.conf.right
if not enabled:

View File

@@ -30,7 +30,7 @@ class DatagridEditionState:
@dataclass
class DatagridSelectionState:
selected: tuple[int, int] | None = None
selected: tuple[int, int] | None = None # column first, then row
last_selected: tuple[int, int] | None = None
selection_mode: str = None # valid values are "row", "column" or None for "cell"
extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))

View File

@@ -0,0 +1,82 @@
from myfasthtml.core.dbmanager import DbManager
from myfasthtml.core.instances import SingleInstance
DATAGRIDS_REGISTRY_ENTRY_KEY = "DataGridsRegistryEntry"
class DataGridsRegistry(SingleInstance):
def __init__(self, parent):
super().__init__(parent)
self._db_manager = DbManager(parent)
# init the registry
if not self._db_manager.exists_entry(DATAGRIDS_REGISTRY_ENTRY_KEY):
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, {})
def put(self, namespace, name, datagrid_id):
"""
:param namespace:
:param name:
:param datagrid_id:
:return:
"""
all_entries = self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY)
all_entries[datagrid_id] = (namespace, name)
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
def get_all_tables(self):
all_entries = self._get_all_entries()
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
def get_columns(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load datagrid state
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
return [c.col_id for c in state["columns"]] if state else []
except KeyError:
return []
def get_column_values(self, table_name, column_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load dataframe
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
df = state["ne_df"] if state else None
return df[column_name].tolist() if df is not None else []
except KeyError:
return []
def get_row_count(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load dataframe
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
df = state["ne_df"] if state else None
return len(df) if df is not None else 0
except KeyError:
return 0
def _get_all_entries(self):
return {k: v for k, v in self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY).items()
if not k.startswith("__")}
def _get_entries_as_id_dict(self):
all_entries = self._get_all_entries()
return {_id: f"{namespace}.{name}" for _id, (namespace, name) in all_entries.items()}
def _get_entries_as_full_name_dict(self):
all_entries = self._get_all_entries()
return {f"{namespace}.{name}": _id for _id, (namespace, name) in all_entries.items()}

View File

@@ -0,0 +1,14 @@
class CompletionsManager:
completions = {}
@staticmethod
def register(engine):
CompletionsManager.completions[engine.get_id()] = engine
@staticmethod
def get_completions(engine_id):
return CompletionsManager.completions[engine_id]
@staticmethod
def reset():
CompletionsManager.completions = {}

View File

@@ -14,6 +14,7 @@ FILTER_INPUT_CID = "__filter_input__"
class Routes:
Commands = "/commands"
Bindings = "/bindings"
Completions = "/completions"
class ColumnType(Enum):

View File

@@ -0,0 +1,19 @@
import pandas as pd
from dbengine.handlers import BaseRefHandler
class DataFrameHandler(BaseRefHandler):
def is_eligible_for(self, obj):
return isinstance(obj, pd.DataFrame)
def tag(self):
return "DataFrame"
def serialize_to_bytes(self, df) -> bytes:
from io import BytesIO
import pickle
return pickle.dumps(df)
def deserialize_from_bytes(self, data: bytes):
import pickle
return pickle.loads(data)

View File

@@ -14,6 +14,7 @@ class DbManager(SingleInstance):
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(parent, auto_register=auto_register)
if not hasattr(self, "db"): # hack to manage singleton inheritance
self.db = DbEngine(root=root)
def save(self, entry, obj):

View File

View File

@@ -0,0 +1,84 @@
"""
Base class for DSL definitions.
DSLDefinition provides the interface for defining domain-specific languages
that can be used with the DslEditor control and CodeMirror.
"""
from abc import ABC, abstractmethod
from functools import cached_property
from typing import List, Dict, Any
from myfasthtml.core.dsl.lark_to_lezer import (
lark_to_lezer_grammar,
extract_completions_from_grammar,
)
class DSLDefinition(ABC):
"""
Base class for DSL definitions.
Subclasses must implement get_grammar() to provide the Lark grammar.
The Lezer grammar and completions are automatically derived.
Attributes:
name: Human-readable name of the DSL.
"""
name: str = "DSL"
@abstractmethod
def get_grammar(self) -> str:
"""
Return the Lark grammar string for this DSL.
Returns:
The Lark grammar as a string.
"""
pass
@cached_property
def lezer_grammar(self) -> str:
"""
Return the Lezer grammar derived from the Lark grammar.
This is cached after first computation.
Returns:
The Lezer grammar as a string.
"""
return lark_to_lezer_grammar(self.get_grammar())
@cached_property
def completions(self) -> Dict[str, List[str]]:
"""
Return completion items extracted from the grammar.
This is cached after first computation.
Returns:
Dictionary with completion categories:
- 'keywords': Language keywords (if, not, and, etc.)
- 'operators': Comparison and arithmetic operators
- 'functions': Function-like constructs (style, format, etc.)
- 'types': Type names (number, date, boolean, etc.)
- 'literals': Literal values (True, False, etc.)
"""
return extract_completions_from_grammar(self.get_grammar())
def get_editor_config(self) -> Dict[str, Any]:
"""
Return the configuration for the DslEditor JavaScript initialization.
Returns:
Dictionary with:
- 'lezerGrammar': The Lezer grammar string
- 'completions': The completion items
- 'name': The DSL name
"""
return {
"name": self.name,
"lezerGrammar": self.lezer_grammar,
"completions": self.completions,
}

View File

@@ -0,0 +1,172 @@
"""
Base completion engine for DSL autocompletion.
Provides an abstract base class that specific DSL implementations
can extend to provide context-aware autocompletion.
"""
from abc import ABC, abstractmethod
from typing import Any
from . import utils
from .base_provider import BaseMetadataProvider
from .types import Position, Suggestion, CompletionResult
class BaseCompletionEngine(ABC):
"""
Abstract base class for DSL completion engines.
Subclasses must implement:
- detect_scope(): Find the current scope from previous lines
- detect_context(): Determine what kind of completion is expected
- get_suggestions(): Generate suggestions for the detected context
The main entry point is get_completions(), which orchestrates the flow.
"""
def __init__(self, provider: BaseMetadataProvider):
"""
Initialize the completion engine.
Args:
provider: Metadata provider for context-aware suggestions
"""
self.provider = provider
def get_completions(self, text: str, cursor: Position) -> CompletionResult:
"""
Get autocompletion suggestions for the given cursor position.
This is the main entry point. It:
1. Checks if cursor is in a comment (no suggestions)
2. Detects the current scope (e.g., which column)
3. Detects the completion context (what kind of token is expected)
4. Generates and filters suggestions
Args:
text: The full DSL document text
cursor: Cursor position
Returns:
CompletionResult with suggestions and replacement range
"""
# Get the current line up to cursor
line = utils.get_line_at(text, cursor.line)
line_to_cursor = utils.get_line_up_to_cursor(text, cursor)
# Check if in comment - no suggestions
if utils.is_in_comment(line, cursor.ch):
return self._empty_result(cursor)
# Find word boundaries for replacement range
word_range = utils.find_word_boundaries(line, cursor.ch)
prefix = line[word_range.start: cursor.ch]
# Detect scope from previous lines
scope = self.detect_scope(text, cursor.line)
# Detect completion context
context = self.detect_context(text, cursor, scope)
# Get suggestions for this context
suggestions = self.get_suggestions(context, scope, prefix)
# Filter suggestions by prefix
if prefix:
suggestions = self._filter_suggestions(suggestions, prefix)
# Build result with correct positions
from_pos = Position(line=cursor.line, ch=word_range.start)
to_pos = Position(line=cursor.line, ch=word_range.end)
return CompletionResult(
from_pos=from_pos,
to_pos=to_pos,
suggestions=suggestions,
)
@abstractmethod
def detect_scope(self, text: str, current_line: int) -> Any:
"""
Detect the current scope by scanning previous lines.
The scope determines which data context we're in (e.g., which column
for column values suggestions).
Args:
text: The full document text
current_line: Current line number (0-based)
Returns:
Scope object (type depends on the specific DSL)
"""
pass
@abstractmethod
def detect_context(self, text: str, cursor: Position, scope: Any) -> Any:
"""
Detect the completion context at the cursor position.
Analyzes the current line to determine what kind of token
is expected (e.g., keyword, preset name, operator).
Args:
text: The full document text
cursor: Cursor position
scope: The detected scope
Returns:
Context identifier (type depends on the specific DSL)
"""
pass
@abstractmethod
def get_suggestions(self, context: Any, scope: Any, prefix: str) -> list[Suggestion]:
"""
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
prefix: The current word prefix (for filtering)
Returns:
List of suggestions
"""
pass
def _filter_suggestions(
self, suggestions: list[Suggestion], prefix: str
) -> list[Suggestion]:
"""
Filter suggestions by prefix (case-insensitive).
Args:
suggestions: List of suggestions
prefix: Prefix to filter by
Returns:
Filtered list of suggestions
"""
prefix_lower = prefix.lower()
return [s for s in suggestions if s.label.lower().startswith(prefix_lower)]
def _empty_result(self, cursor: Position) -> CompletionResult:
"""
Return an empty completion result.
Args:
cursor: Cursor position
Returns:
CompletionResult with no suggestions
"""
return CompletionResult(
from_pos=cursor,
to_pos=cursor,
suggestions=[],
)
def get_id(self):
return type(self).__name__

View File

@@ -0,0 +1,38 @@
"""
Base provider protocol for DSL autocompletion.
Defines the minimal interface that metadata providers must implement
to support context-aware autocompletion.
"""
from typing import Protocol
class BaseMetadataProvider(Protocol):
"""
Protocol defining the interface for metadata providers.
Metadata providers give the autocompletion engine access to
context-specific data (e.g., column names, available values).
This is a minimal interface. Specific DSL implementations
can extend this with additional methods.
"""
def get_style_presets(self) -> list[str]:
"""
Return the list of available style preset names.
Returns:
List of style preset names (e.g., ["primary", "error", "success"])
"""
...
def get_format_presets(self) -> list[str]:
"""
Return the list of available format preset names.
Returns:
List of format preset names (e.g., ["EUR", "USD", "percentage"])
"""
...

View File

@@ -0,0 +1,256 @@
"""
Utilities for converting Lark grammars to Lezer format and extracting completions.
This module provides functions to:
1. Transform a Lark grammar to a Lezer grammar for CodeMirror
2. Extract completion items (keywords, operators, etc.) from a Lark grammar
"""
import re
from typing import Dict, List, Set
def lark_to_lezer_grammar(lark_grammar: str) -> str:
"""
Convert a Lark grammar to a Lezer grammar.
This is a simplified converter that handles common Lark patterns.
Complex grammars may require manual adjustment.
Args:
lark_grammar: The Lark grammar string.
Returns:
The Lezer grammar string.
"""
lines = lark_grammar.strip().split("\n")
lezer_rules = []
tokens = []
for line in lines:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith("//") or line.startswith("#"):
continue
# Skip Lark-specific directives
if line.startswith("%"):
continue
# Parse rule definitions (lowercase names only)
rule_match = re.match(r"^([a-z_][a-z0-9_]*)\s*:\s*(.+)$", line)
if rule_match:
name, body = rule_match.groups()
lezer_rule = _convert_rule(name, body)
if lezer_rule:
lezer_rules.append(lezer_rule)
continue
# Parse terminal definitions (uppercase names)
terminal_match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*:\s*(.+)$", line)
if terminal_match:
name, pattern = terminal_match.groups()
token = _convert_terminal(name, pattern)
if token:
tokens.append(token)
# Build Lezer grammar
lezer_output = ["@top Start { scope+ }", ""]
# Add rules
for rule in lezer_rules:
lezer_output.append(rule)
lezer_output.append("")
lezer_output.append("@tokens {")
# Add tokens
for token in tokens:
lezer_output.append(f" {token}")
# Add common tokens
lezer_output.extend([
' whitespace { $[ \\t]+ }',
' newline { $[\\n\\r] }',
' Comment { "#" ![$\\n]* }',
])
lezer_output.append("}")
lezer_output.append("")
lezer_output.append("@skip { whitespace | Comment }")
return "\n".join(lezer_output)
def _convert_rule(name: str, body: str) -> str:
"""Convert a single Lark rule to Lezer format."""
# Skip internal rules (starting with _)
if name.startswith("_"):
return ""
# Convert rule name to PascalCase for Lezer
lezer_name = _to_pascal_case(name)
# Convert body
lezer_body = _convert_body(body)
if lezer_body:
return f"{lezer_name} {{ {lezer_body} }}"
return ""
def _convert_terminal(name: str, pattern: str) -> str:
"""Convert a Lark terminal to Lezer token format."""
pattern = pattern.strip()
# Handle regex patterns
if pattern.startswith("/") and pattern.endswith("/"):
regex = pattern[1:-1]
# Convert to Lezer regex format
return f'{name} {{ ${regex}$ }}'
# Handle string literals
if pattern.startswith('"') or pattern.startswith("'"):
return f'{name} {{ {pattern} }}'
# Handle alternatives (literal strings separated by |)
if "|" in pattern:
alternatives = [alt.strip() for alt in pattern.split("|")]
if all(alt.startswith('"') or alt.startswith("'") for alt in alternatives):
return f'{name} {{ {" | ".join(alternatives)} }}'
return ""
def _convert_body(body: str) -> str:
"""Convert the body of a Lark rule to Lezer format."""
# Remove inline transformations (-> name)
body = re.sub(r"\s*->\s*\w+", "", body)
# Convert alternatives
parts = []
for alt in body.split("|"):
alt = alt.strip()
if alt:
converted = _convert_sequence(alt)
if converted:
parts.append(converted)
return " | ".join(parts)
def _convert_sequence(seq: str) -> str:
"""Convert a sequence of items in a rule."""
items = []
# Tokenize the sequence
tokens = re.findall(
r'"[^"]*"|\'[^\']*\'|/[^/]+/|\([^)]+\)|\[[^\]]+\]|[a-zA-Z_][a-zA-Z0-9_]*|\?|\*|\+',
seq
)
for token in tokens:
if token.startswith('"') or token.startswith("'"):
# String literal
items.append(token)
elif token.startswith("("):
# Group
inner = token[1:-1]
items.append(f"({_convert_body(inner)})")
elif token.startswith("["):
# Optional group in Lark
inner = token[1:-1]
items.append(f"({_convert_body(inner)})?")
elif token in ("?", "*", "+"):
# Quantifiers - attach to previous item
if items:
items[-1] = items[-1] + token
elif token.isupper() or token.startswith("_"):
# Terminal reference
items.append(token)
elif token.islower() or "_" in token:
# Rule reference - convert to PascalCase
items.append(_to_pascal_case(token))
return " ".join(items)
def _to_pascal_case(name: str) -> str:
"""Convert snake_case to PascalCase."""
return "".join(word.capitalize() for word in name.split("_"))
def extract_completions_from_grammar(lark_grammar: str) -> Dict[str, List[str]]:
"""
Extract completion items from a Lark grammar.
Parses the grammar to find:
- Keywords (reserved words like if, not, and)
- Operators (==, !=, contains, etc.)
- Functions (style, format, etc.)
- Types (number, date, boolean, etc.)
- Literals (True, False, etc.)
Args:
lark_grammar: The Lark grammar string.
Returns:
Dictionary with completion categories.
"""
keywords: Set[str] = set()
operators: Set[str] = set()
functions: Set[str] = set()
types: Set[str] = set()
literals: Set[str] = set()
# Find all quoted strings (potential keywords/operators)
quoted_strings = re.findall(r'"([^"]+)"', lark_grammar)
# Also look for terminal definitions with string alternatives (e.g., BOOLEAN: "True" | "False")
terminal_literals = re.findall(r'[A-Z_]+:\s*"([^"]+)"(?:\s*\|\s*"([^"]+)")*', lark_grammar)
for match in terminal_literals:
for literal in match:
if literal:
quoted_strings.append(literal)
for s in quoted_strings:
s_lower = s.lower()
# Classify based on pattern
if s in ("==", "!=", "<=", "<", ">=", ">", "+", "-", "*", "/"):
operators.add(s)
elif s_lower in ("contains", "startswith", "endswith", "in", "between", "isempty", "isnotempty"):
operators.add(s_lower)
elif s_lower in ("if", "not", "and", "or"):
keywords.add(s_lower)
elif s_lower in ("true", "false"):
literals.add(s)
elif s_lower in ("style", "format"):
functions.add(s_lower)
elif s_lower in ("column", "row", "cell", "value", "col"):
keywords.add(s_lower)
elif s_lower in ("number", "date", "boolean", "text", "enum"):
types.add(s_lower)
elif s_lower == "case":
keywords.add(s_lower)
# Find function-like patterns: word "("
function_patterns = re.findall(r'"(\w+)"\s*"?\("', lark_grammar)
for func in function_patterns:
if func.lower() not in ("true", "false"):
functions.add(func.lower())
# Find type patterns from format_type rule
type_match = re.search(r'format_type\s*:\s*(.+?)(?:\n\n|\Z)', lark_grammar, re.DOTALL)
if type_match:
type_strings = re.findall(r'"(\w+)"', type_match.group(1))
types.update(t.lower() for t in type_strings)
return {
"keywords": sorted(keywords),
"operators": sorted(operators),
"functions": sorted(functions),
"types": sorted(types),
"literals": sorted(literals),
}

View File

@@ -0,0 +1,103 @@
"""
Base types for DSL autocompletion.
Provides dataclasses for cursor position, suggestions, and completion results
compatible with CodeMirror 5.
"""
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class Position:
"""
Cursor position in a document.
Compatible with CodeMirror 5 position format.
Attributes:
line: 0-based line number
ch: 0-based character position in the line
"""
line: int
ch: int
def to_dict(self) -> dict[str, int]:
"""Convert to CodeMirror-compatible dictionary."""
return {"line": self.line, "ch": self.ch}
@dataclass(frozen=True)
class Suggestion:
"""
A single autocompletion suggestion.
Attributes:
label: The text to display and insert
detail: Optional description shown next to the label
kind: Optional category (e.g., "keyword", "preset", "value")
"""
label: str
detail: str = ""
kind: str = ""
def to_dict(self) -> dict[str, str]:
"""Convert to dictionary for JSON serialization."""
result = {"label": self.label}
if self.detail:
result["detail"] = self.detail
if self.kind:
result["kind"] = self.kind
return result
@dataclass
class CompletionResult:
"""
Result of an autocompletion request.
Compatible with CodeMirror 5 hint format.
Attributes:
from_pos: Start position of the text to replace
to_pos: End position of the text to replace
suggestions: List of completion suggestions
"""
from_pos: Position
to_pos: Position
suggestions: list[Suggestion] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert to CodeMirror-compatible dictionary."""
return {
"from": self.from_pos.to_dict(),
"to": self.to_pos.to_dict(),
"suggestions": [s.to_dict() for s in self.suggestions],
}
@property
def is_empty(self) -> bool:
"""Return True if there are no suggestions."""
return len(self.suggestions) == 0
@dataclass(frozen=True)
class WordRange:
"""
Range of a word in a line.
Used for determining what text to replace when applying a suggestion.
Attributes:
start: Start character position (inclusive)
end: End character position (exclusive)
text: The word text
"""
start: int
end: int
text: str = ""

View File

@@ -0,0 +1,226 @@
"""
Shared utilities for DSL autocompletion.
Provides helper functions for text analysis, word boundary detection,
and other common operations used by completion engines.
"""
from .types import Position, WordRange
# Delimiters used to detect word boundaries
DELIMITERS = set('"\' ()[]{}=,:<>!\t\n\r')
def get_line_at(text: str, line_number: int) -> str:
"""
Get the content of a specific line.
Args:
text: The full document text
line_number: 0-based line number
Returns:
The line content, or empty string if line doesn't exist
"""
lines = text.split("\n")
if 0 <= line_number < len(lines):
return lines[line_number]
return ""
def get_line_up_to_cursor(text: str, cursor: Position) -> str:
"""
Get the content of the current line up to the cursor position.
Args:
text: The full document text
cursor: Cursor position
Returns:
The line content from start to cursor position
"""
line = get_line_at(text, cursor.line)
return line[: cursor.ch]
def get_lines_up_to(text: str, line_number: int) -> list[str]:
"""
Get all lines from start up to and including the specified line.
Args:
text: The full document text
line_number: 0-based line number (inclusive)
Returns:
List of lines from 0 to line_number
"""
lines = text.split("\n")
return lines[: line_number + 1]
def find_word_boundaries(line: str, cursor_ch: int) -> WordRange:
"""
Find the word boundaries around the cursor position.
Uses delimiters to detect where a word starts and ends.
The cursor can be anywhere within the word.
Args:
line: The line content
cursor_ch: Cursor character position in the line
Returns:
WordRange with start, end positions and the word text
"""
if not line or cursor_ch < 0:
return WordRange(start=cursor_ch, end=cursor_ch, text="")
# Clamp cursor position to line length
cursor_ch = min(cursor_ch, len(line))
# Find start of word (scan backwards from cursor)
start = cursor_ch
while start > 0 and line[start - 1] not in DELIMITERS:
start -= 1
# Find end of word (scan forwards from cursor)
end = cursor_ch
while end < len(line) and line[end] not in DELIMITERS:
end += 1
word = line[start:end]
return WordRange(start=start, end=end, text=word)
def get_prefix(line: str, cursor_ch: int) -> str:
"""
Get the word prefix before the cursor.
This is the text from the start of the current word to the cursor.
Args:
line: The line content
cursor_ch: Cursor character position in the line
Returns:
The prefix text
"""
word_range = find_word_boundaries(line, cursor_ch)
# Prefix is from word start to cursor
return line[word_range.start: cursor_ch]
def is_in_comment(line: str, cursor_ch: int) -> bool:
"""
Check if the cursor is inside a comment.
A comment starts with # and extends to the end of the line.
Args:
line: The line content
cursor_ch: Cursor character position in the line
Returns:
True if cursor is after a # character
"""
# Find first # that's not inside a string
in_string = False
string_char = None
for i, char in enumerate(line):
if i >= cursor_ch:
break
if char in ('"', "'") and (i == 0 or line[i - 1] != "\\"):
if not in_string:
in_string = True
string_char = char
elif char == string_char:
in_string = False
string_char = None
elif char == "#" and not in_string:
return True
return False
def is_in_string(line: str, cursor_ch: int) -> tuple[bool, str | None]:
"""
Check if the cursor is inside a string literal.
Args:
line: The line content
cursor_ch: Cursor character position in the line
Returns:
Tuple of (is_in_string, quote_char)
quote_char is '"' or "'" if inside a string, None otherwise
"""
in_string = False
string_char = None
for i, char in enumerate(line):
if i >= cursor_ch:
break
if char in ('"', "'") and (i == 0 or line[i - 1] != "\\"):
if not in_string:
in_string = True
string_char = char
elif char == string_char:
in_string = False
string_char = None
return in_string, string_char if in_string else None
def get_indentation(line: str) -> int:
"""
Get the indentation level of a line.
Counts leading spaces (tabs are converted to 4 spaces).
Args:
line: The line content
Returns:
Number of leading spaces
"""
count = 0
for char in line:
if char == " ":
count += 1
elif char == "\t":
count += 4
else:
break
return count
def is_indented(line: str) -> bool:
"""
Check if a line is indented (has leading whitespace).
Args:
line: The line content
Returns:
True if line starts with whitespace
"""
return len(line) > 0 and line[0] in (" ", "\t")
def strip_quotes(text: str) -> str:
"""
Remove surrounding quotes from a string.
Args:
text: Text that may be quoted
Returns:
Text without surrounding quotes
"""
if len(text) >= 2:
if (text[0] == '"' and text[-1] == '"') or (text[0] == "'" and text[-1] == "'"):
return text[1:-1]
return text

View File

@@ -0,0 +1,69 @@
"""
DataGrid Formatting DSL Module.
This module provides a Domain Specific Language (DSL) for defining
formatting rules in the DataGrid component.
Example:
from myfasthtml.core.formatting.dsl import parse_dsl
rules = parse_dsl('''
column amount:
style("error") if value < 0
format("EUR")
column status:
style("success") if value == "approved"
style("warning") if value == "pending"
''')
for scoped_rule in rules:
print(f"Scope: {scoped_rule.scope}")
print(f"Rule: {scoped_rule.rule}")
"""
from .parser import get_parser
from .transformer import DSLTransformer
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .exceptions import DSLError, DSLSyntaxError, DSLValidationError
def parse_dsl(text: str) -> list[ScopedRule]:
"""
Parse DSL text into a list of ScopedRule objects.
Args:
text: The DSL text to parse
Returns:
List of ScopedRule objects, each containing a scope and a FormatRule
Raises:
DSLSyntaxError: If the text has syntax errors
DSLValidationError: If the text is syntactically correct but semantically invalid
Example:
rules = parse_dsl('''
column price:
style("error") if value < 0
format("EUR", precision=2)
''')
"""
parser = get_parser()
tree = parser.parse(text)
transformer = DSLTransformer()
return transformer.transform(tree)
__all__ = [
# Main API
"parse_dsl",
# Scope classes
"ColumnScope",
"RowScope",
"CellScope",
"ScopedRule",
# Exceptions
"DSLError",
"DSLSyntaxError",
"DSLValidationError",
]

View File

@@ -0,0 +1,323 @@
"""
Completion contexts for the formatting DSL.
Defines the Context enum and detection logic to determine
what kind of autocompletion suggestions are appropriate.
"""
import re
from dataclasses import dataclass
from enum import Enum, auto
from myfasthtml.core.dsl import utils
from myfasthtml.core.dsl.types import Position
class Context(Enum):
"""
Autocompletion context identifiers.
Each context corresponds to a specific position in the DSL
where certain types of suggestions are appropriate.
"""
# No suggestions (e.g., in comment)
NONE = auto()
# Scope-level contexts
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell
COLUMN_NAME = auto() # After "column ": column names
ROW_INDEX = auto() # After "row ": row indices
CELL_START = auto() # After "cell ": (
CELL_COLUMN = auto() # After "cell (": column names
CELL_ROW = auto() # After "cell (col, ": row indices
# Rule-level contexts
RULE_START = auto() # Start of indented line: style(, format(, format.
# Style contexts
STYLE_ARGS = auto() # After "style(": presets + params
STYLE_PRESET = auto() # Inside style("): preset names
STYLE_PARAM = auto() # After comma in style(): params
# Format contexts
FORMAT_PRESET = auto() # Inside format("): preset names
FORMAT_TYPE = auto() # After "format.": number, date, etc.
FORMAT_PARAM_DATE = auto() # Inside format.date(): format=
FORMAT_PARAM_TEXT = auto() # Inside format.text(): transform=, etc.
# After style/format
AFTER_STYLE_OR_FORMAT = auto() # After ")": style(, format(, if
# Condition contexts
CONDITION_START = auto() # After "if ": value, col., not
CONDITION_AFTER_NOT = auto() # After "if not ": value, col.
COLUMN_REF = auto() # After "col.": column names
COLUMN_REF_QUOTED = auto() # After 'col."': column names with quote
# Operator contexts
OPERATOR = auto() # After operand: ==, <, contains, etc.
OPERATOR_VALUE = auto() # After operator: col., True, False, values
BETWEEN_AND = auto() # After "between X ": and
BETWEEN_VALUE = auto() # After "between X and ": values
IN_LIST_START = auto() # After "in ": [
IN_LIST_VALUE = auto() # Inside [ or after ,: values
# Value contexts
BOOLEAN_VALUE = auto() # After "bold=": True, False
COLOR_VALUE = auto() # After "color=": colors
DATE_FORMAT_VALUE = auto() # After "format=" in format.date: patterns
TRANSFORM_VALUE = auto() # After "transform=": uppercase, etc.
@dataclass
class DetectedScope:
"""
Represents the detected scope from scanning previous lines.
Attributes:
scope_type: "column", "row", "cell", or None
column_name: Column name (for column and cell scopes)
row_index: Row index (for row and cell scopes)
table_name: DataGrid name (if determinable)
"""
scope_type: str | None = None
column_name: str | None = None
row_index: int | None = None
table_name: str | None = None
def detect_scope(text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning backwards from the cursor line.
Looks for the most recent scope declaration (column/row/cell)
that is not indented.
Args:
text: The full document text
current_line: Current line number (0-based)
Returns:
DetectedScope with scope information
"""
lines = text.split("\n")
# Scan backwards from current line
for i in range(current_line, -1, -1):
if i >= len(lines):
continue
line = lines[i]
# Skip empty lines and indented lines
if not line.strip() or utils.is_indented(line):
continue
# Check for column scope
match = re.match(r'^column\s+(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s*:', line)
if match:
column_name = match.group(1) or match.group(2)
return DetectedScope(scope_type="column", column_name=column_name)
# Check for row scope
match = re.match(r"^row\s+(\d+)\s*:", line)
if match:
row_index = int(match.group(1))
return DetectedScope(scope_type="row", row_index=row_index)
# Check for cell scope
match = re.match(
r'^cell\s+\(\s*(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s*,\s*(\d+)\s*\)\s*:',
line,
)
if match:
column_name = match.group(1) or match.group(2)
row_index = int(match.group(3))
return DetectedScope(
scope_type="cell", column_name=column_name, row_index=row_index
)
return DetectedScope()
def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context:
"""
Detect the completion context at the cursor position.
Analyzes the current line up to the cursor to determine
what kind of token is expected.
Args:
text: The full document text
cursor: Cursor position
scope: The detected scope
Returns:
Context enum value
"""
line = utils.get_line_at(text, cursor.line)
line_to_cursor = line[: cursor.ch]
# Check if in comment
if utils.is_in_comment(line, cursor.ch):
return Context.NONE
# Check if line is indented (inside a scope)
is_indented = utils.is_indented(line)
# =========================================================================
# Non-indented line contexts (scope definitions)
# =========================================================================
if not is_indented:
# After "column "
if re.match(r"^column\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
return Context.COLUMN_NAME
# After "row "
if re.match(r"^row\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
return Context.ROW_INDEX
# After "cell "
if re.match(r"^cell\s+$", line_to_cursor):
return Context.CELL_START
# After "cell ("
if re.match(r"^cell\s+\(\s*$", line_to_cursor):
return Context.CELL_COLUMN
# After "cell (col, " or "cell ("col", "
if re.match(r'^cell\s+\(\s*(?:"[^"]*"|[a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*$', line_to_cursor):
return Context.CELL_ROW
# Start of line or partial keyword
return Context.SCOPE_KEYWORD
# =========================================================================
# Indented line contexts (rules inside a scope)
# =========================================================================
stripped = line_to_cursor.strip()
# Empty indented line - rule start
if not stripped:
return Context.RULE_START
# -------------------------------------------------------------------------
# Style contexts
# -------------------------------------------------------------------------
# Inside style(" - preset
if re.search(r'style\s*\(\s*"[^"]*$', line_to_cursor):
return Context.STYLE_PRESET
# After style( without quote - args (preset or params)
if re.search(r"style\s*\(\s*$", line_to_cursor):
return Context.STYLE_ARGS
# After comma in style() - params
if re.search(r"style\s*\([^)]*,\s*$", line_to_cursor):
return Context.STYLE_PARAM
# After param= in style - check which param
if re.search(r"style\s*\([^)]*(?:bold|italic|underline|strikethrough)\s*=\s*$", line_to_cursor):
return Context.BOOLEAN_VALUE
if re.search(r"style\s*\([^)]*(?:color|background_color)\s*=\s*$", line_to_cursor):
return Context.COLOR_VALUE
# -------------------------------------------------------------------------
# Format contexts
# -------------------------------------------------------------------------
# After "format." - type
if re.search(r"format\s*\.\s*$", line_to_cursor):
return Context.FORMAT_TYPE
# Inside format(" - preset
if re.search(r'format\s*\(\s*"[^"]*$', line_to_cursor):
return Context.FORMAT_PRESET
# Inside format.date( - params
if re.search(r"format\s*\.\s*date\s*\(\s*$", line_to_cursor):
return Context.FORMAT_PARAM_DATE
# After format= in format.date
if re.search(r"format\s*\.\s*date\s*\([^)]*format\s*=\s*$", line_to_cursor):
return Context.DATE_FORMAT_VALUE
# Inside format.text( - params
if re.search(r"format\s*\.\s*text\s*\(\s*$", line_to_cursor):
return Context.FORMAT_PARAM_TEXT
# After transform= in format.text
if re.search(r"format\s*\.\s*text\s*\([^)]*transform\s*=\s*$", line_to_cursor):
return Context.TRANSFORM_VALUE
# -------------------------------------------------------------------------
# After style/format - if or more style/format
# -------------------------------------------------------------------------
# After closing ) of style or format
if re.search(r"\)\s*$", line_to_cursor):
# Check if there's already an "if" on this line
if " if " not in line_to_cursor:
return Context.AFTER_STYLE_OR_FORMAT
# -------------------------------------------------------------------------
# Condition contexts
# -------------------------------------------------------------------------
# After "if not "
if re.search(r"\bif\s+not\s+$", line_to_cursor):
return Context.CONDITION_AFTER_NOT
# After "if "
if re.search(r"\bif\s+$", line_to_cursor):
return Context.CONDITION_START
# After "col." - column reference
if re.search(r'\bcol\s*\.\s*"$', line_to_cursor):
return Context.COLUMN_REF_QUOTED
if re.search(r"\bcol\s*\.\s*$", line_to_cursor):
return Context.COLUMN_REF
# After "between X and " - value
if re.search(r"\bbetween\s+\S+\s+and\s+$", line_to_cursor):
return Context.BETWEEN_VALUE
# After "between X " - and
if re.search(r"\bbetween\s+\S+\s+$", line_to_cursor):
return Context.BETWEEN_AND
# After "in [" or "in [...," - list value
if re.search(r"\bin\s+\[[^\]]*,\s*$", line_to_cursor):
return Context.IN_LIST_VALUE
if re.search(r"\bin\s+\[\s*$", line_to_cursor):
return Context.IN_LIST_VALUE
# After "in " - list start
if re.search(r"\bin\s+$", line_to_cursor):
return Context.IN_LIST_START
# After operator - value
if re.search(r"(?:==|!=|<=?|>=?|contains|startswith|endswith)\s+$", line_to_cursor):
return Context.OPERATOR_VALUE
# After operand (value, col.xxx, literal) - operator
if re.search(r"(?:value|col\.[a-zA-Z_][a-zA-Z0-9_]*|\d+|\"[^\"]*\"|True|False)\s+$", line_to_cursor):
return Context.OPERATOR
# -------------------------------------------------------------------------
# Fallback - rule start for partial input
# -------------------------------------------------------------------------
# If we're at the start of typing something
if re.match(r"^\s*[a-zA-Z]*$", line_to_cursor):
return Context.RULE_START
return Context.NONE

View File

@@ -0,0 +1,109 @@
"""
Completion engine for the formatting DSL.
Implements the BaseCompletionEngine for DataGrid formatting rules.
"""
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from . import suggestions as suggestions_module
from .contexts import Context, DetectedScope, detect_scope, detect_context
from .provider import DatagridMetadataProvider
class FormattingCompletionEngine(BaseCompletionEngine):
"""
Autocompletion engine for the DataGrid Formatting DSL.
Provides context-aware suggestions for:
- Scope definitions (column, row, cell)
- Style expressions with presets and parameters
- Format expressions with presets and types
- Conditions with operators and values
"""
def __init__(self, provider: DatagridMetadataProvider):
"""
Initialize the completion engine.
Args:
provider: DataGrid metadata provider for dynamic suggestions
"""
super().__init__(provider)
self.provider: DatagridMetadataProvider = provider
def detect_scope(self, text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning previous lines.
Looks for the most recent scope declaration (column/row/cell).
Args:
text: The full document text
current_line: Current line number (0-based)
Returns:
DetectedScope with scope information
"""
return detect_scope(text, current_line)
def detect_context(
self, text: str, cursor: Position, scope: DetectedScope
) -> Context:
"""
Detect the completion context at the cursor position.
Args:
text: The full document text
cursor: Cursor position
scope: The detected scope
Returns:
Context enum value
"""
return detect_context(text, cursor, scope)
def get_suggestions(
self, context: Context, scope: DetectedScope, prefix: str
) -> list[Suggestion]:
"""
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
prefix: The current word prefix (not used here, filtering done in base)
Returns:
List of suggestions
"""
return suggestions_module.get_suggestions(context, scope, self.provider)
def get_completions(
text: str,
cursor: Position,
provider: DatagridMetadataProvider,
) -> CompletionResult:
"""
Get autocompletion suggestions for the formatting DSL.
This is the main entry point for the autocompletion API.
Args:
text: The full DSL document text
cursor: Cursor position (line and ch are 0-based)
provider: DataGrid metadata provider
Returns:
CompletionResult with suggestions and replacement range
Example:
result = get_completions(
text='column amount:\\n style("err',
cursor=Position(line=1, ch=15),
provider=my_provider
)
# result.suggestions contains ["error"] filtered by prefix "err"
"""
engine = FormattingCompletionEngine(provider)
return engine.get_completions(text, cursor)

View File

@@ -0,0 +1,245 @@
"""
Static data for formatting DSL autocompletion.
Contains predefined values for style presets, colors, date patterns, etc.
"""
from myfasthtml.core.dsl.types import Suggestion
# =============================================================================
# Style Presets (DaisyUI 5)
# =============================================================================
STYLE_PRESETS: list[Suggestion] = [
Suggestion("primary", "Primary theme color", "preset"),
Suggestion("secondary", "Secondary theme color", "preset"),
Suggestion("accent", "Accent theme color", "preset"),
Suggestion("neutral", "Neutral theme color", "preset"),
Suggestion("info", "Info (blue)", "preset"),
Suggestion("success", "Success (green)", "preset"),
Suggestion("warning", "Warning (yellow)", "preset"),
Suggestion("error", "Error (red)", "preset"),
]
# =============================================================================
# Format Presets
# =============================================================================
FORMAT_PRESETS: list[Suggestion] = [
Suggestion("EUR", "Euro currency (1 234,56 €)", "preset"),
Suggestion("USD", "US Dollar ($1,234.56)", "preset"),
Suggestion("percentage", "Percentage (×100, adds %)", "preset"),
Suggestion("short_date", "DD/MM/YYYY", "preset"),
Suggestion("iso_date", "YYYY-MM-DD", "preset"),
Suggestion("yes_no", "Yes/No", "preset"),
]
# =============================================================================
# CSS Colors
# =============================================================================
CSS_COLORS: list[Suggestion] = [
Suggestion("red", "Red", "color"),
Suggestion("blue", "Blue", "color"),
Suggestion("green", "Green", "color"),
Suggestion("yellow", "Yellow", "color"),
Suggestion("orange", "Orange", "color"),
Suggestion("purple", "Purple", "color"),
Suggestion("pink", "Pink", "color"),
Suggestion("gray", "Gray", "color"),
Suggestion("black", "Black", "color"),
Suggestion("white", "White", "color"),
]
# =============================================================================
# DaisyUI Color Variables
# =============================================================================
DAISYUI_COLORS: list[Suggestion] = [
Suggestion("var(--color-primary)", "Primary color", "variable"),
Suggestion("var(--color-primary-content)", "Primary content color", "variable"),
Suggestion("var(--color-secondary)", "Secondary color", "variable"),
Suggestion("var(--color-secondary-content)", "Secondary content color", "variable"),
Suggestion("var(--color-accent)", "Accent color", "variable"),
Suggestion("var(--color-accent-content)", "Accent content color", "variable"),
Suggestion("var(--color-neutral)", "Neutral color", "variable"),
Suggestion("var(--color-neutral-content)", "Neutral content color", "variable"),
Suggestion("var(--color-info)", "Info color", "variable"),
Suggestion("var(--color-info-content)", "Info content color", "variable"),
Suggestion("var(--color-success)", "Success color", "variable"),
Suggestion("var(--color-success-content)", "Success content color", "variable"),
Suggestion("var(--color-warning)", "Warning color", "variable"),
Suggestion("var(--color-warning-content)", "Warning content color", "variable"),
Suggestion("var(--color-error)", "Error color", "variable"),
Suggestion("var(--color-error-content)", "Error content color", "variable"),
Suggestion("var(--color-base-100)", "Base 100", "variable"),
Suggestion("var(--color-base-200)", "Base 200", "variable"),
Suggestion("var(--color-base-300)", "Base 300", "variable"),
Suggestion("var(--color-base-content)", "Base content color", "variable"),
]
# Combined color suggestions
ALL_COLORS: list[Suggestion] = CSS_COLORS + DAISYUI_COLORS
# =============================================================================
# Date Format Patterns
# =============================================================================
DATE_PATTERNS: list[Suggestion] = [
Suggestion('"%Y-%m-%d"', "ISO format (2026-01-29)", "pattern"),
Suggestion('"%d/%m/%Y"', "European (29/01/2026)", "pattern"),
Suggestion('"%m/%d/%Y"', "US format (01/29/2026)", "pattern"),
Suggestion('"%d %b %Y"', "Short month (29 Jan 2026)", "pattern"),
Suggestion('"%d %B %Y"', "Full month (29 January 2026)", "pattern"),
]
# =============================================================================
# Text Transform Values
# =============================================================================
TEXT_TRANSFORMS: list[Suggestion] = [
Suggestion('"uppercase"', "UPPERCASE", "value"),
Suggestion('"lowercase"', "lowercase", "value"),
Suggestion('"capitalize"', "Capitalize Each Word", "value"),
]
# =============================================================================
# Boolean Values
# =============================================================================
BOOLEAN_VALUES: list[Suggestion] = [
Suggestion("True", "Boolean true", "literal"),
Suggestion("False", "Boolean false", "literal"),
]
# =============================================================================
# Scope Keywords
# =============================================================================
SCOPE_KEYWORDS: list[Suggestion] = [
Suggestion("column", "Define column scope", "keyword"),
Suggestion("row", "Define row scope", "keyword"),
Suggestion("cell", "Define cell scope", "keyword"),
]
# =============================================================================
# Rule Start Keywords
# =============================================================================
RULE_START: list[Suggestion] = [
Suggestion("style(", "Apply visual styling", "function"),
Suggestion("format(", "Apply value formatting (preset)", "function"),
Suggestion("format.", "Apply value formatting (typed)", "function"),
]
# =============================================================================
# After Style/Format Keywords
# =============================================================================
AFTER_STYLE_OR_FORMAT: list[Suggestion] = [
Suggestion("style(", "Apply visual styling", "function"),
Suggestion("format(", "Apply value formatting (preset)", "function"),
Suggestion("format.", "Apply value formatting (typed)", "function"),
Suggestion("if", "Add condition", "keyword"),
]
# =============================================================================
# Style Parameters
# =============================================================================
STYLE_PARAMS: list[Suggestion] = [
Suggestion("bold=", "Bold text", "parameter"),
Suggestion("italic=", "Italic text", "parameter"),
Suggestion("underline=", "Underlined text", "parameter"),
Suggestion("strikethrough=", "Strikethrough text", "parameter"),
Suggestion("color=", "Text color", "parameter"),
Suggestion("background_color=", "Background color", "parameter"),
Suggestion("font_size=", "Font size", "parameter"),
]
# =============================================================================
# Format Types
# =============================================================================
FORMAT_TYPES: list[Suggestion] = [
Suggestion("number", "Number formatting", "type"),
Suggestion("date", "Date formatting", "type"),
Suggestion("boolean", "Boolean formatting", "type"),
Suggestion("text", "Text transformation", "type"),
Suggestion("enum", "Value mapping", "type"),
]
# =============================================================================
# Format Parameters by Type
# =============================================================================
FORMAT_PARAMS_DATE: list[Suggestion] = [
Suggestion("format=", "strftime pattern", "parameter"),
]
FORMAT_PARAMS_TEXT: list[Suggestion] = [
Suggestion("transform=", "Text transformation", "parameter"),
Suggestion("max_length=", "Maximum length", "parameter"),
Suggestion("ellipsis=", "Truncation suffix", "parameter"),
]
# =============================================================================
# Condition Keywords
# =============================================================================
CONDITION_START: list[Suggestion] = [
Suggestion("value", "Current cell value", "keyword"),
Suggestion("col.", "Reference another column", "keyword"),
Suggestion("not", "Negate condition", "keyword"),
]
CONDITION_AFTER_NOT: list[Suggestion] = [
Suggestion("value", "Current cell value", "keyword"),
Suggestion("col.", "Reference another column", "keyword"),
]
# =============================================================================
# Operators
# =============================================================================
COMPARISON_OPERATORS: list[Suggestion] = [
Suggestion("==", "Equal", "operator"),
Suggestion("!=", "Not equal", "operator"),
Suggestion("<", "Less than", "operator"),
Suggestion("<=", "Less or equal", "operator"),
Suggestion(">", "Greater than", "operator"),
Suggestion(">=", "Greater or equal", "operator"),
Suggestion("contains", "String contains", "operator"),
Suggestion("startswith", "String starts with", "operator"),
Suggestion("endswith", "String ends with", "operator"),
Suggestion("in", "Value in list", "operator"),
Suggestion("between", "Value in range", "operator"),
Suggestion("isempty", "Is null or empty", "operator"),
Suggestion("isnotempty", "Is not null or empty", "operator"),
]
# =============================================================================
# Operator Value Start
# =============================================================================
OPERATOR_VALUE_BASE: list[Suggestion] = [
Suggestion("col.", "Reference another column", "keyword"),
Suggestion("True", "Boolean true", "literal"),
Suggestion("False", "Boolean false", "literal"),
]
# =============================================================================
# Between Keyword
# =============================================================================
BETWEEN_AND: list[Suggestion] = [
Suggestion("and", "Between upper bound", "keyword"),
]
# =============================================================================
# In List Start
# =============================================================================
IN_LIST_START: list[Suggestion] = [
Suggestion("[", "Start list", "syntax"),
]

View File

@@ -0,0 +1,94 @@
"""
Metadata provider for DataGrid formatting DSL autocompletion.
Provides access to DataGrid metadata (columns, values, row counts)
for context-aware autocompletion.
"""
from typing import Protocol, Any
class DatagridMetadataProvider(Protocol):
"""
Protocol for providing DataGrid metadata to the autocompletion engine.
Implementations must provide access to:
- Available DataGrids (tables)
- Column names for each DataGrid
- Distinct values for each column
- Row count for each DataGrid
- Style and format presets
DataGrid names follow the pattern namespace.name (multi-level namespaces).
"""
def get_tables(self) -> list[str]:
"""
Return the list of available DataGrid names.
Returns:
List of DataGrid names (e.g., ["app.orders", "app.customers"])
"""
...
def get_columns(self, table_name: str) -> list[str]:
"""
Return the column names for a specific DataGrid.
Args:
table_name: The DataGrid name
Returns:
List of column names (e.g., ["id", "amount", "status"])
"""
...
def get_column_values(self, table_name, column_name: str) -> list[Any]:
"""
Return the distinct values for a column in the current DataGrid.
This is used to suggest values in conditions like `value == |`.
Args:
column_name: The column name
Returns:
List of distinct values in the column
"""
...
def get_row_count(self, table_name: str) -> int:
"""
Return the number of rows in a DataGrid.
Used to suggest row indices for row scope and cell scope.
Args:
table_name: The DataGrid name
Returns:
Number of rows
"""
...
def get_style_presets(self) -> list[str]:
"""
Return the list of available style preset names.
Includes default presets (primary, error, etc.) and custom presets.
Returns:
List of style preset names
"""
...
def get_format_presets(self) -> list[str]:
"""
Return the list of available format preset names.
Includes default presets (EUR, USD, etc.) and custom presets.
Returns:
List of format preset names
"""
...

View File

@@ -0,0 +1,311 @@
"""
Suggestions generation for the formatting DSL.
Provides functions to generate appropriate suggestions
based on the detected context and scope.
"""
from myfasthtml.core.dsl.types import Suggestion
from . import presets
from .contexts import Context, DetectedScope
from .provider import DatagridMetadataProvider
def get_suggestions(
context: Context,
scope: DetectedScope,
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
provider: Metadata provider for dynamic data
Returns:
List of suggestions
"""
match context:
# =================================================================
# Scope-level contexts
# =================================================================
case Context.NONE:
return []
case Context.SCOPE_KEYWORD:
return presets.SCOPE_KEYWORDS
case Context.COLUMN_NAME:
return _get_column_suggestions(provider)
case Context.ROW_INDEX:
return _get_row_index_suggestions(provider)
case Context.CELL_START:
return [Suggestion("(", "Start cell coordinates", "syntax")]
case Context.CELL_COLUMN:
return _get_column_suggestions(provider)
case Context.CELL_ROW:
return _get_row_index_suggestions(provider)
# =================================================================
# Rule-level contexts
# =================================================================
case Context.RULE_START:
return presets.RULE_START
# =================================================================
# Style contexts
# =================================================================
case Context.STYLE_ARGS:
# Presets (with quotes) + params
style_presets = _get_style_preset_suggestions_quoted(provider)
return style_presets + presets.STYLE_PARAMS
case Context.STYLE_PRESET:
return _get_style_preset_suggestions(provider)
case Context.STYLE_PARAM:
return presets.STYLE_PARAMS
# =================================================================
# Format contexts
# =================================================================
case Context.FORMAT_PRESET:
return _get_format_preset_suggestions(provider)
case Context.FORMAT_TYPE:
return presets.FORMAT_TYPES
case Context.FORMAT_PARAM_DATE:
return presets.FORMAT_PARAMS_DATE
case Context.FORMAT_PARAM_TEXT:
return presets.FORMAT_PARAMS_TEXT
# =================================================================
# After style/format
# =================================================================
case Context.AFTER_STYLE_OR_FORMAT:
return presets.AFTER_STYLE_OR_FORMAT
# =================================================================
# Condition contexts
# =================================================================
case Context.CONDITION_START:
return presets.CONDITION_START
case Context.CONDITION_AFTER_NOT:
return presets.CONDITION_AFTER_NOT
case Context.COLUMN_REF:
return _get_column_suggestions(provider)
case Context.COLUMN_REF_QUOTED:
return _get_column_suggestions_with_closing_quote(provider)
# =================================================================
# Operator contexts
# =================================================================
case Context.OPERATOR:
return presets.COMPARISON_OPERATORS
case Context.OPERATOR_VALUE | Context.BETWEEN_VALUE:
# col., True, False + column values
base = presets.OPERATOR_VALUE_BASE.copy()
base.extend(_get_column_value_suggestions(scope, provider))
return base
case Context.BETWEEN_AND:
return presets.BETWEEN_AND
case Context.IN_LIST_START:
return presets.IN_LIST_START
case Context.IN_LIST_VALUE:
return _get_column_value_suggestions(scope, provider)
# =================================================================
# Value contexts
# =================================================================
case Context.BOOLEAN_VALUE:
return presets.BOOLEAN_VALUES
case Context.COLOR_VALUE:
return presets.ALL_COLORS
case Context.DATE_FORMAT_VALUE:
return presets.DATE_PATTERNS
case Context.TRANSFORM_VALUE:
return presets.TEXT_TRANSFORMS
case _:
return []
def _get_column_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get column name suggestions from provider."""
try:
# Try to get columns from the first available table
tables = provider.get_tables()
if tables:
columns = provider.get_columns(tables[0])
return [Suggestion(col, "Column", "column") for col in columns]
except Exception:
pass
return []
def _get_column_suggestions_with_closing_quote(
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""Get column name suggestions with closing quote."""
try:
tables = provider.get_tables()
if tables:
columns = provider.get_columns(tables[0])
return [Suggestion(f'{col}"', "Column", "column") for col in columns]
except Exception:
pass
return []
def _get_style_preset_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
try:
custom_presets = provider.get_style_presets()
for preset in custom_presets:
# Check if it's already in default presets
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets (just the name, no quotes - we're inside quotes)
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_style_preset_suggestions_quoted(
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""Get style preset suggestions with quotes."""
suggestions = []
# Add provider presets if available
try:
custom_presets = provider.get_style_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
except Exception:
pass
# Add default presets with quotes
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
return suggestions
def _get_format_preset_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get format preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
try:
custom_presets = provider.get_format_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets
for preset in presets.FORMAT_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_row_index_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get row index suggestions (first 10 + last)."""
suggestions = []
try:
tables = provider.get_tables()
if tables:
row_count = provider.get_row_count(tables[0])
if row_count > 0:
# First 10 rows
for i in range(min(10, row_count)):
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
# Last row if not already included
last_index = row_count - 1
if last_index >= 10:
suggestions.append(
Suggestion(str(last_index), f"Last row ({row_count} total)", "index")
)
except Exception:
pass
# Fallback if no provider data
if not suggestions:
for i in range(10):
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
return suggestions
def _get_column_value_suggestions(
scope: DetectedScope,
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""Get column value suggestions based on the current scope."""
if not scope.column_name:
return []
try:
values = provider.get_column_values(scope.column_name)
suggestions = []
for value in values:
if value is None:
continue
# Format value appropriately
if isinstance(value, str):
label = f'"{value}"'
detail = "Text value"
elif isinstance(value, bool):
label = str(value)
detail = "Boolean value"
elif isinstance(value, (int, float)):
label = str(value)
detail = "Numeric value"
else:
label = f'"{value}"'
detail = "Value"
suggestions.append(Suggestion(label, detail, "value"))
return suggestions
except Exception:
return []

View File

@@ -0,0 +1,23 @@
"""
FormattingDSL definition for the DslEditor control.
Provides the Lark grammar and derived completions for the
DataGrid Formatting DSL.
"""
from myfasthtml.core.dsl.base import DSLDefinition
from myfasthtml.core.formatting.dsl.grammar import GRAMMAR
class FormattingDSL(DSLDefinition):
"""
DSL definition for DataGrid formatting rules.
Uses the existing Lark grammar from grammar.py.
"""
name: str = "Formatting DSL"
def get_grammar(self) -> str:
"""Return the Lark grammar for formatting DSL."""
return GRAMMAR

View File

@@ -0,0 +1,55 @@
"""
DSL-specific exceptions.
"""
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 ""))

View File

@@ -0,0 +1,159 @@
"""
Lark grammar for the DataGrid Formatting DSL.
This grammar is designed to be translatable to Lezer for CodeMirror integration.
"""
GRAMMAR = r"""
// ==================== Top-level structure ====================
start: _NL* scope+
// ==================== Scopes ====================
scope: scope_header ":" _NL _INDENT rule+ _DEDENT
scope_header: column_scope
| row_scope
| cell_scope
column_scope: "column" column_name
row_scope: "row" INTEGER
cell_scope: "cell" cell_ref
column_name: NAME -> name
| QUOTED_STRING -> quoted_name
cell_ref: "(" column_name "," INTEGER ")" -> cell_coords
| CELL_ID -> cell_id
// ==================== Rules ====================
rule: rule_content _NL
rule_content: style_expr format_expr? condition?
| format_expr style_expr? condition?
condition: "if" comparison
// ==================== Comparisons ====================
comparison: negation? comparison_expr case_modifier?
negation: "not"
comparison_expr: binary_comparison
| unary_comparison
binary_comparison: operand operator operand -> binary_comp
| operand "in" list -> in_comp
| operand "between" operand "and" operand -> between_comp
unary_comparison: operand "isempty" -> isempty_comp
| operand "isnotempty" -> isnotempty_comp
case_modifier: "(" "case" ")"
// ==================== Operators ====================
operator: "==" -> op_eq
| "!=" -> op_ne
| "<=" -> op_le
| "<" -> op_lt
| ">=" -> op_ge
| ">" -> op_gt
| "contains" -> op_contains
| "startswith" -> op_startswith
| "endswith" -> op_endswith
// ==================== Operands ====================
operand: value_ref
| column_ref
| row_ref
| cell_ref_expr
| literal
| arithmetic
| "(" operand ")"
value_ref: "value"
column_ref: "col" "." (NAME | QUOTED_STRING)
row_ref: "row" "." INTEGER
cell_ref_expr: "cell" "." NAME "-" INTEGER
literal: QUOTED_STRING -> string_literal
| SIGNED_NUMBER -> number_literal
| BOOLEAN -> boolean_literal
arithmetic: operand arith_op operand
arith_op: "*" -> arith_mul
| "/" -> arith_div
| "+" -> arith_add
| "-" -> arith_sub
list: "[" [literal ("," literal)*] "]"
// ==================== Style expression ====================
style_expr: "style" "(" style_args ")"
style_args: QUOTED_STRING ("," kwargs)? -> style_with_preset
| kwargs -> style_without_preset
// ==================== Format expression ====================
format_expr: format_preset
| format_typed
format_preset: "format" "(" QUOTED_STRING ("," kwargs)? ")"
format_typed: "format" "." format_type "(" kwargs? ")"
format_type: "number" -> fmt_number
| "date" -> fmt_date
| "boolean" -> fmt_boolean
| "text" -> fmt_text
| "enum" -> fmt_enum
// ==================== Keyword arguments ====================
kwargs: kwarg ("," kwarg)*
kwarg: NAME "=" kwarg_value
kwarg_value: QUOTED_STRING -> kwarg_string
| SIGNED_NUMBER -> kwarg_number
| BOOLEAN -> kwarg_boolean
| dict -> kwarg_dict
dict: "{" [dict_entry ("," dict_entry)*] "}"
dict_entry: QUOTED_STRING ":" (QUOTED_STRING | SIGNED_NUMBER | BOOLEAN)
// ==================== Terminals ====================
NAME: /[a-zA-Z_][a-zA-Z0-9_]*/
QUOTED_STRING: /"[^"]*"/ | /'[^']*'/
INTEGER: /[0-9]+/
SIGNED_NUMBER: /[+-]?[0-9]+(\.[0-9]+)?/
BOOLEAN: "True" | "False" | "true" | "false"
CELL_ID: /tcell_[a-zA-Z0-9_-]+/
// ==================== Whitespace handling ====================
COMMENT: /#[^\n]*/
// Newline token includes following whitespace for indentation tracking
// This is required by lark's Indenter to detect indentation levels
_NL: /(\r?\n[\t ]*)+/
// Ignore inline whitespace (within a line, not at line start)
%ignore /[\t ]+/
%ignore COMMENT
%declare _INDENT _DEDENT
"""

View File

@@ -0,0 +1,111 @@
"""
DSL Parser using lark.
Handles parsing of the DSL text into an AST.
"""
from lark import Lark, UnexpectedInput
from lark.indenter import Indenter
from .exceptions import DSLSyntaxError
from .grammar import GRAMMAR
class DSLIndenter(Indenter):
"""
Custom indenter for Python-style indentation.
Handles INDENT/DEDENT tokens for scoped rules.
"""
NL_type = "_NL"
OPEN_PAREN_types = [] # No multi-line expressions in our DSL
CLOSE_PAREN_types = []
INDENT_type = "_INDENT"
DEDENT_type = "_DEDENT"
tab_len = 4
class DSLParser:
"""
Parser for the DataGrid Formatting DSL.
Uses lark with custom indentation handling.
Example:
parser = DSLParser()
tree = parser.parse('''
column amount:
style("error") if value < 0
format("EUR")
''')
"""
def __init__(self):
self._parser = Lark(
GRAMMAR,
parser="lalr",
postlex=DSLIndenter(),
propagate_positions=True,
)
def parse(self, text: str):
"""
Parse DSL text into an AST.
Args:
text: The DSL text to parse
Returns:
lark.Tree: The parsed AST
Raises:
DSLSyntaxError: If the text has syntax errors
"""
# Pre-process: replace comment lines with empty lines (preserves line numbers)
lines = text.split("\n")
lines = ["" if line.strip().startswith("#") else line for line in lines]
text = "\n".join(lines)
# Strip leading whitespace/newlines and ensure text ends with newline
text = text.strip()
if text and not text.endswith("\n"):
text += "\n"
try:
return self._parser.parse(text)
except UnexpectedInput as e:
# Extract context for error message
context = None
if hasattr(e, "get_context"):
context = e.get_context(text)
raise DSLSyntaxError(
message=self._format_error_message(e),
line=getattr(e, "line", None),
column=getattr(e, "column", None),
context=context,
) from e
def _format_error_message(self, error: UnexpectedInput) -> str:
"""Format a user-friendly error message from lark exception."""
if hasattr(error, "expected"):
expected = list(error.expected)
if len(expected) == 1:
return f"Expected {expected[0]}"
elif len(expected) <= 5:
return f"Expected one of: {', '.join(expected)}"
else:
return "Unexpected input"
return str(error)
# Singleton parser instance
_parser = None
def get_parser() -> DSLParser:
"""Get the singleton parser instance."""
global _parser
if _parser is None:
_parser = DSLParser()
return _parser

View File

@@ -0,0 +1,47 @@
"""
Scope dataclasses for DSL output.
"""
from dataclasses import dataclass
from ..dataclasses import FormatRule
@dataclass
class ColumnScope:
"""Scope targeting a column by name."""
column: str
@dataclass
class RowScope:
"""Scope targeting a row by index."""
row: int
@dataclass
class CellScope:
"""
Scope targeting a specific cell.
Can be specified either by:
- Coordinates: column + row
- Cell ID: cell_id
"""
column: str = None
row: int = None
cell_id: str = None
@dataclass
class ScopedRule:
"""
A format rule with its scope.
The DSL parser returns a list of ScopedRule objects.
Attributes:
scope: Where the rule applies (ColumnScope, RowScope, or CellScope)
rule: The FormatRule (condition + style + formatter)
"""
scope: ColumnScope | RowScope | CellScope
rule: FormatRule

View File

@@ -0,0 +1,430 @@
"""
DSL Transformer.
Converts lark AST into FormatRule and ScopedRule objects.
"""
from lark import Transformer
from .exceptions import DSLValidationError
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from ..dataclasses import (
Condition,
Style,
FormatRule,
NumberFormatter,
DateFormatter,
BooleanFormatter,
TextFormatter,
EnumFormatter,
)
class DSLTransformer(Transformer):
"""
Transforms the lark AST into ScopedRule objects.
This transformer visits each node in the AST and converts it
to the appropriate dataclass.
"""
# ==================== Top-level ====================
def start(self, items):
"""Flatten all scoped rules from all scopes."""
result = []
for scope_rules in items:
result.extend(scope_rules)
return result
# ==================== Scopes ====================
def scope(self, items):
"""Process a scope block, returning list of ScopedRules."""
scope_obj = items[0] # scope_header result
rules = items[1:] # rule results
return [ScopedRule(scope=scope_obj, rule=rule) for rule in rules]
def scope_header(self, items):
return items[0]
def column_scope(self, items):
column_name = items[0]
return ColumnScope(column=column_name)
def row_scope(self, items):
row_index = int(items[0])
return RowScope(row=row_index)
def cell_scope(self, items):
return items[0] # cell_ref result
def cell_coords(self, items):
column_name = items[0]
row_index = int(items[1])
return CellScope(column=column_name, row=row_index)
def cell_id(self, items):
cell_id = str(items[0])
return CellScope(cell_id=cell_id)
def name(self, items):
return str(items[0])
def quoted_name(self, items):
return self._unquote(items[0])
# ==================== Rules ====================
def rule(self, items):
return items[0] # rule_content result
def rule_content(self, items):
"""Build a FormatRule from style, format, and condition."""
style_obj = None
formatter_obj = None
condition_obj = None
for item in items:
if isinstance(item, Style):
style_obj = item
elif isinstance(item, (NumberFormatter, DateFormatter, BooleanFormatter,
TextFormatter, EnumFormatter)):
formatter_obj = item
elif isinstance(item, Condition):
condition_obj = item
return FormatRule(
condition=condition_obj,
style=style_obj,
formatter=formatter_obj,
)
# ==================== Conditions ====================
def condition(self, items):
return items[0] # comparison result
def comparison(self, items):
"""Process comparison with optional negation and case modifier."""
negate = False
case_sensitive = False
condition = None
for item in items:
if item == "not":
negate = True
elif item == "case":
case_sensitive = True
elif isinstance(item, Condition):
condition = item
if condition:
condition.negate = negate
condition.case_sensitive = case_sensitive
return condition
def negation(self, items):
return "not"
def case_modifier(self, items):
return "case"
def comparison_expr(self, items):
return items[0]
def binary_comparison(self, items):
return items[0]
def unary_comparison(self, items):
return items[0]
def binary_comp(self, items):
left, operator, right = items
# Handle column reference in value
if isinstance(right, dict) and "col" in right:
value = right
else:
value = right
return Condition(operator=operator, value=value)
def in_comp(self, items):
operand, values = items
return Condition(operator="in", value=values)
def between_comp(self, items):
operand, low, high = items
return Condition(operator="between", value=[low, high])
def isempty_comp(self, items):
return Condition(operator="isempty")
def isnotempty_comp(self, items):
return Condition(operator="isnotempty")
# ==================== Operators ====================
def op_eq(self, items):
return "=="
def op_ne(self, items):
return "!="
def op_lt(self, items):
return "<"
def op_le(self, items):
return "<="
def op_gt(self, items):
return ">"
def op_ge(self, items):
return ">="
def op_contains(self, items):
return "contains"
def op_startswith(self, items):
return "startswith"
def op_endswith(self, items):
return "endswith"
# ==================== Operands ====================
def operand(self, items):
return items[0]
def value_ref(self, items):
return "value" # Marker for current cell value
def column_ref(self, items):
col_name = items[0]
if isinstance(col_name, str) and col_name.startswith('"'):
col_name = self._unquote(col_name)
return {"col": col_name}
def row_ref(self, items):
row_index = int(items[0])
return {"row": row_index}
def cell_ref_expr(self, items):
col_name = str(items[0])
row_index = int(items[1])
return {"col": col_name, "row": row_index}
def literal(self, items):
return items[0]
def string_literal(self, items):
return self._unquote(items[0])
def number_literal(self, items):
value = str(items[0])
if "." in value:
return float(value)
return int(value)
def boolean_literal(self, items):
return str(items[0]).lower() == "true"
def arithmetic(self, items):
left, op, right = items
# For now, return as a dict representing the expression
# This could be evaluated later or kept as-is for complex comparisons
return {"arithmetic": {"left": left, "op": op, "right": right}}
def arith_mul(self, items):
return "*"
def arith_div(self, items):
return "/"
def arith_add(self, items):
return "+"
def arith_sub(self, items):
return "-"
def list(self, items):
return list(items)
# ==================== Style ====================
def style_expr(self, items):
return items[0] # style_args result
def style_args(self, items):
return items[0]
def style_with_preset(self, items):
preset = self._unquote(items[0])
kwargs = items[1] if len(items) > 1 else {}
return self._build_style(preset, kwargs)
def style_without_preset(self, items):
kwargs = items[0] if items else {}
return self._build_style(None, kwargs)
def _build_style(self, preset: str, kwargs: dict) -> Style:
"""Build a Style object from preset and kwargs."""
# Map DSL parameter names to Style attribute names
param_map = {
"bold": ("font_weight", lambda v: "bold" if v else "normal"),
"italic": ("font_style", lambda v: "italic" if v else "normal"),
"underline": ("text_decoration", lambda v: "underline" if v else None),
"strikethrough": ("text_decoration", lambda v: "line-through" if v else None),
"background_color": ("background_color", lambda v: v),
"color": ("color", lambda v: v),
"font_size": ("font_size", lambda v: v),
}
style_kwargs = {"preset": preset}
for key, value in kwargs.items():
if key in param_map:
attr_name, converter = param_map[key]
converted = converter(value)
if converted is not None:
style_kwargs[attr_name] = converted
else:
# Pass through unknown params (may be custom)
style_kwargs[key] = value
return Style(**{k: v for k, v in style_kwargs.items() if v is not None})
# ==================== Format ====================
def format_expr(self, items):
return items[0]
def format_preset(self, items):
preset = self._unquote(items[0])
kwargs = items[1] if len(items) > 1 else {}
# When using preset, we don't know the type yet
# Return a generic formatter with preset
return NumberFormatter(preset=preset, **self._filter_number_kwargs(kwargs))
def format_typed(self, items):
format_type = items[0]
kwargs = items[1] if len(items) > 1 else {}
return self._build_formatter(format_type, kwargs)
def format_type(self, items):
return items[0]
def fmt_number(self, items):
return "number"
def fmt_date(self, items):
return "date"
def fmt_boolean(self, items):
return "boolean"
def fmt_text(self, items):
return "text"
def fmt_enum(self, items):
return "enum"
def _build_formatter(self, format_type: str, kwargs: dict):
"""Build the appropriate Formatter subclass."""
if format_type == "number":
return NumberFormatter(**self._filter_number_kwargs(kwargs))
elif format_type == "date":
return DateFormatter(**self._filter_date_kwargs(kwargs))
elif format_type == "boolean":
return BooleanFormatter(**self._filter_boolean_kwargs(kwargs))
elif format_type == "text":
return TextFormatter(**self._filter_text_kwargs(kwargs))
elif format_type == "enum":
return EnumFormatter(**self._filter_enum_kwargs(kwargs))
else:
raise DSLValidationError(f"Unknown formatter type: {format_type}")
def _filter_number_kwargs(self, kwargs: dict) -> dict:
"""Filter kwargs for NumberFormatter."""
valid_keys = {"prefix", "suffix", "thousands_sep", "decimal_sep", "precision", "multiplier"}
return {k: v for k, v in kwargs.items() if k in valid_keys}
def _filter_date_kwargs(self, kwargs: dict) -> dict:
"""Filter kwargs for DateFormatter."""
valid_keys = {"format"}
return {k: v for k, v in kwargs.items() if k in valid_keys}
def _filter_boolean_kwargs(self, kwargs: dict) -> dict:
"""Filter kwargs for BooleanFormatter."""
valid_keys = {"true_value", "false_value", "null_value"}
return {k: v for k, v in kwargs.items() if k in valid_keys}
def _filter_text_kwargs(self, kwargs: dict) -> dict:
"""Filter kwargs for TextFormatter."""
valid_keys = {"transform", "max_length", "ellipsis"}
return {k: v for k, v in kwargs.items() if k in valid_keys}
def _filter_enum_kwargs(self, kwargs: dict) -> dict:
"""Filter kwargs for EnumFormatter."""
valid_keys = {"source", "default", "allow_empty", "empty_label", "order_by"}
return {k: v for k, v in kwargs.items() if k in valid_keys}
# ==================== Keyword arguments ====================
def kwargs(self, items):
"""Collect keyword arguments into a dict."""
result = {}
for item in items:
if isinstance(item, tuple):
key, value = item
result[key] = value
return result
def kwarg(self, items):
key = str(items[0])
value = items[1]
return (key, value)
def kwarg_value(self, items):
return items[0]
def kwarg_string(self, items):
return self._unquote(items[0])
def kwarg_number(self, items):
value = str(items[0])
if "." in value:
return float(value)
return int(value)
def kwarg_boolean(self, items):
return str(items[0]).lower() == "true"
def kwarg_dict(self, items):
return items[0]
def dict(self, items):
"""Build a dict from dict entries."""
result = {}
for item in items:
if isinstance(item, tuple):
key, value = item
result[key] = value
return result
def dict_entry(self, items):
key = self._unquote(items[0])
value = items[1]
if isinstance(value, str) and (value.startswith('"') or value.startswith("'")):
value = self._unquote(value)
return (key, value)
# ==================== Helpers ====================
def _unquote(self, s) -> str:
"""Remove quotes from a quoted string."""
s = str(s)
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
return s[1:-1]
return s

View File

@@ -311,6 +311,7 @@ def make_html_id(s: str | None) -> str | None:
return s
def make_safe_id(s: str | None):
if s is None:
return None
@@ -341,6 +342,7 @@ def get_class(qualified_class_name: str):
return getattr(module, class_name)
@utils_rt(Routes.Commands)
def post(session, c_id: str, client_response: dict = None):
"""
@@ -378,3 +380,15 @@ def post(session, b_id: str, values: dict):
return res
raise ValueError(f"Binding with ID '{b_id}' not found.")
@utils_rt(Routes.Completions)
def get(session, c_id, text: str, line: int, ch: int):
"""
Default routes for Domaine Specific Languages completion
:param session:
:param c_id:
:param values:
:return:
"""
logger.debug(f"Entering {Routes.Bindings} with {session=}, {c_id=}, {values=}")

View File

@@ -48,4 +48,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -69,4 +69,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -30,4 +30,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -44,4 +44,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -37,4 +37,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -43,4 +43,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -44,4 +44,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -30,4 +30,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -26,4 +26,4 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)

View File

@@ -25,4 +25,4 @@ def index():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)

View File

@@ -0,0 +1,15 @@
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False, live=True)
@rt("/")
def get_homepage():
return Div("Hello, FastHtml my!")
if __name__ == "__main__":
serve(port=5010)

View File

@@ -12,4 +12,4 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)

View File

@@ -33,6 +33,7 @@ def get_asset_content(filename):
def create_app(daisyui: Optional[bool] = True,
vis: Optional[bool] = True,
code_mirror: Optional[bool] = True,
protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False,
base_url: Optional[str] = None,
@@ -41,8 +42,15 @@ def create_app(daisyui: Optional[bool] = True,
Creates and configures a FastHtml application with optional support for daisyUI themes and
authentication routes.
:param daisyui: Flag to enable or disable inclusion of daisyUI-related assets for styling.
Defaults to False.
:param daisyui: Flag to enable or disable inclusion of daisyUI (https://daisyui.com/).
Defaults to True.
:param vis: Flag to enable or disable inclusion of Vis network (https://visjs.org/)
Defaults to True.
:param code_mirror: Flag to enable or disable inclusion of Code Mirror (https://codemirror.net/)
Defaults to True.
:param protect_routes: Flag to enable or disable routes protection based on authentication.
Defaults to True.
:param mount_auth_app: Flag to enable or disable mounting of authentication routes.
@@ -70,6 +78,17 @@ def create_app(daisyui: Optional[bool] = True,
Script(src="/myfasthtml/vis-network.min.js"),
]
if code_mirror:
hdrs += [
Script(src="/myfasthtml/codemirror.min.js"),
Link(href="/myfasthtml/codemirror.min.css", rel="stylesheet", type="text/css"),
Script(src="/myfasthtml/placeholder.min.js"),
Script(src="/myfasthtml/show-hint.min.js"),
Link(href="/myfasthtml/show-hint.min.css", rel="stylesheet", type="text/css"),
]
beforeware = create_auth_beforeware() if protect_routes else None
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
@@ -80,13 +99,14 @@ def create_app(daisyui: Optional[bool] = True,
# Serve assets
@app.get("/myfasthtml/{filename:path}.{ext:static}")
def serve_assets(filename: str, ext: str):
logger.debug(f"Serving asset: {filename=}, {ext=}")
path = filename + "." + ext
try:
content = get_asset_content(path)
if filename.endswith('.css'):
if ext == '.css':
return Response(content, media_type="text/css")
elif filename.endswith('.js'):
elif ext == 'js':
return Response(content, media_type="application/javascript")
else:
return Response(content)

View File

View File

@@ -0,0 +1,137 @@
"""
Tests for BaseCompletionEngine.
Uses a mock implementation to test the abstract base class functionality.
"""
import pytest
from typing import Any
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
class MockProvider:
"""Mock metadata provider for testing."""
def get_style_presets(self) -> list[str]:
return ["custom_highlight"]
def get_format_presets(self) -> list[str]:
return ["CHF"]
class MockCompletionEngine(BaseCompletionEngine):
"""Mock completion engine for testing base class functionality."""
def __init__(self, provider: BaseMetadataProvider, suggestions: list[Suggestion] = None):
super().__init__(provider)
self._suggestions = suggestions or []
self._scope = None
self._context = "test_context"
def detect_scope(self, text: str, current_line: int) -> Any:
return self._scope
def detect_context(self, text: str, cursor: Position, scope: Any) -> Any:
return self._context
def get_suggestions(self, context: Any, scope: Any, prefix: str) -> list[Suggestion]:
return self._suggestions
# =============================================================================
# Filter Suggestions Tests
# =============================================================================
def test_i_can_filter_suggestions_by_prefix():
"""Test that _filter_suggestions filters case-insensitively."""
provider = MockProvider()
engine = MockCompletionEngine(provider)
suggestions = [
Suggestion("primary", "Primary color", "preset"),
Suggestion("error", "Error color", "preset"),
Suggestion("warning", "Warning color", "preset"),
Suggestion("Error", "Title case error", "preset"),
]
# Filter by "err" - should match "error" and "Error" (case-insensitive)
filtered = engine._filter_suggestions(suggestions, "err")
labels = [s.label for s in filtered]
assert "error" in labels
assert "Error" in labels
assert "primary" not in labels
assert "warning" not in labels
def test_i_can_filter_suggestions_empty_prefix():
"""Test that empty prefix returns all suggestions."""
provider = MockProvider()
engine = MockCompletionEngine(provider)
suggestions = [
Suggestion("a"),
Suggestion("b"),
Suggestion("c"),
]
filtered = engine._filter_suggestions(suggestions, "")
assert len(filtered) == 3
# =============================================================================
# Empty Result Tests
# =============================================================================
def test_i_can_get_empty_result():
"""Test that _empty_result returns a CompletionResult with no suggestions."""
provider = MockProvider()
engine = MockCompletionEngine(provider)
cursor = Position(line=5, ch=10)
result = engine._empty_result(cursor)
assert result.from_pos == cursor
assert result.to_pos == cursor
assert result.suggestions == []
assert result.is_empty is True
# =============================================================================
# Comment Skipping Tests
# =============================================================================
def test_i_can_skip_completion_in_comment():
"""Test that get_completions returns empty when cursor is in a comment."""
provider = MockProvider()
suggestions = [Suggestion("should_not_appear")]
engine = MockCompletionEngine(provider, suggestions)
text = "# This is a comment"
cursor = Position(line=0, ch=15) # Inside the comment
result = engine.get_completions(text, cursor)
assert result.is_empty is True
assert len(result.suggestions) == 0
def test_i_can_get_completions_outside_comment():
"""Test that get_completions works when cursor is not in a comment."""
provider = MockProvider()
suggestions = [Suggestion("style"), Suggestion("format")]
engine = MockCompletionEngine(provider, suggestions)
# Cursor at space (ch=5) so prefix is empty and all suggestions are returned
text = "text # comment"
cursor = Position(line=0, ch=5) # At empty space, before comment
result = engine.get_completions(text, cursor)
assert result.is_empty is False
assert len(result.suggestions) == 2

View File

@@ -0,0 +1,172 @@
"""Tests for lark_to_lezer module."""
import pytest
from myfasthtml.core.dsl.lark_to_lezer import (
extract_completions_from_grammar,
lark_to_lezer_grammar,
)
# Sample grammars for testing
SIMPLE_GRAMMAR = r'''
start: rule+
rule: "if" condition
condition: "value" operator literal
operator: "==" -> op_eq
| "!=" -> op_ne
| "contains" -> op_contains
literal: QUOTED_STRING -> string_literal
| BOOLEAN -> boolean_literal
QUOTED_STRING: /"[^"]*"/
BOOLEAN: "True" | "False"
'''
GRAMMAR_WITH_KEYWORDS = r'''
start: scope+
scope: "column" NAME ":" rule
| "row" INTEGER ":" rule
| "cell" cell_ref ":" rule
rule: style_expr condition?
condition: "if" "not"? comparison
comparison: operand "and" operand
| operand "or" operand
style_expr: "style" "(" args ")"
operand: "value" | literal
'''
GRAMMAR_WITH_TYPES = r'''
format_type: "number" -> fmt_number
| "date" -> fmt_date
| "boolean" -> fmt_boolean
| "text" -> fmt_text
| "enum" -> fmt_enum
'''
class TestExtractCompletions:
"""Tests for extract_completions_from_grammar function."""
def test_i_can_extract_keywords_from_grammar(self):
"""Test that keywords like if, not, and are extracted."""
completions = extract_completions_from_grammar(GRAMMAR_WITH_KEYWORDS)
assert "if" in completions["keywords"]
assert "not" in completions["keywords"]
assert "column" in completions["keywords"]
assert "row" in completions["keywords"]
assert "cell" in completions["keywords"]
assert "value" in completions["keywords"]
@pytest.mark.parametrize(
"operator",
["==", "!=", "contains"],
)
def test_i_can_extract_operators_from_grammar(self, operator):
"""Test that operators are extracted from grammar."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
assert operator in completions["operators"]
def test_i_can_extract_functions_from_grammar(self):
"""Test that function-like constructs are extracted."""
completions = extract_completions_from_grammar(GRAMMAR_WITH_KEYWORDS)
assert "style" in completions["functions"]
@pytest.mark.parametrize(
"type_name",
["number", "date", "boolean", "text", "enum"],
)
def test_i_can_extract_types_from_grammar(self, type_name):
"""Test that type names are extracted from format_type rule."""
completions = extract_completions_from_grammar(GRAMMAR_WITH_TYPES)
assert type_name in completions["types"]
@pytest.mark.parametrize("literal", [
"True",
"False"
])
def test_i_can_extract_literals_from_grammar(self, literal):
"""Test that literal values like True/False are extracted."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
assert literal in completions["literals"]
def test_i_can_extract_completions_returns_all_categories(self):
"""Test that all completion categories are present in result."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
assert "keywords" in completions
assert "operators" in completions
assert "functions" in completions
assert "types" in completions
assert "literals" in completions
def test_i_can_extract_completions_returns_sorted_lists(self):
"""Test that completion lists are sorted alphabetically."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
for category in completions.values():
assert category == sorted(category)
class TestLarkToLezerConversion:
"""Tests for lark_to_lezer_grammar function."""
def test_i_can_convert_simple_grammar_to_lezer(self):
"""Test that a simple Lark grammar is converted to Lezer format."""
lezer = lark_to_lezer_grammar(SIMPLE_GRAMMAR)
# Should have @top directive
assert "@top Start" in lezer
# Should have @tokens block
assert "@tokens {" in lezer
# Should have @skip directive
assert "@skip {" in lezer
def test_i_can_convert_rule_names_to_pascal_case(self):
"""Test that snake_case rule names become PascalCase."""
grammar = r'''
my_rule: other_rule
other_rule: "test"
'''
lezer = lark_to_lezer_grammar(grammar)
assert "MyRule" in lezer
assert "OtherRule" in lezer
def test_i_cannot_include_internal_rules_in_lezer(self):
"""Test that rules starting with _ are not included."""
grammar = r'''
start: rule _NL
rule: "test"
_NL: /\n/
'''
lezer = lark_to_lezer_grammar(grammar)
# Internal rules should not appear as Lezer rules
assert "Nl {" not in lezer
def test_i_can_convert_terminal_regex_to_lezer(self):
"""Test that terminal regex patterns are converted."""
grammar = r'''
NAME: /[a-zA-Z_][a-zA-Z0-9_]*/
'''
lezer = lark_to_lezer_grammar(grammar)
assert "NAME" in lezer
@pytest.mark.parametrize(
"terminal,pattern",
[
('BOOLEAN: "True" | "False"', "BOOLEAN"),
('KEYWORD: "if"', "KEYWORD"),
],
)
def test_i_can_convert_terminal_strings_to_lezer(self, terminal, pattern):
"""Test that terminal string literals are converted."""
grammar = f"start: test\n{terminal}"
lezer = lark_to_lezer_grammar(grammar)
assert pattern in lezer

View File

@@ -0,0 +1,145 @@
"""
Tests for DSL autocompletion types.
Tests for Position, Suggestion, CompletionResult, and WordRange dataclasses.
"""
import pytest
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult, WordRange
# =============================================================================
# Position Tests
# =============================================================================
def test_i_can_create_position():
"""Test that a Position can be created with line and ch."""
pos = Position(line=0, ch=5)
assert pos.line == 0
assert pos.ch == 5
def test_i_can_convert_position_to_dict():
"""Test that Position.to_dict() returns correct CodeMirror format."""
pos = Position(line=3, ch=12)
result = pos.to_dict()
assert result == {"line": 3, "ch": 12}
# =============================================================================
# Suggestion Tests
# =============================================================================
def test_i_can_create_suggestion_with_label_only():
"""Test that a Suggestion can be created with just a label."""
suggestion = Suggestion("text")
assert suggestion.label == "text"
assert suggestion.detail == ""
assert suggestion.kind == ""
def test_i_can_create_suggestion_with_all_fields():
"""Test that a Suggestion can be created with label, detail, and kind."""
suggestion = Suggestion(label="primary", detail="Primary theme color", kind="preset")
assert suggestion.label == "primary"
assert suggestion.detail == "Primary theme color"
assert suggestion.kind == "preset"
def test_i_can_convert_suggestion_to_dict_with_label_only():
"""Test that Suggestion.to_dict() works with label only."""
suggestion = Suggestion("text")
result = suggestion.to_dict()
assert result == {"label": "text"}
assert "detail" not in result
assert "kind" not in result
def test_i_can_convert_suggestion_to_dict_with_all_fields():
"""Test that Suggestion.to_dict() includes detail and kind when present."""
suggestion = Suggestion(label="error", detail="Error style", kind="preset")
result = suggestion.to_dict()
assert result == {"label": "error", "detail": "Error style", "kind": "preset"}
def test_i_can_convert_suggestion_to_dict_with_partial_fields():
"""Test that Suggestion.to_dict() includes only non-empty fields."""
suggestion = Suggestion(label="text", detail="Description")
result = suggestion.to_dict()
assert result == {"label": "text", "detail": "Description"}
assert "kind" not in result
# =============================================================================
# CompletionResult Tests
# =============================================================================
def test_i_can_create_completion_result():
"""Test that a CompletionResult can be created with positions and suggestions."""
from_pos = Position(line=0, ch=5)
to_pos = Position(line=0, ch=10)
suggestions = [Suggestion("option1"), Suggestion("option2")]
result = CompletionResult(from_pos=from_pos, to_pos=to_pos, suggestions=suggestions)
assert result.from_pos == from_pos
assert result.to_pos == to_pos
assert len(result.suggestions) == 2
def test_i_can_convert_completion_result_to_dict():
"""Test that CompletionResult.to_dict() returns CodeMirror-compatible format."""
from_pos = Position(line=1, ch=4)
to_pos = Position(line=1, ch=8)
suggestions = [Suggestion("primary", "Primary color", "preset")]
result = CompletionResult(from_pos=from_pos, to_pos=to_pos, suggestions=suggestions)
dict_result = result.to_dict()
assert dict_result == {
"from": {"line": 1, "ch": 4},
"to": {"line": 1, "ch": 8},
"suggestions": [{"label": "primary", "detail": "Primary color", "kind": "preset"}],
}
def test_completion_result_is_empty_when_no_suggestions():
"""Test that is_empty returns True when there are no suggestions."""
result = CompletionResult(
from_pos=Position(line=0, ch=0),
to_pos=Position(line=0, ch=0),
suggestions=[],
)
assert result.is_empty is True
def test_completion_result_is_not_empty_when_has_suggestions():
"""Test that is_empty returns False when there are suggestions."""
result = CompletionResult(
from_pos=Position(line=0, ch=0),
to_pos=Position(line=0, ch=5),
suggestions=[Suggestion("text")],
)
assert result.is_empty is False
# =============================================================================
# WordRange Tests
# =============================================================================
def test_i_can_create_word_range():
"""Test that a WordRange can be created with start, end, and text."""
word_range = WordRange(start=5, end=10, text="hello")
assert word_range.start == 5
assert word_range.end == 10
assert word_range.text == "hello"
def test_i_can_create_word_range_with_default_text():
"""Test that a WordRange has default empty text."""
word_range = WordRange(start=0, end=0)
assert word_range.text == ""

View File

@@ -0,0 +1,261 @@
"""
Tests for DSL autocompletion utilities.
Tests for line extraction, word boundaries, comment/string detection,
and indentation functions.
"""
import pytest
from myfasthtml.core.dsl.types import Position, WordRange
from myfasthtml.core.dsl.utils import (
get_line_at,
get_line_up_to_cursor,
get_lines_up_to,
find_word_boundaries,
get_prefix,
is_in_comment,
is_in_string,
get_indentation,
is_indented,
strip_quotes,
)
# =============================================================================
# Line Extraction Tests
# =============================================================================
def test_i_can_get_line_at_valid_index():
"""Test that get_line_at returns the correct line."""
text = "line0\nline1\nline2"
assert get_line_at(text, 0) == "line0"
assert get_line_at(text, 1) == "line1"
assert get_line_at(text, 2) == "line2"
def test_i_can_get_line_at_invalid_index():
"""Test that get_line_at returns empty string for invalid index."""
text = "line0\nline1"
assert get_line_at(text, -1) == ""
assert get_line_at(text, 5) == ""
def test_i_can_get_line_up_to_cursor():
"""Test that get_line_up_to_cursor truncates at cursor position."""
text = "hello world\nfoo bar"
cursor = Position(line=0, ch=5)
assert get_line_up_to_cursor(text, cursor) == "hello"
cursor = Position(line=1, ch=3)
assert get_line_up_to_cursor(text, cursor) == "foo"
def test_i_can_get_lines_up_to():
"""Test that get_lines_up_to returns lines 0..N."""
text = "line0\nline1\nline2\nline3"
assert get_lines_up_to(text, 0) == ["line0"]
assert get_lines_up_to(text, 2) == ["line0", "line1", "line2"]
# =============================================================================
# Word Boundaries Tests
# =============================================================================
def test_i_can_find_word_boundaries_in_middle():
"""Test word boundaries when cursor is in middle of word."""
line = "hello world"
result = find_word_boundaries(line, 3) # hel|lo
assert result.start == 0
assert result.end == 5
assert result.text == "hello"
def test_i_can_find_word_boundaries_at_start():
"""Test word boundaries when cursor is at start of word."""
line = "hello world"
result = find_word_boundaries(line, 0) # |hello
assert result.start == 0
assert result.end == 5
assert result.text == "hello"
def test_i_can_find_word_boundaries_at_end():
"""Test word boundaries when cursor is at end of word."""
line = "hello world"
result = find_word_boundaries(line, 5) # hello|
assert result.start == 0
assert result.end == 5
assert result.text == "hello"
def test_i_can_find_word_boundaries_with_delimiters():
"""Test word boundaries with delimiter characters like parentheses and quotes."""
line = 'style("error")'
result = find_word_boundaries(line, 10) # style("err|or")
assert result.start == 7
assert result.end == 12
assert result.text == "error"
def test_i_can_find_word_boundaries_empty_line():
"""Test word boundaries on empty line."""
line = ""
result = find_word_boundaries(line, 0)
assert result.start == 0
assert result.end == 0
assert result.text == ""
def test_i_can_get_prefix():
"""Test that get_prefix returns text from word start to cursor."""
line = "style"
prefix = get_prefix(line, 3) # sty|le
assert prefix == "sty"
def test_i_can_get_prefix_at_word_start():
"""Test that get_prefix returns empty at word start."""
line = "style"
prefix = get_prefix(line, 0) # |style
assert prefix == ""
# =============================================================================
# Comment Detection Tests
# =============================================================================
def test_i_can_detect_comment():
"""Test that cursor after # is detected as in comment."""
line = "text # comment"
assert is_in_comment(line, 12) is True # In "comment"
assert is_in_comment(line, 7) is True # Right after #
def test_i_cannot_detect_comment_before_hash():
"""Test that cursor before # is not detected as in comment."""
line = "text # comment"
assert is_in_comment(line, 4) is False # Before #
assert is_in_comment(line, 0) is False # At start
def test_i_cannot_detect_comment_hash_in_string():
"""Test that # inside a string is not detected as comment start."""
line = '"#hash" text'
assert is_in_comment(line, 9) is False # After the string
def test_i_can_detect_comment_hash_after_string():
"""Test that # after a string is detected as comment."""
line = '"text" # comment'
assert is_in_comment(line, 10) is True
# =============================================================================
# String Detection Tests
# =============================================================================
def test_i_can_detect_string_double_quote():
"""Test detection of cursor inside double-quoted string."""
line = 'style("error")'
in_string, quote_char = is_in_string(line, 10) # Inside "error"
assert in_string is True
assert quote_char == '"'
def test_i_can_detect_string_single_quote():
"""Test detection of cursor inside single-quoted string."""
line = "style('error')"
in_string, quote_char = is_in_string(line, 10) # Inside 'error'
assert in_string is True
assert quote_char == "'"
def test_i_cannot_detect_string_outside_quotes():
"""Test that cursor outside quotes is not detected as in string."""
line = 'style("error")'
in_string, quote_char = is_in_string(line, 3) # In "style"
assert in_string is False
assert quote_char is None
def test_i_cannot_detect_string_after_closing_quote():
"""Test that cursor after closing quote is not in string."""
line = '"text" other'
in_string, quote_char = is_in_string(line, 8)
assert in_string is False
assert quote_char is None
# =============================================================================
# Indentation Tests
# =============================================================================
def test_i_can_get_indentation_spaces():
"""Test that spaces are counted correctly."""
line = " text"
assert get_indentation(line) == 4
def test_i_can_get_indentation_tabs():
"""Test that tabs are converted to 4 spaces."""
line = "\ttext"
assert get_indentation(line) == 4
def test_i_can_get_indentation_mixed():
"""Test mixed spaces and tabs."""
line = " \t text" # 2 spaces + tab (4) + 2 spaces = 8
assert get_indentation(line) == 8
def test_i_can_detect_indented_line():
"""Test that indented line is detected."""
assert is_indented(" text") is True
assert is_indented("\ttext") is True
def test_i_cannot_detect_indented_for_non_indented():
"""Test that non-indented line is not detected as indented."""
assert is_indented("text") is False
def test_i_cannot_detect_indented_for_empty_line():
"""Test that empty line is not detected as indented."""
assert is_indented("") is False
# =============================================================================
# Quote Stripping Tests
# =============================================================================
def test_i_can_strip_quotes_double():
"""Test stripping double quotes."""
assert strip_quotes('"text"') == "text"
def test_i_can_strip_quotes_single():
"""Test stripping single quotes."""
assert strip_quotes("'text'") == "text"
def test_i_cannot_strip_quotes_unquoted():
"""Test that unquoted text is returned unchanged."""
assert strip_quotes("text") == "text"
def test_i_cannot_strip_quotes_mismatched():
"""Test that mismatched quotes are not stripped."""
assert strip_quotes('"text\'') == '"text\''
assert strip_quotes("'text\"") == "'text\""
def test_i_cannot_strip_quotes_too_short():
"""Test that text shorter than 2 chars is returned unchanged."""
assert strip_quotes('"') == '"'
assert strip_quotes("") == ""

View File

View File

@@ -0,0 +1,770 @@
"""
Tests for formatting DSL autocompletion.
Tests for scope detection, context detection, suggestions, and engine integration.
"""
import pytest
from typing import Any
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from myfasthtml.core.formatting.dsl.completion.contexts import (
Context,
DetectedScope,
detect_scope,
detect_context,
)
from myfasthtml.core.formatting.dsl.completion.suggestions import get_suggestions
from myfasthtml.core.formatting.dsl.completion.engine import (
FormattingCompletionEngine,
get_completions,
)
from myfasthtml.core.formatting.dsl.completion import presets
# =============================================================================
# Mock Provider Fixture
# =============================================================================
class MockProvider:
"""
Mock metadata provider for testing.
Provides predefined data for columns, values, and presets.
"""
def get_tables(self) -> list[str]:
return ["app.orders"]
def get_columns(self, table: str) -> list[str]:
return ["id", "amount", "status"]
def get_column_values(self, column: str) -> list[Any]:
if column == "status":
return ["draft", "pending", "approved"]
if column == "amount":
return [100, 250, 500]
return []
def get_row_count(self, table: str) -> int:
return 150
def get_style_presets(self) -> list[str]:
return ["custom_highlight"]
def get_format_presets(self) -> list[str]:
return ["CHF"]
@pytest.fixture
def provider():
"""Return a mock provider for tests."""
return MockProvider()
# =============================================================================
# Scope Detection Tests
# =============================================================================
def test_i_can_detect_column_scope():
"""Test detection of column scope."""
text = "column amount:\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
assert scope.row_index is None
def test_i_can_detect_column_scope_after_first_line():
"""Test detection of column scope."""
text = "column amount:\n style()"
scope = detect_scope(text, current_line=2)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
assert scope.row_index is None
def test_i_can_detect_column_scope_quoted():
"""Test detection of column scope with quoted column name."""
text = 'column "total amount":\n style()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "column"
assert scope.column_name == "total amount"
def test_i_can_detect_row_scope():
"""Test detection of row scope."""
text = "row 5:\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "row"
assert scope.row_index == 5
assert scope.column_name is None
def test_i_can_detect_cell_scope():
"""Test detection of cell scope."""
text = "cell (amount, 3):\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "cell"
assert scope.column_name == "amount"
assert scope.row_index == 3
def test_i_can_detect_cell_scope_quoted():
"""Test detection of cell scope with quoted column name."""
text = 'cell ("total amount", 3):\n style()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "cell"
assert scope.column_name == "total amount"
assert scope.row_index == 3
def test_i_cannot_detect_scope_without_declaration():
"""Test that no scope is detected when there's no declaration."""
text = " style()"
scope = detect_scope(text, current_line=0)
assert scope.scope_type is None
def test_i_can_detect_scope_with_multiple_declarations():
"""Test that the most recent scope is detected."""
text = "column id:\n style()\ncolumn amount:\n format()"
scope = detect_scope(text, current_line=3)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
# =============================================================================
# Context Detection - Scope Contexts
# =============================================================================
def test_context_scope_keyword_at_line_start():
"""Test SCOPE_KEYWORD context at start of non-indented line."""
text = ""
cursor = Position(line=0, ch=0)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.SCOPE_KEYWORD
def test_context_scope_keyword_partial():
"""Test SCOPE_KEYWORD context with partial keyword."""
text = "col"
cursor = Position(line=0, ch=3)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.SCOPE_KEYWORD
def test_context_column_name_after_column():
"""Test COLUMN_NAME context after 'column '."""
text = "column "
cursor = Position(line=0, ch=7)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.COLUMN_NAME
def test_context_row_index_after_row():
"""Test ROW_INDEX context after 'row '."""
text = "row "
cursor = Position(line=0, ch=4)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.ROW_INDEX
def test_context_cell_start_after_cell():
"""Test CELL_START context after 'cell '."""
text = "cell "
cursor = Position(line=0, ch=5)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_START
def test_context_cell_column_after_open_paren():
"""Test CELL_COLUMN context after 'cell ('."""
text = "cell ("
cursor = Position(line=0, ch=6)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_COLUMN
def test_context_cell_row_after_comma():
"""Test CELL_ROW context after 'cell (amount, '."""
text = "cell (amount, "
cursor = Position(line=0, ch=14)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_ROW
def test_context_cell_row_after_comma_quoted():
"""Test CELL_ROW context after 'cell ("column", '."""
text = 'cell ("amount", '
cursor = Position(line=0, ch=16)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_ROW
# =============================================================================
# Context Detection - Rule Contexts
# =============================================================================
def test_context_rule_start_on_indented_empty_line():
"""Test RULE_START context on empty indented line."""
text = "column amount:\n "
cursor = Position(line=1, ch=4)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.RULE_START
def test_context_rule_start_partial_keyword():
"""Test RULE_START context with partial keyword."""
text = "column amount:\n sty"
cursor = Position(line=1, ch=7)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.RULE_START
# =============================================================================
# Context Detection - Style Contexts
# =============================================================================
def test_context_style_args_after_open_paren():
"""Test STYLE_ARGS context after 'style('."""
text = "column amount:\n style("
cursor = Position(line=1, ch=10)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.STYLE_ARGS
def test_context_style_preset_inside_quotes():
"""Test STYLE_PRESET context inside style quotes."""
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.STYLE_PRESET
def test_context_style_param_after_comma():
"""Test STYLE_PARAM context after comma in style()."""
text = 'column amount:\n style("error", '
cursor = Position(line=1, ch=21)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.STYLE_PARAM
def test_context_boolean_value_after_bold():
"""Test BOOLEAN_VALUE context after 'bold='."""
text = "column amount:\n style(bold="
cursor = Position(line=1, ch=15)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BOOLEAN_VALUE
def test_context_boolean_value_after_italic():
"""Test BOOLEAN_VALUE context after 'italic='."""
text = "column amount:\n style(italic="
cursor = Position(line=1, ch=17)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BOOLEAN_VALUE
def test_context_color_value_after_color():
"""Test COLOR_VALUE context after 'color='."""
text = "column amount:\n style(color="
cursor = Position(line=1, ch=16)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLOR_VALUE
def test_context_color_value_after_background_color():
"""Test COLOR_VALUE context after 'background_color='."""
text = "column amount:\n style(background_color="
cursor = Position(line=1, ch=27)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLOR_VALUE
# =============================================================================
# Context Detection - Format Contexts
# =============================================================================
def test_context_format_preset_inside_quotes():
"""Test FORMAT_PRESET context inside format quotes."""
text = 'column amount:\n format("EU'
cursor = Position(line=1, ch=15)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_PRESET
def test_context_format_type_after_dot():
"""Test FORMAT_TYPE context after 'format.'."""
text = "column amount:\n format."
cursor = Position(line=1, ch=11)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_TYPE
def test_context_format_param_date():
"""Test FORMAT_PARAM_DATE context inside format.date()."""
text = "column amount:\n format.date("
cursor = Position(line=1, ch=16)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_PARAM_DATE
def test_context_format_param_text():
"""Test FORMAT_PARAM_TEXT context inside format.text()."""
text = "column amount:\n format.text("
cursor = Position(line=1, ch=16)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_PARAM_TEXT
def test_context_date_format_value():
"""Test DATE_FORMAT_VALUE context after 'format=' in format.date."""
text = "column amount:\n format.date(format="
cursor = Position(line=1, ch=23)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.DATE_FORMAT_VALUE
def test_context_transform_value():
"""Test TRANSFORM_VALUE context after 'transform='."""
text = "column amount:\n format.text(transform="
cursor = Position(line=1, ch=26)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.TRANSFORM_VALUE
# =============================================================================
# Context Detection - After Style/Format
# =============================================================================
def test_context_after_style_or_format():
"""Test AFTER_STYLE_OR_FORMAT context after closing paren."""
text = 'column amount:\n style("error")'
cursor = Position(line=1, ch=19)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.AFTER_STYLE_OR_FORMAT
# =============================================================================
# Context Detection - Condition Contexts
# =============================================================================
def test_context_condition_start_after_if():
"""Test CONDITION_START context after 'if '."""
text = 'column amount:\n style("error") if '
cursor = Position(line=1, ch=23)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.CONDITION_START
def test_context_condition_after_not():
"""Test CONDITION_AFTER_NOT context after 'if not '."""
text = 'column amount:\n style("error") if not '
cursor = Position(line=1, ch=27)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.CONDITION_AFTER_NOT
def test_context_column_ref_after_col_dot():
"""Test COLUMN_REF context after 'col.'."""
text = 'column amount:\n style("error") if col.'
cursor = Position(line=1, ch=28)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLUMN_REF
def test_context_column_ref_quoted():
"""Test COLUMN_REF_QUOTED context after 'col."'."""
text = 'column amount:\n style("error") if col."'
cursor = Position(line=1, ch=29)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLUMN_REF_QUOTED
def test_context_operator_after_value():
"""Test OPERATOR context after 'value '."""
text = 'column amount:\n style("error") if value '
cursor = Position(line=1, ch=30)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.OPERATOR
def test_context_operator_value_after_equals():
"""Test OPERATOR_VALUE context after 'value == '."""
text = 'column amount:\n style("error") if value == '
cursor = Position(line=1, ch=33)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.OPERATOR_VALUE
def test_context_between_and():
"""Test BETWEEN_AND context after 'between 0 '."""
text = 'column amount:\n style("error") if value between 0 '
cursor = Position(line=1, ch=39)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BETWEEN_AND
def test_context_between_value():
"""Test BETWEEN_VALUE context after 'between 0 and '."""
text = 'column amount:\n style("error") if value between 0 and '
cursor = Position(line=1, ch=43)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BETWEEN_VALUE
def test_context_in_list_start():
"""Test IN_LIST_START context after 'in '."""
text = 'column status:\n style("error") if value in '
cursor = Position(line=1, ch=33)
scope = DetectedScope(scope_type="column", column_name="status")
context = detect_context(text, cursor, scope)
assert context == Context.IN_LIST_START
def test_context_in_list_value():
"""Test IN_LIST_VALUE context after 'in ['."""
text = 'column status:\n style("error") if value in ['
cursor = Position(line=1, ch=34)
scope = DetectedScope(scope_type="column", column_name="status")
context = detect_context(text, cursor, scope)
assert context == Context.IN_LIST_VALUE
def test_context_in_list_value_after_comma():
"""Test IN_LIST_VALUE context after comma in list."""
text = 'column status:\n style("error") if value in ["a", '
cursor = Position(line=1, ch=39)
scope = DetectedScope(scope_type="column", column_name="status")
context = detect_context(text, cursor, scope)
assert context == Context.IN_LIST_VALUE
# =============================================================================
# Context Detection - Special Cases
# =============================================================================
def test_context_none_in_comment():
"""Test NONE context when cursor is in comment."""
text = "column amount:\n # comment"
cursor = Position(line=1, ch=15)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.NONE
# =============================================================================
# Suggestions Tests
# =============================================================================
def test_suggestions_scope_keyword(provider):
"""Test suggestions for SCOPE_KEYWORD context."""
scope = DetectedScope()
suggestions = get_suggestions(Context.SCOPE_KEYWORD, scope, provider)
labels = [s.label for s in suggestions]
assert "column" in labels
assert "row" in labels
assert "cell" in labels
def test_suggestions_style_preset(provider):
"""Test suggestions for STYLE_PRESET context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.STYLE_PRESET, scope, provider)
labels = [s.label for s in suggestions]
assert "primary" in labels
assert "error" in labels
assert "warning" in labels
assert "custom_highlight" in labels # From provider
def test_suggestions_format_type(provider):
"""Test suggestions for FORMAT_TYPE context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.FORMAT_TYPE, scope, provider)
labels = [s.label for s in suggestions]
assert "number" in labels
assert "date" in labels
assert "boolean" in labels
assert "text" in labels
assert "enum" in labels
def test_suggestions_operators(provider):
"""Test suggestions for OPERATOR context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.OPERATOR, scope, provider)
labels = [s.label for s in suggestions]
assert "==" in labels
assert "<" in labels
assert "contains" in labels
assert "in" in labels
assert "between" in labels
def test_suggestions_boolean_value(provider):
"""Test suggestions for BOOLEAN_VALUE context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.BOOLEAN_VALUE, scope, provider)
labels = [s.label for s in suggestions]
assert "True" in labels
assert "False" in labels
def test_suggestions_color_value(provider):
"""Test suggestions for COLOR_VALUE context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.COLOR_VALUE, scope, provider)
labels = [s.label for s in suggestions]
assert "red" in labels
assert "blue" in labels
assert "var(--color-primary)" in labels
def test_suggestions_column_values(provider):
"""Test suggestions for OPERATOR_VALUE context with column scope."""
scope = DetectedScope(scope_type="column", column_name="status")
suggestions = get_suggestions(Context.OPERATOR_VALUE, scope, provider)
labels = [s.label for s in suggestions]
# Base suggestions
assert "col." in labels
assert "True" in labels
assert "False" in labels
# Column values from provider
assert '"draft"' in labels
assert '"pending"' in labels
assert '"approved"' in labels
def test_suggestions_rule_start(provider):
"""Test suggestions for RULE_START context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.RULE_START, scope, provider)
labels = [s.label for s in suggestions]
assert "style(" in labels
assert "format(" in labels
assert "format." in labels
def test_suggestions_none_context(provider):
"""Test that NONE context returns empty suggestions."""
scope = DetectedScope()
suggestions = get_suggestions(Context.NONE, scope, provider)
assert suggestions == []
# =============================================================================
# Engine Integration Tests
# =============================================================================
def test_i_can_get_completions_for_style_preset(provider):
"""Test complete flow for style preset completion."""
text = 'column amount:\n style("'
cursor = Position(line=1, ch=11)
result = get_completions(text, cursor, provider)
assert not result.is_empty
labels = [s.label for s in result.suggestions]
assert "primary" in labels
assert "error" in labels
def test_i_can_get_completions_filters_by_prefix(provider):
"""Test that completions are filtered by prefix."""
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14)
result = get_completions(text, cursor, provider)
labels = [s.label for s in result.suggestions]
assert "error" in labels
assert "primary" not in labels
def test_i_can_get_completions_returns_correct_positions(provider):
"""Test that completion result has correct from/to positions."""
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14) # After "err"
result = get_completions(text, cursor, provider)
# from_pos should be at start of "err"
assert result.from_pos.line == 1
assert result.from_pos.ch == 11 # Start of "err"
# to_pos should be at end of "err"
assert result.to_pos.line == 1
assert result.to_pos.ch == 14 # End of "err"
def test_i_can_get_completions_at_scope_start(provider):
"""Test completions at the start of a new line (scope keywords)."""
text = ""
cursor = Position(line=0, ch=0)
result = get_completions(text, cursor, provider)
labels = [s.label for s in result.suggestions]
assert "column" in labels
assert "row" in labels
assert "cell" in labels
def test_i_can_get_completions_for_column_names(provider):
"""Test completions for column names."""
text = "column "
cursor = Position(line=0, ch=7)
result = get_completions(text, cursor, provider)
labels = [s.label for s in result.suggestions]
assert "id" in labels
assert "amount" in labels
assert "status" in labels
def test_i_can_get_completions_in_comment_returns_empty(provider):
"""Test that completions in comment are empty."""
text = "column amount:\n # comment"
cursor = Position(line=1, ch=15)
result = get_completions(text, cursor, provider)
assert result.is_empty
def test_i_can_create_formatting_completion_engine(provider):
"""Test that FormattingCompletionEngine can be instantiated."""
engine = FormattingCompletionEngine(provider)
assert engine.provider == provider
def test_i_can_use_engine_detect_scope(provider):
"""Test engine's detect_scope method."""
engine = FormattingCompletionEngine(provider)
text = "column amount:\n style()"
scope = engine.detect_scope(text, current_line=1)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
def test_i_can_use_engine_detect_context(provider):
"""Test engine's detect_context method."""
engine = FormattingCompletionEngine(provider)
text = "column amount:\n style("
cursor = Position(line=1, ch=10)
scope = DetectedScope(scope_type="column", column_name="amount")
context = engine.detect_context(text, cursor, scope)
assert context == Context.STYLE_ARGS

View File

@@ -0,0 +1,576 @@
"""
Tests for the DataGrid Formatting DSL parser.
Tests the parsing of DSL text into ScopedRule objects.
"""
import pytest
from myfasthtml.core.formatting.dsl import (
parse_dsl,
ColumnScope,
RowScope,
CellScope,
ScopedRule,
DSLSyntaxError,
)
from myfasthtml.core.formatting.dataclasses import (
Condition,
Style,
FormatRule,
NumberFormatter,
DateFormatter,
BooleanFormatter,
TextFormatter,
EnumFormatter,
)
# =============================================================================
# Scope Tests
# =============================================================================
class TestColumnScope:
"""Tests for column scope parsing."""
def test_i_can_parse_column_scope(self):
"""Test parsing a simple column scope."""
dsl = """
column amount:
style("error")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, ColumnScope)
assert rules[0].scope.column == "amount"
def test_i_can_parse_column_scope_with_quoted_name(self):
"""Test parsing a column scope with quoted name containing spaces."""
dsl = """
column "total amount":
style("error")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, ColumnScope)
assert rules[0].scope.column == "total amount"
class TestRowScope:
"""Tests for row scope parsing."""
def test_i_can_parse_row_scope(self):
"""Test parsing a row scope."""
dsl = """
row 0:
style("neutral")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, RowScope)
assert rules[0].scope.row == 0
def test_i_can_parse_row_scope_with_large_index(self):
"""Test parsing a row scope with a large index."""
dsl = """
row 999:
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert rules[0].scope.row == 999
class TestCellScope:
"""Tests for cell scope parsing."""
def test_i_can_parse_cell_scope_with_coords(self):
"""Test parsing a cell scope with coordinates."""
dsl = """
cell (amount, 3):
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, CellScope)
assert rules[0].scope.column == "amount"
assert rules[0].scope.row == 3
assert rules[0].scope.cell_id is None
def test_i_can_parse_cell_scope_with_quoted_column(self):
"""Test parsing a cell scope with quoted column name."""
dsl = """
cell ("total amount", 5):
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert rules[0].scope.column == "total amount"
assert rules[0].scope.row == 5
def test_i_can_parse_cell_scope_with_id(self):
"""Test parsing a cell scope with cell ID."""
dsl = """
cell tcell_grid1-3-2:
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, CellScope)
assert rules[0].scope.cell_id == "tcell_grid1-3-2"
assert rules[0].scope.column is None
assert rules[0].scope.row is None
# =============================================================================
# Style Tests
# =============================================================================
class TestStyleParsing:
"""Tests for style expression parsing."""
def test_i_can_parse_style_with_preset(self):
"""Test parsing style with preset only."""
dsl = """
column amount:
style("error")
"""
rules = parse_dsl(dsl)
assert rules[0].rule.style is not None
assert rules[0].rule.style.preset == "error"
def test_i_can_parse_style_without_preset(self):
"""Test parsing style without preset, with direct properties."""
dsl = """
column amount:
style(color="red", bold=True)
"""
rules = parse_dsl(dsl)
style = rules[0].rule.style
assert style.preset is None
assert style.color == "red"
assert style.font_weight == "bold"
def test_i_can_parse_style_with_preset_and_options(self):
"""Test parsing style with preset and additional options."""
dsl = """
column amount:
style("error", bold=True, italic=True)
"""
rules = parse_dsl(dsl)
style = rules[0].rule.style
assert style.preset == "error"
assert style.font_weight == "bold"
assert style.font_style == "italic"
@pytest.mark.parametrize("option,attr_name,attr_value", [
("bold=True", "font_weight", "bold"),
("italic=True", "font_style", "italic"),
("underline=True", "text_decoration", "underline"),
("strikethrough=True", "text_decoration", "line-through"),
])
def test_i_can_parse_style_options(self, option, attr_name, attr_value):
"""Test parsing individual style options."""
dsl = f"""
column amount:
style({option})
"""
rules = parse_dsl(dsl)
style = rules[0].rule.style
assert getattr(style, attr_name) == attr_value
# =============================================================================
# Format Tests
# =============================================================================
class TestFormatParsing:
"""Tests for format expression parsing."""
def test_i_can_parse_format_preset(self):
"""Test parsing format with preset."""
dsl = """
column amount:
format("EUR")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert formatter is not None
assert formatter.preset == "EUR"
def test_i_can_parse_format_preset_with_options(self):
"""Test parsing format preset with options."""
dsl = """
column amount:
format("EUR", precision=3)
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert formatter.preset == "EUR"
assert formatter.precision == 3
@pytest.mark.parametrize("format_type,formatter_class", [
("number", NumberFormatter),
("date", DateFormatter),
("boolean", BooleanFormatter),
("text", TextFormatter),
("enum", EnumFormatter),
])
def test_i_can_parse_format_types(self, format_type, formatter_class):
"""Test parsing explicit format types."""
dsl = f"""
column amount:
format.{format_type}()
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, formatter_class)
def test_i_can_parse_format_number_with_options(self):
"""Test parsing format.number with all options."""
dsl = """
column amount:
format.number(precision=2, suffix=" EUR", thousands_sep=" ")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, NumberFormatter)
assert formatter.precision == 2
assert formatter.suffix == " EUR"
assert formatter.thousands_sep == " "
def test_i_can_parse_format_date_with_options(self):
"""Test parsing format.date with format option."""
dsl = """
column created_at:
format.date(format="%d/%m/%Y")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, DateFormatter)
assert formatter.format == "%d/%m/%Y"
def test_i_can_parse_format_boolean_with_options(self):
"""Test parsing format.boolean with all options."""
dsl = """
column active:
format.boolean(true_value="Oui", false_value="Non")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, BooleanFormatter)
assert formatter.true_value == "Oui"
assert formatter.false_value == "Non"
def test_i_can_parse_format_enum_with_source(self):
"""Test parsing format.enum with source mapping."""
dsl = """
column status:
format.enum(source={"draft": "Brouillon", "published": "Publie"})
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, EnumFormatter)
assert formatter.source == {"draft": "Brouillon", "published": "Publie"}
# =============================================================================
# Condition Tests
# =============================================================================
class TestConditionParsing:
"""Tests for condition parsing."""
@pytest.mark.parametrize("operator,dsl_op", [
("==", "=="),
("!=", "!="),
("<", "<"),
("<=", "<="),
(">", ">"),
(">=", ">="),
("contains", "contains"),
("startswith", "startswith"),
("endswith", "endswith"),
])
def test_i_can_parse_comparison_operators(self, operator, dsl_op):
"""Test parsing all comparison operators."""
dsl = f"""
column amount:
style("error") if value {dsl_op} 0
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition is not None
assert condition.operator == operator
@pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"])
def test_i_can_parse_unary_conditions(self, unary_op):
"""Test parsing unary conditions (isempty, isnotempty)."""
dsl = f"""
column name:
style("neutral") if value {unary_op}
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.operator == unary_op
def test_i_can_parse_condition_in(self):
"""Test parsing 'in' condition with list."""
dsl = """
column status:
style("success") if value in ["approved", "validated"]
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.operator == "in"
assert condition.value == ["approved", "validated"]
def test_i_can_parse_condition_between(self):
"""Test parsing 'between' condition."""
dsl = """
column score:
style("warning") if value between 30 and 70
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.operator == "between"
assert condition.value == [30, 70]
def test_i_can_parse_condition_negation(self):
"""Test parsing negated condition."""
dsl = """
column status:
style("error") if not value == "approved"
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.negate is True
assert condition.operator == "=="
def test_i_can_parse_condition_case_sensitive(self):
"""Test parsing case-sensitive condition."""
dsl = """
column name:
style("error") if value == "Error" (case)
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.case_sensitive is True
# =============================================================================
# Literal Tests
# =============================================================================
class TestLiteralParsing:
"""Tests for literal value parsing in conditions."""
@pytest.mark.parametrize("literal,expected_value,expected_type", [
('"hello"', "hello", str),
("'world'", "world", str),
("42", 42, int),
("-10", -10, int),
("3.14", 3.14, float),
("-2.5", -2.5, float),
("True", True, bool),
("False", False, bool),
("true", True, bool),
("false", False, bool),
])
def test_i_can_parse_literals(self, literal, expected_value, expected_type):
"""Test parsing various literal types in conditions."""
dsl = f"""
column amount:
style("error") if value == {literal}
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.value == expected_value
assert isinstance(condition.value, expected_type)
# =============================================================================
# Reference Tests
# =============================================================================
class TestReferenceParsing:
"""Tests for cell reference parsing in conditions."""
def test_i_can_parse_column_reference(self):
"""Test parsing column reference in condition."""
dsl = """
column actual:
style("error") if value > col.budget
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.value == {"col": "budget"}
def test_i_can_parse_column_reference_with_quoted_name(self):
"""Test parsing column reference with quoted name."""
dsl = """
column actual:
style("error") if value > col."max budget"
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.value == {"col": "max budget"}
# =============================================================================
# Complex Structure Tests
# =============================================================================
class TestComplexStructures:
"""Tests for complex DSL structures."""
def test_i_can_parse_multiple_rules_in_scope(self):
"""Test parsing multiple rules under one scope."""
dsl = """
column amount:
style("error") if value < 0
style("success") if value > 1000
format("EUR")
"""
rules = parse_dsl(dsl)
assert len(rules) == 3
# All rules share the same scope
for rule in rules:
assert isinstance(rule.scope, ColumnScope)
assert rule.scope.column == "amount"
def test_i_can_parse_multiple_scopes(self):
"""Test parsing multiple scopes."""
dsl = """
column amount:
format("EUR")
column status:
style("success") if value == "approved"
row 0:
style("neutral", bold=True)
"""
rules = parse_dsl(dsl)
assert len(rules) == 3
# First rule: column amount
assert isinstance(rules[0].scope, ColumnScope)
assert rules[0].scope.column == "amount"
# Second rule: column status
assert isinstance(rules[1].scope, ColumnScope)
assert rules[1].scope.column == "status"
# Third rule: row 0
assert isinstance(rules[2].scope, RowScope)
assert rules[2].scope.row == 0
def test_i_can_parse_style_and_format_combined(self):
"""Test parsing style and format on same line."""
dsl = """
column amount:
style("error") format("EUR") if value < 0
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
rule = rules[0].rule
assert rule.style is not None
assert rule.style.preset == "error"
assert rule.formatter is not None
assert rule.formatter.preset == "EUR"
assert rule.condition is not None
assert rule.condition.operator == "<"
def test_i_can_parse_comments(self):
"""Test that comments are ignored."""
dsl = """
# This is a comment
column amount:
# Another comment
style("error") if value < 0
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert rules[0].rule.style.preset == "error"
# =============================================================================
# Error Tests
# =============================================================================
class TestSyntaxErrors:
"""Tests for syntax error handling."""
def test_i_cannot_parse_invalid_syntax(self):
"""Test that invalid syntax raises DSLSyntaxError."""
dsl = """
column amount
style("error")
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)
def test_i_cannot_parse_missing_indent(self):
"""Test that missing indentation raises DSLSyntaxError."""
dsl = """
column amount:
style("error")
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)
def test_i_cannot_parse_empty_scope(self):
"""Test that empty scope raises DSLSyntaxError."""
dsl = """
column amount:
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)
def test_i_cannot_parse_invalid_operator(self):
"""Test that invalid operator raises DSLSyntaxError."""
dsl = """
column amount:
style("error") if value <> 0
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)

View File

@@ -0,0 +1,105 @@
"""Tests for FormattingDSL definition."""
import pytest
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.dsl.grammar import GRAMMAR
class TestFormattingDSL:
"""Tests for FormattingDSL class."""
def test_i_can_create_formatting_dsl(self):
"""Test that FormattingDSL can be instantiated."""
dsl = FormattingDSL()
assert dsl is not None
assert dsl.name == "Formatting DSL"
def test_i_can_get_formatting_dsl_grammar(self):
"""Test that get_grammar() returns the GRAMMAR constant."""
dsl = FormattingDSL()
grammar = dsl.get_grammar()
assert grammar == GRAMMAR
assert "scope" in grammar
assert "style_expr" in grammar
assert "format_expr" in grammar
@pytest.mark.parametrize(
"keyword",
["column", "row", "cell", "if", "not", "value", "and"],
)
def test_i_can_get_formatting_dsl_keywords(self, keyword):
"""Test that expected keywords are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert keyword in completions["keywords"]
@pytest.mark.parametrize(
"operator",
["==", "!=", "<=", "<", ">=", ">", "contains", "startswith", "endswith"],
)
def test_i_can_get_formatting_dsl_operators(self, operator):
"""Test that expected operators are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert operator in completions["operators"]
@pytest.mark.parametrize(
"function",
["style", "format"],
)
def test_i_can_get_formatting_dsl_functions(self, function):
"""Test that expected functions are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert function in completions["functions"]
@pytest.mark.parametrize(
"type_name",
["number", "date", "boolean", "text", "enum"],
)
def test_i_can_get_formatting_dsl_types(self, type_name):
"""Test that expected types are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert type_name in completions["types"]
def test_i_can_get_completions_is_cached(self):
"""Test that completions property is cached (same object returned)."""
dsl = FormattingDSL()
completions1 = dsl.completions
completions2 = dsl.completions
assert completions1 is completions2
def test_i_can_get_lezer_grammar_is_cached(self):
"""Test that lezer_grammar property is cached (same object returned)."""
dsl = FormattingDSL()
lezer1 = dsl.lezer_grammar
lezer2 = dsl.lezer_grammar
assert lezer1 is lezer2
def test_i_can_get_editor_config(self):
"""Test that get_editor_config() returns expected structure."""
dsl = FormattingDSL()
config = dsl.get_editor_config()
assert "name" in config
assert "lezerGrammar" in config
assert "completions" in config
assert config["name"] == "Formatting DSL"

View File

@@ -0,0 +1,112 @@
import shutil
import pytest
from dbengine.handlers import handlers
from pandas import DataFrame
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry, DATAGRIDS_REGISTRY_ENTRY_KEY
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.dbmanager import DbManager
from myfasthtml.core.instances import SingleInstance, InstancesManager
def clean_db_object(obj):
return {k: v for k, v in obj.items() if not k.startswith("__")}
@pytest.fixture(scope="session")
def session():
handlers.register_handler(DataFrameHandler())
return {
"user_info": {
"id": "test_tenant_id",
"email": "test@email.com",
"username": "test user",
"role": [],
}
}
@pytest.fixture
def parent(session):
return SingleInstance(session=session, _id="test_parent_id")
@pytest.fixture
def db_manager(parent):
shutil.rmtree("TestDb", ignore_errors=True)
db_manager_instance = DbManager(parent, root="TestDb", auto_register=True)
yield db_manager_instance
shutil.rmtree("TestDb", ignore_errors=True)
InstancesManager.reset()
@pytest.fixture
def dg(parent):
# the table must be created
data = {"name": ["john", "jane"], "id": [1, 2]}
df = DataFrame(data)
dgc = DatagridConf("namespace", "table_name")
datagrid = DataGrid(parent, conf=dgc, save_state=True)
datagrid.init_from_dataframe(df, init_state=True)
yield datagrid
datagrid.dispose()
@pytest.fixture
def dgr(parent, db_manager):
return DataGridsRegistry(parent)
def test_entry_is_created_at_startup(db_manager, dgr, ):
assert db_manager.exists_entry(DATAGRIDS_REGISTRY_ENTRY_KEY)
assert clean_db_object(db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY)) == {}
def test_i_can_put_a_table_in_registry(dgr):
dgr.put("namespace", "name", "datagrid_id")
dgr.put("namespace2", "name2", "datagrid_id2")
assert dgr.get_all_tables() == ["namespace.name", "namespace2.name2"]
def test_i_can_columns_names_for_a_table(dgr, dg):
expected = ["__row_index__", "name", "id"] if dg.get_state().row_index else ["name", "id"]
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert dgr.get_columns(table_full_name) == expected
def test_i_can_get_columns_values(dgr, dg):
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert dgr.get_column_values(table_full_name, "name") == ["john", "jane"]
def test_i_can_get_row_count(dgr, dg):
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert dgr.get_row_count(table_full_name) == 2
def test_i_can_manage_when_table_name_does_not_exist(dgr):
assert dgr.get_columns("namespace.name") == []
assert dgr.get_row_count("namespace.name") == 0
def test_i_can_manage_when_column_does_not_exist(dgr, dg):
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert len(dgr.get_columns(table_full_name)) > 0
assert dgr.get_column_values("namespace.name", "") == []

View File

@@ -1,4 +1,4 @@
from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
from myfasthtml.core.vis_network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
class TestFromNestedDict: