Working on Formating DSL completion
This commit is contained in:
442
.claude/commands/developer-control.md
Normal file
442
.claude/commands/developer-control.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
1317
docs/DataGrid Formatting DSL.md
Normal file
1317
docs/DataGrid Formatting DSL.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ dependencies = [
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"websockets",
|
||||
"lark",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -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
|
||||
|
||||
17
src/app.py
17
src/app.py
@@ -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):
|
||||
"""
|
||||
|
||||
11
src/myfasthtml/assets/Readme.md
Normal file
11
src/myfasthtml/assets/Readme.md
Normal 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
|
||||
```
|
||||
1
src/myfasthtml/assets/codemirror.min.css
vendored
Normal file
1
src/myfasthtml/assets/codemirror.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/codemirror.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
1
src/myfasthtml/assets/placeholder.min.js
vendored
Normal file
1
src/myfasthtml/assets/placeholder.min.js
vendored
Normal 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)})});
|
||||
1
src/myfasthtml/assets/show-hint.min.css
vendored
Normal file
1
src/myfasthtml/assets/show-hint.min.css
vendored
Normal 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}
|
||||
1
src/myfasthtml/assets/show-hint.min.js
vendored
Normal file
1
src/myfasthtml/assets/show-hint.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
|
||||
196
src/myfasthtml/controls/DslEditor.py
Normal file
196
src/myfasthtml/controls/DslEditor.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
82
src/myfasthtml/core/DataGridsRegistry.py
Normal file
82
src/myfasthtml/core/DataGridsRegistry.py
Normal 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()}
|
||||
14
src/myfasthtml/core/completions.py
Normal file
14
src/myfasthtml/core/completions.py
Normal 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 = {}
|
||||
@@ -14,6 +14,7 @@ FILTER_INPUT_CID = "__filter_input__"
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
Bindings = "/bindings"
|
||||
Completions = "/completions"
|
||||
|
||||
|
||||
class ColumnType(Enum):
|
||||
|
||||
19
src/myfasthtml/core/dbengine_utils.py
Normal file
19
src/myfasthtml/core/dbengine_utils.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
0
src/myfasthtml/core/dsl/__init__.py
Normal file
0
src/myfasthtml/core/dsl/__init__.py
Normal file
84
src/myfasthtml/core/dsl/base.py
Normal file
84
src/myfasthtml/core/dsl/base.py
Normal 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,
|
||||
}
|
||||
172
src/myfasthtml/core/dsl/base_completion.py
Normal file
172
src/myfasthtml/core/dsl/base_completion.py
Normal 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__
|
||||
38
src/myfasthtml/core/dsl/base_provider.py
Normal file
38
src/myfasthtml/core/dsl/base_provider.py
Normal 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"])
|
||||
"""
|
||||
...
|
||||
256
src/myfasthtml/core/dsl/lark_to_lezer.py
Normal file
256
src/myfasthtml/core/dsl/lark_to_lezer.py
Normal 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),
|
||||
}
|
||||
103
src/myfasthtml/core/dsl/types.py
Normal file
103
src/myfasthtml/core/dsl/types.py
Normal 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 = ""
|
||||
226
src/myfasthtml/core/dsl/utils.py
Normal file
226
src/myfasthtml/core/dsl/utils.py
Normal 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
|
||||
69
src/myfasthtml/core/formatting/dsl/__init__.py
Normal file
69
src/myfasthtml/core/formatting/dsl/__init__.py
Normal 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",
|
||||
]
|
||||
323
src/myfasthtml/core/formatting/dsl/completion/contexts.py
Normal file
323
src/myfasthtml/core/formatting/dsl/completion/contexts.py
Normal 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
|
||||
109
src/myfasthtml/core/formatting/dsl/completion/engine.py
Normal file
109
src/myfasthtml/core/formatting/dsl/completion/engine.py
Normal 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)
|
||||
245
src/myfasthtml/core/formatting/dsl/completion/presets.py
Normal file
245
src/myfasthtml/core/formatting/dsl/completion/presets.py
Normal 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"),
|
||||
]
|
||||
94
src/myfasthtml/core/formatting/dsl/completion/provider.py
Normal file
94
src/myfasthtml/core/formatting/dsl/completion/provider.py
Normal 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
|
||||
"""
|
||||
...
|
||||
311
src/myfasthtml/core/formatting/dsl/completion/suggestions.py
Normal file
311
src/myfasthtml/core/formatting/dsl/completion/suggestions.py
Normal 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 []
|
||||
23
src/myfasthtml/core/formatting/dsl/definition.py
Normal file
23
src/myfasthtml/core/formatting/dsl/definition.py
Normal 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
|
||||
55
src/myfasthtml/core/formatting/dsl/exceptions.py
Normal file
55
src/myfasthtml/core/formatting/dsl/exceptions.py
Normal 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 ""))
|
||||
159
src/myfasthtml/core/formatting/dsl/grammar.py
Normal file
159
src/myfasthtml/core/formatting/dsl/grammar.py
Normal 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
|
||||
"""
|
||||
111
src/myfasthtml/core/formatting/dsl/parser.py
Normal file
111
src/myfasthtml/core/formatting/dsl/parser.py
Normal 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
|
||||
47
src/myfasthtml/core/formatting/dsl/scopes.py
Normal file
47
src/myfasthtml/core/formatting/dsl/scopes.py
Normal 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
|
||||
430
src/myfasthtml/core/formatting/dsl/transformer.py
Normal file
430
src/myfasthtml/core/formatting/dsl/transformer.py
Normal 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
|
||||
@@ -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=}")
|
||||
|
||||
@@ -48,4 +48,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -69,4 +69,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -30,4 +30,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -44,4 +44,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -37,4 +37,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -43,4 +43,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -44,4 +44,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -30,4 +30,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -26,4 +26,4 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -25,4 +25,4 @@ def index():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
15
src/myfasthtml/examples/formatter_config.py
Normal file
15
src/myfasthtml/examples/formatter_config.py
Normal 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)
|
||||
@@ -12,4 +12,4 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
tests/core/dsl/__init__.py
Normal file
0
tests/core/dsl/__init__.py
Normal file
137
tests/core/dsl/test_base_completion.py
Normal file
137
tests/core/dsl/test_base_completion.py
Normal 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
|
||||
172
tests/core/dsl/test_lark_to_lezer.py
Normal file
172
tests/core/dsl/test_lark_to_lezer.py
Normal 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
|
||||
145
tests/core/dsl/test_types.py
Normal file
145
tests/core/dsl/test_types.py
Normal 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 == ""
|
||||
261
tests/core/dsl/test_utils.py
Normal file
261
tests/core/dsl/test_utils.py
Normal 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("") == ""
|
||||
0
tests/core/formatting/dsl/__init__.py
Normal file
0
tests/core/formatting/dsl/__init__.py
Normal file
770
tests/core/formatting/dsl/test_completion.py
Normal file
770
tests/core/formatting/dsl/test_completion.py
Normal 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
|
||||
576
tests/core/formatting/test_dsl_parser.py
Normal file
576
tests/core/formatting/test_dsl_parser.py
Normal 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)
|
||||
105
tests/core/formatting/test_formatting_dsl_definition.py
Normal file
105
tests/core/formatting/test_formatting_dsl_definition.py
Normal 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"
|
||||
112
tests/core/test_datagrid_registry.py
Normal file
112
tests/core/test_datagrid_registry.py
Normal 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", "") == []
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user