From d7ec99c3d9878bdf8fef36478ae7acdba0ff8545 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 31 Jan 2026 19:09:14 +0100 Subject: [PATCH] Working on Formating DSL completion --- .claude/commands/developer-control.md | 442 ++++++ .claude/commands/developer.md | 3 +- .claude/commands/reset.md | 2 + .claude/commands/technical-writer.md | 1 + .claude/commands/unit-tester.md | 1 + CLAUDE.md | 11 + README.md | 4 +- docs/DataGrid Formatting DSL.md | 1317 +++++++++++++++++ pyproject.toml | 1 + requirements.txt | 2 + src/app.py | 17 +- src/myfasthtml/assets/Readme.md | 11 + src/myfasthtml/assets/codemirror.min.css | 1 + src/myfasthtml/assets/codemirror.min.js | 1 + src/myfasthtml/assets/myfasthtml.js | 169 +++ src/myfasthtml/assets/placeholder.min.js | 1 + src/myfasthtml/assets/show-hint.min.css | 1 + src/myfasthtml/assets/show-hint.min.js | 1 + src/myfasthtml/controls/CommandsDebugger.py | 2 +- src/myfasthtml/controls/DataGrid.py | 59 +- .../controls/DataGridColumnsManager.py | 30 +- src/myfasthtml/controls/DataGridsManager.py | 6 +- src/myfasthtml/controls/DslEditor.py | 196 +++ src/myfasthtml/controls/InstancesDebugger.py | 2 +- src/myfasthtml/controls/Panel.py | 8 + src/myfasthtml/controls/datagrid_objects.py | 4 +- src/myfasthtml/core/DataGridsRegistry.py | 82 + src/myfasthtml/core/completions.py | 14 + src/myfasthtml/core/constants.py | 1 + src/myfasthtml/core/dbengine_utils.py | 19 + src/myfasthtml/core/dbmanager.py | 3 +- src/myfasthtml/core/dsl/__init__.py | 0 src/myfasthtml/core/dsl/base.py | 84 ++ src/myfasthtml/core/dsl/base_completion.py | 172 +++ src/myfasthtml/core/dsl/base_provider.py | 38 + src/myfasthtml/core/dsl/lark_to_lezer.py | 256 ++++ src/myfasthtml/core/dsl/types.py | 103 ++ src/myfasthtml/core/dsl/utils.py | 226 +++ .../core/formatting/dsl/__init__.py | 69 + .../formatting/dsl/completion/__init__.py | 0 .../formatting/dsl/completion/contexts.py | 323 ++++ .../core/formatting/dsl/completion/engine.py | 109 ++ .../core/formatting/dsl/completion/presets.py | 245 +++ .../formatting/dsl/completion/provider.py | 94 ++ .../formatting/dsl/completion/suggestions.py | 311 ++++ .../core/formatting/dsl/definition.py | 23 + .../core/formatting/dsl/exceptions.py | 55 + src/myfasthtml/core/formatting/dsl/grammar.py | 159 ++ src/myfasthtml/core/formatting/dsl/parser.py | 111 ++ src/myfasthtml/core/formatting/dsl/scopes.py | 47 + .../core/formatting/dsl/transformer.py | 430 ++++++ src/myfasthtml/core/utils.py | 14 + ...{network_utils.py => vis_network_utils.py} | 0 src/myfasthtml/examples/binding_checkbox.py | 2 +- src/myfasthtml/examples/binding_datalist.py | 2 +- src/myfasthtml/examples/binding_input.py | 2 +- src/myfasthtml/examples/binding_radio.py | 2 +- src/myfasthtml/examples/binding_range.py | 2 +- src/myfasthtml/examples/binding_select.py | 2 +- .../examples/binding_select_multiple.py | 2 +- src/myfasthtml/examples/binding_textarea.py | 2 +- src/myfasthtml/examples/clickme.py | 2 +- .../examples/command_with_htmx_params.py | 2 +- src/myfasthtml/examples/formatter_config.py | 15 + src/myfasthtml/examples/helloworld.py | 2 +- src/myfasthtml/myfastapp.py | 28 +- tests/core/dsl/__init__.py | 0 tests/core/dsl/test_base_completion.py | 137 ++ tests/core/dsl/test_lark_to_lezer.py | 172 +++ tests/core/dsl/test_types.py | 145 ++ tests/core/dsl/test_utils.py | 261 ++++ tests/core/formatting/dsl/__init__.py | 0 tests/core/formatting/dsl/test_completion.py | 770 ++++++++++ tests/core/formatting/test_dsl_parser.py | 576 +++++++ .../test_formatting_dsl_definition.py | 105 ++ tests/core/test_datagrid_registry.py | 112 ++ tests/core/test_network_utils.py | 2 +- 77 files changed, 7563 insertions(+), 63 deletions(-) create mode 100644 .claude/commands/developer-control.md create mode 100644 docs/DataGrid Formatting DSL.md create mode 100644 src/myfasthtml/assets/Readme.md create mode 100644 src/myfasthtml/assets/codemirror.min.css create mode 100644 src/myfasthtml/assets/codemirror.min.js create mode 100644 src/myfasthtml/assets/placeholder.min.js create mode 100644 src/myfasthtml/assets/show-hint.min.css create mode 100644 src/myfasthtml/assets/show-hint.min.js create mode 100644 src/myfasthtml/controls/DslEditor.py create mode 100644 src/myfasthtml/core/DataGridsRegistry.py create mode 100644 src/myfasthtml/core/completions.py create mode 100644 src/myfasthtml/core/dbengine_utils.py create mode 100644 src/myfasthtml/core/dsl/__init__.py create mode 100644 src/myfasthtml/core/dsl/base.py create mode 100644 src/myfasthtml/core/dsl/base_completion.py create mode 100644 src/myfasthtml/core/dsl/base_provider.py create mode 100644 src/myfasthtml/core/dsl/lark_to_lezer.py create mode 100644 src/myfasthtml/core/dsl/types.py create mode 100644 src/myfasthtml/core/dsl/utils.py create mode 100644 src/myfasthtml/core/formatting/dsl/__init__.py create mode 100644 src/myfasthtml/core/formatting/dsl/completion/__init__.py create mode 100644 src/myfasthtml/core/formatting/dsl/completion/contexts.py create mode 100644 src/myfasthtml/core/formatting/dsl/completion/engine.py create mode 100644 src/myfasthtml/core/formatting/dsl/completion/presets.py create mode 100644 src/myfasthtml/core/formatting/dsl/completion/provider.py create mode 100644 src/myfasthtml/core/formatting/dsl/completion/suggestions.py create mode 100644 src/myfasthtml/core/formatting/dsl/definition.py create mode 100644 src/myfasthtml/core/formatting/dsl/exceptions.py create mode 100644 src/myfasthtml/core/formatting/dsl/grammar.py create mode 100644 src/myfasthtml/core/formatting/dsl/parser.py create mode 100644 src/myfasthtml/core/formatting/dsl/scopes.py create mode 100644 src/myfasthtml/core/formatting/dsl/transformer.py rename src/myfasthtml/core/{network_utils.py => vis_network_utils.py} (100%) create mode 100644 src/myfasthtml/examples/formatter_config.py create mode 100644 tests/core/dsl/__init__.py create mode 100644 tests/core/dsl/test_base_completion.py create mode 100644 tests/core/dsl/test_lark_to_lezer.py create mode 100644 tests/core/dsl/test_types.py create mode 100644 tests/core/dsl/test_utils.py create mode 100644 tests/core/formatting/dsl/__init__.py create mode 100644 tests/core/formatting/dsl/test_completion.py create mode 100644 tests/core/formatting/test_dsl_parser.py create mode 100644 tests/core/formatting/test_formatting_dsl_definition.py create mode 100644 tests/core/test_datagrid_registry.py diff --git a/.claude/commands/developer-control.md b/.claude/commands/developer-control.md new file mode 100644 index 0000000..9035fda --- /dev/null +++ b/.claude/commands/developer-control.md @@ -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 diff --git a/.claude/commands/developer.md b/.claude/commands/developer.md index 19b4dc6..9db3c04 100644 --- a/.claude/commands/developer.md +++ b/.claude/commands/developer.md @@ -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 diff --git a/.claude/commands/reset.md b/.claude/commands/reset.md index ebcae23..e634aa5 100644 --- a/.claude/commands/reset.md +++ b/.claude/commands/reset.md @@ -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 diff --git a/.claude/commands/technical-writer.md b/.claude/commands/technical-writer.md index 524c1b1..707c1ac 100644 --- a/.claude/commands/technical-writer.md +++ b/.claude/commands/technical-writer.md @@ -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 diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md index d91c44d..ede3a93 100644 --- a/.claude/commands/unit-tester.md +++ b/.claude/commands/unit-tester.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 947bdb1..b1fecd5 100644 --- a/CLAUDE.md +++ b/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 diff --git a/README.md b/README.md index 046c816..164c99d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/DataGrid Formatting DSL.md b/docs/DataGrid Formatting DSL.md new file mode 100644 index 0000000..ab8b909 --- /dev/null +++ b/docs/DataGrid Formatting DSL.md @@ -0,0 +1,1317 @@ +# DataGrid Formatting DSL + +## Introduction + +This document describes the Domain Specific Language (DSL) for defining formatting rules in the DataGrid component. + +### Purpose + +The DSL provides a concise, readable way to define conditional formatting rules for cells, rows, and columns. It allows users to: + +- Apply styles (colors, fonts, decorations) based on cell values +- Format values for display (currencies, dates, percentages) +- Reference other cells for cross-column/row comparisons + +### Two Modes + +The formatting system offers two interfaces: + +| Mode | Target Users | Description | +|------|--------------|-------------| +| **Advanced (DSL)** | Developers, power users | Text-based rules with Python-like syntax | +| **Basic (GUI)** | End users | Visual rule builder (80/20 coverage) | + +This document focuses on the **Advanced DSL mode**, which covers 100% of formatting capabilities. + +--- + +## Complete Syntax + +### Overview + +A DSL document consists of one or more **scopes**, each containing one or more **rules**: + +```python +: + + + ... + +: + + ... +``` + +Rules are indented (Python-style) under their scope. + +### Scopes + +Scopes define which cells a rule applies to: + +| Scope | Syntax | Applies To | +|-------|--------|------------| +| **Column** | `column :` | All cells in the column | +| **Row** | `row :` | All cells in the row | +| **Cell (coordinates)** | `cell (, ):` | Single cell by position | +| **Cell (ID)** | `cell :` | Single cell by ID | + +**Column scope:** + +```python +# Simple column name +column amount: + style("error") if value < 0 + +# Column name with spaces (quoted) +column "total amount": + format("EUR") + +# Both syntaxes are valid +column status: +column "status": +``` + +**Row scope:** + +```python +# Row by index (0-based) +row 0: + style("neutral", bold=True) + +row 5: + style("highlight") +``` + +**Cell scope:** + +```python +# By coordinates (column, row) +cell (amount, 3): + style("highlight") + +cell ("total amount", 0): + style("neutral", bold=True) + +# By cell ID +cell tcell_grid1-3-2: + style(background_color="yellow") +``` + +### Rules + +A rule consists of optional **style**, optional **format**, and optional **condition**: + +``` +[style(...)] [format(...)] [if ] +``` + +At least one of `style()` or `format()` must be present. + +**Examples:** + +```python +# Style only +style("error") + +# Format only +format("EUR") + +# Style + Format +style("error") format("EUR") + +# With condition +style("error") if value < 0 + +# All combined +style("error") format("EUR") if value < 0 +``` + +### Style + +The `style()` function applies visual formatting to cells. + +**Syntax:** + +```python +style() +style(, ) +style() +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `preset` | string (positional) | Preset name (optional) | +| `background_color` | string | Background color | +| `color` | string | Text color | +| `bold` | boolean | Bold text | +| `italic` | boolean | Italic text | +| `underline` | boolean | Underlined text | +| `strikethrough` | boolean | Strikethrough text | +| `font_size` | string | Font size (e.g., "12px", "0.9em") | + +**Available presets (DaisyUI 5):** + +| Preset | Background | Text | +|--------|------------|------| +| `primary` | `var(--color-primary)` | `var(--color-primary-content)` | +| `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` | +| `accent` | `var(--color-accent)` | `var(--color-accent-content)` | +| `neutral` | `var(--color-neutral)` | `var(--color-neutral-content)` | +| `info` | `var(--color-info)` | `var(--color-info-content)` | +| `success` | `var(--color-success)` | `var(--color-success-content)` | +| `warning` | `var(--color-warning)` | `var(--color-warning-content)` | +| `error` | `var(--color-error)` | `var(--color-error-content)` | + +**Examples:** + +```python +# Preset only +style("error") + +# Preset with overrides +style("error", bold=True) +style("success", italic=True, underline=True) + +# No preset, direct properties +style(color="red", bold=True) +style(background_color="#ffeeee", color="#cc0000") +``` + +### Format + +The `format()` function transforms cell values for display. + +**Syntax:** + +```python +format() +format(, ) +format.() +``` + +**Using presets:** + +```python +# Preset only +format("EUR") + +# Preset with overrides +format("EUR", precision=3) +format("percentage", precision=0) +``` + +**Available presets:** + +| Preset | Type | Description | +|--------|------|-------------| +| `EUR` | number | Euro currency (1 234,56 €) | +| `USD` | number | US Dollar ($1,234.56) | +| `percentage` | number | Percentage (×100, adds %) | +| `short_date` | date | DD/MM/YYYY | +| `iso_date` | date | YYYY-MM-DD | +| `yes_no` | boolean | Yes/No | + +**Using explicit types:** + +When not using a preset, specify the type explicitly: + +```python +format.number(precision=2, suffix=" €", thousands_sep=" ") +format.date(format="%d/%m/%Y") +format.boolean(true_value="Oui", false_value="Non") +format.text(max_length=50, ellipsis="...") +format.enum(source={"draft": "Draft", "published": "Published"}) +``` + +**Type-specific parameters:** + +**`format.number`:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `prefix` | string | `""` | Text before value | +| `suffix` | string | `""` | Text after value | +| `thousands_sep` | string | `""` | Thousands separator | +| `decimal_sep` | string | `"."` | Decimal separator | +| `precision` | int | `0` | Decimal places | +| `multiplier` | number | `1` | Multiply before display | + +**`format.date`:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `format` | string | `"%Y-%m-%d"` | strftime pattern | + +**`format.boolean`:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `true_value` | string | `"true"` | Display for true | +| `false_value` | string | `"false"` | Display for false | +| `null_value` | string | `""` | Display for null | + +**`format.text`:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `transform` | string | - | `"uppercase"`, `"lowercase"`, `"capitalize"` | +| `max_length` | int | - | Truncate if exceeded | +| `ellipsis` | string | `"..."` | Suffix when truncated | + +**`format.enum`:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `source` | object | - | Mapping or datagrid reference | +| `default` | string | `""` | Label for unknown values | + +### Conditions + +Conditions determine when a rule applies. + +**Syntax:** + +```python +if +if +``` + +**Operators:** + +| Operator | Description | Example | +|----------|-------------|---------| +| `==` | Equal | `value == 0` | +| `!=` | Not equal | `value != ""` | +| `<` | Less than | `value < 0` | +| `<=` | Less or equal | `value <= 100` | +| `>` | Greater than | `value > 1000` | +| `>=` | Greater or equal | `value >= 0` | +| `contains` | String contains | `value contains "error"` | +| `startswith` | String starts with | `value startswith "ERR"` | +| `endswith` | String ends with | `value endswith ".pdf"` | +| `in` | Value in list | `value in ["A", "B", "C"]` | +| `between` | Value in range | `value between 0 and 100` | +| `isempty` | Is null/empty | `value isempty` | +| `isnotempty` | Is not null/empty | `value isnotempty` | + +**Negation:** + +Use `not` to negate any condition: + +```python +style("error") if not value in ["valid", "approved"] +style("warning") if not value contains "OK" +``` + +**Case sensitivity:** + +String comparisons are case-insensitive by default. Use `(case)` modifier for case-sensitive: + +```python +style("error") if value == "Error" (case) +style("warning") if value contains "WARN" (case) +``` + +### References + +References allow comparing with values from other cells. + +**Syntax:** + +| Reference | Description | Example | +|-----------|-------------|---------| +| `value` | Current cell value | `value < 0` | +| `col.` | Value from another column (same row) | `value > col.budget` | +| `col.""` | Column with spaces | `value > col."max amount"` | +| `row.` | Value from another row (same column) | `value != row.0` | +| `cell.-` | Specific cell by coordinates | `value == cell.status-0` | + +**Examples:** + +```python +# Compare with another column +column amount: + style("error") if value > col.budget + style("warning") if value > col.budget * 0.9 + +# Compare with header row +column total: + style("highlight") if value == row.0 + +# Compare with specific cell +column status: + style("success") if value == cell.status-0 +``` + +--- + +## Formal Grammar (EBNF) + +```ebnf +// Top-level structure +program : scope+ + +// Scopes +scope : scope_header NEWLINE 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 | QUOTED_STRING +cell_ref : "(" column_name "," INTEGER ")" | CELL_ID + +// Rules +rule : (style_expr format_expr? | format_expr style_expr?) condition? NEWLINE +condition : "if" comparison + +// Comparisons +comparison : "not"? (binary_comp | unary_comp) case_modifier? +binary_comp : operand operator operand + | operand "in" list + | operand "between" operand "and" operand +unary_comp : operand ("isempty" | "isnotempty") +case_modifier : "(" "case" ")" + +// Operators +operator : "==" | "!=" | "<" | "<=" | ">" | ">=" + | "contains" | "startswith" | "endswith" + +// Operands +operand : value_ref | column_ref | row_ref | cell_ref_expr | literal | arithmetic +value_ref : "value" +column_ref : "col." (NAME | QUOTED_STRING) +row_ref : "row." INTEGER +cell_ref_expr : "cell." NAME "-" INTEGER +literal : STRING | NUMBER | BOOLEAN +arithmetic : operand ("*" | "/" | "+" | "-") operand +list : "[" (literal ("," literal)*)? "]" + +// Style expression +style_expr : "style" "(" style_args ")" +style_args : (QUOTED_STRING ("," style_kwargs)?) | style_kwargs +style_kwargs : style_kwarg ("," style_kwarg)* +style_kwarg : NAME "=" (QUOTED_STRING | BOOLEAN | NUMBER) + +// Format expression +format_expr : format_preset | format_typed +format_preset : "format" "(" QUOTED_STRING ("," format_kwargs)? ")" +format_typed : "format" "." FORMAT_TYPE "(" format_kwargs? ")" +format_kwargs : format_kwarg ("," format_kwarg)* +format_kwarg : NAME "=" (QUOTED_STRING | BOOLEAN | NUMBER | dict) +dict : "{" (dict_entry ("," dict_entry)*)? "}" +dict_entry : QUOTED_STRING ":" QUOTED_STRING + +// Tokens +FORMAT_TYPE : "number" | "date" | "boolean" | "text" | "enum" +NAME : /[a-zA-Z_][a-zA-Z0-9_]*/ +QUOTED_STRING : /"[^"]*"/ | /'[^']*'/ +INTEGER : /[0-9]+/ +NUMBER : /[0-9]+(\.[0-9]+)?/ +BOOLEAN : "True" | "False" | "true" | "false" +CELL_ID : /tcell_[a-zA-Z0-9_-]+/ +NEWLINE : /\n/ +INDENT : /^[ \t]+/ +DEDENT : // decrease in indentation +``` + +--- + +## Examples + +### Basic Examples + +**Highlight negative values:** + +```python +column amount: + style("error") if value < 0 +``` + +**Format as currency:** + +```python +column price: + format("EUR") +``` + +**Conditional formatting with multiple rules:** + +```python +column status: + style("success") if value == "approved" + style("warning") if value == "pending" + style("error") if value == "rejected" +``` + +**Style header row:** + +```python +row 0: + style("neutral", bold=True) +``` + +### Advanced Examples + +**Compare with another column:** + +```python +column actual: + style("error") if value > col.budget + style("warning") if value > col.budget * 0.8 + style("success") if value <= col.budget * 0.8 +``` + +**Multiple formatting on same column:** + +```python +column amount: + format("EUR") + style("error") if value < 0 + style("success", bold=True) if value > 10000 +``` + +**Complex conditions:** + +```python +column score: + style("error") if value between 0 and 30 + style("warning") if value between 31 and 70 + style("success") if value between 71 and 100 + +column category: + style("primary") if value in ["A", "B", "C"] + style("secondary") if not value in ["A", "B", "C"] + +column name: + style("info") if value startswith "VIP" + style("neutral") if value isempty +``` + +**Enum formatting with display mapping:** + +```python +column status: + format.enum(source={"draft": "Brouillon", "pending": "En attente", "approved": "Approuvé"}, default="Inconnu") +``` + +**Complete example - Financial report:** + +```python +# Header styling +row 0: + style("neutral", bold=True) + +# Amount column +column amount: + format.number(precision=2, suffix=" €", thousands_sep=" ") + style("error") if value < 0 + style("success") if value > col.target + +# Percentage column +column progress: + format("percentage") + style("error") if value < 0.5 + style("warning") if value between 0.5 and 0.8 + style("success") if value > 0.8 + +# Status column +column status: + format.enum(source={"draft": "Draft", "review": "In Review", "approved": "Approved", "rejected": "Rejected"}) + style("neutral") if value == "draft" + style("info") if value == "review" + style("success") if value == "approved" + style("error") if value == "rejected" + +# Date column +column created_at: + format.date(format="%d %b %Y") + +# Highlight specific cell +cell (amount, 10): + style("accent", bold=True) +``` + +--- + +## Autocompletion + +The DSL editor provides context-aware autocompletion to help users write rules efficiently. + +### How It Works + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CodeMirror Editor │ +│ │ +│ User types: style("err| │ +│ ▲ │ +│ │ cursor position │ +└────────────────────────┼────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Autocompletion Request (HTMX) │ +│ { "text": "style(\"err", "cursor": 11, "context": "..." } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Python Backend │ +│ │ +│ 1. Parse partial input │ +│ 2. Determine context (inside style(), first arg) │ +│ 3. Filter matching presets: ["error"] │ +│ 4. Return suggestions │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Suggestions Dropdown │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ error - Red background for errors │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Completion Contexts + +| Context | Trigger | Suggestions | +|---------|---------|-------------| +| **Scope keyword** | Start of line | `column`, `row`, `cell` | +| **Column name** | After `column ` | Column names from DataGrid | +| **Style preset** | Inside `style("` | Style presets | +| **Style parameter** | Inside `style(... , ` | `bold`, `italic`, `color`, etc. | +| **Format preset** | Inside `format("` | Format presets | +| **Format type** | After `format.` | `number`, `date`, `boolean`, `text`, `enum` | +| **Format parameter** | Inside `format.number(` | Type-specific params | +| **Operator** | After operand | `==`, `<`, `contains`, etc. | +| **Column reference** | After `col.` | Column names | +| **Keyword** | After condition | `if`, `not`, `and`, `or` | + +### Example Completion Flow + +``` +User types │ Suggestions +───────────────┼──────────────────────────────────── +col │ column +column │ [column names from grid] +column amount: │ (new line, indent) + st │ style + style( │ "error", "warning", "success", ... + style("e │ "error" + style("err │ "error" (filtered) + style("error", │ bold=, italic=, color=, ... + style("error", b │ bold= + style("error", bold=│ True, False + style("error", bold=True) │ format(, if + style("error", bold=True) if │ value, col., row., not + style("error", bold=True) if value │ ==, !=, <, >, in, ... + style("error", bold=True) if value < │ [number input] +``` + +--- + +## CodeMirror Integration + +### DaisyUI Theme + +The editor uses DaisyUI CSS variables for consistent theming: + +```javascript +import { EditorView } from '@codemirror/view' +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { tags } from '@lezer/highlight' + +// Editor theme (container, cursor, selection) +const daisyEditorTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--color-base-100)', + color: 'var(--color-base-content)', + fontSize: '14px', + }, + '.cm-content': { + fontFamily: 'var(--font-mono, ui-monospace, monospace)', + padding: '8px 0', + }, + '.cm-line': { + padding: '0 8px', + }, + '.cm-cursor': { + borderLeftColor: 'var(--color-primary)', + borderLeftWidth: '2px', + }, + '.cm-selectionBackground': { + backgroundColor: 'color-mix(in srgb, var(--color-primary) 20%, transparent)', + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: 'color-mix(in srgb, var(--color-primary) 30%, transparent)', + }, + '.cm-gutters': { + backgroundColor: 'var(--color-base-200)', + color: 'var(--color-base-content)', + opacity: '0.5', + border: 'none', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--color-base-300)', + }, + '.cm-activeLine': { + backgroundColor: 'color-mix(in srgb, var(--color-base-content) 5%, transparent)', + }, +}) + +// Syntax highlighting +const daisyHighlightStyle = HighlightStyle.define([ + // Keywords: column, row, cell, if, not, and, or + { tag: tags.keyword, color: 'var(--color-primary)', fontWeight: 'bold' }, + + // Functions: style, format + { tag: tags.function(tags.variableName), color: 'var(--color-secondary)' }, + + // Strings: "error", "EUR", "approved" + { tag: tags.string, color: 'var(--color-success)' }, + + // Numbers: 0, 100, 3.14 + { tag: tags.number, color: 'var(--color-accent)' }, + + // Operators: ==, <, >, contains, in + { tag: tags.operator, color: 'var(--color-warning)' }, + { tag: tags.compareOperator, color: 'var(--color-warning)' }, + + // Booleans: True, False + { tag: tags.bool, color: 'var(--color-info)' }, + + // Property names: bold=, precision= + { tag: tags.propertyName, color: 'var(--color-base-content)', opacity: '0.8' }, + + // Comments (if supported later) + { tag: tags.comment, color: 'var(--color-base-content)', opacity: '0.5', fontStyle: 'italic' }, + + // Invalid/errors + { tag: tags.invalid, color: 'var(--color-error)', textDecoration: 'underline wavy' }, +]) + +// Combined extension +export const daisyTheme = [ + daisyEditorTheme, + syntaxHighlighting(daisyHighlightStyle), +] +``` + +### Lezer Grammar (Translated from lark) + +The lark grammar is translated to Lezer format for client-side parsing: + +```javascript +// formatrules.grammar (Lezer) + +@top Program { scope+ } + +@skip { space | newline } + +scope { + scopeHeader ":" indent rule+ dedent +} + +scopeHeader { + ColumnScope | RowScope | CellScope +} + +ColumnScope { kw<"column"> columnName } +RowScope { kw<"row"> Integer } +CellScope { kw<"cell"> cellRef } + +columnName { Name | QuotedString } +cellRef { "(" columnName "," Integer ")" | CellId } + +rule { + (styleExpr formatExpr? | formatExpr styleExpr?) condition? +} + +condition { kw<"if"> comparison } + +comparison { + not? (binaryComp | unaryComp) caseModifier? +} + +not { kw<"not"> } + +binaryComp { + operand compareOp operand | + operand kw<"in"> list | + operand kw<"between"> operand kw<"and"> operand +} + +unaryComp { + operand (kw<"isempty"> | kw<"isnotempty">) +} + +caseModifier { "(" kw<"case"> ")" } + +compareOp { "==" | "!=" | "<" | "<=" | ">" | ">=" | kw<"contains"> | kw<"startswith"> | kw<"endswith"> } + +operand { + ValueRef | ColumnRef | RowRef | CellRefExpr | literal | ArithmeticExpr +} + +ValueRef { kw<"value"> } +ColumnRef { kw<"col"> "." (Name | QuotedString) } +RowRef { kw<"row"> "." Integer } +CellRefExpr { kw<"cell"> "." Name "-" Integer } + +literal { QuotedString | Number | Boolean } + +ArithmeticExpr { operand !arith arithmeticOp operand } +arithmeticOp { "*" | "/" | "+" | "-" } + +list { "[" (literal ("," literal)*)? "]" } + +styleExpr { kw<"style"> "(" styleArgs ")" } +styleArgs { (QuotedString ("," styleKwargs)?) | styleKwargs } +styleKwargs { styleKwarg ("," styleKwarg)* } +styleKwarg { Name "=" (QuotedString | Boolean | Number) } + +formatExpr { formatPreset | formatTyped } +formatPreset { kw<"format"> "(" QuotedString ("," formatKwargs)? ")" } +formatTyped { kw<"format"> "." formatType "(" formatKwargs? ")" } +formatType { @specialize[@name=FormatType] } +formatKwargs { formatKwarg ("," formatKwarg)* } +formatKwarg { Name "=" (QuotedString | Boolean | Number | Dict) } + +Dict { "{" (dictEntry ("," dictEntry)*)? "}" } +dictEntry { QuotedString ":" QuotedString } + +kw { @specialize[@name={term}] } + +@tokens { + Name { @asciiLetter (@asciiLetter | @digit | "_")* } + QuotedString { '"' (!["\\] | "\\" _)* '"' | "'" (!['\\] | "\\" _)* "'" } + Integer { @digit+ } + Number { @digit+ ("." @digit+)? } + Boolean { "True" | "False" | "true" | "false" } + CellId { "tcell_" ((@asciiLetter | @digit | "_" | "-"))+ } + + space { " " | "\t" } + newline { "\n" | "\r\n" } + + @precedence { CellId, Name } + @precedence { Number, Integer } +} + +@precedence { + arith @left +} + +@detectDelim +``` + +### Editor Setup + +```javascript +import { EditorState } from '@codemirror/state' +import { EditorView, keymap, lineNumbers, drawSelection } from '@codemirror/view' +import { defaultKeymap, indentWithTab } from '@codemirror/commands' +import { indentOnInput, bracketMatching } from '@codemirror/language' +import { autocompletion } from '@codemirror/autocomplete' +import { linter } from '@codemirror/lint' + +import { formatRulesLanguage } from './formatrules-lang' // Generated from grammar +import { daisyTheme } from './daisy-theme' +import { formatRulesCompletion } from './completion' +import { formatRulesLinter } from './linter' + +function createFormatRulesEditor(container, initialValue, options = {}) { + const { onChange, columns = [], stylePresets = [], formatPresets = [] } = options + + const state = EditorState.create({ + doc: initialValue, + extensions: [ + lineNumbers(), + drawSelection(), + indentOnInput(), + bracketMatching(), + keymap.of([...defaultKeymap, indentWithTab]), + + // Language support + formatRulesLanguage(), + + // Theme + daisyTheme, + + // Autocompletion with context + autocompletion({ + override: [formatRulesCompletion({ columns, stylePresets, formatPresets })], + }), + + // Linting (validation) + linter(formatRulesLinter()), + + // Change callback + EditorView.updateListener.of((update) => { + if (update.docChanged && onChange) { + onChange(update.state.doc.toString()) + } + }), + ], + }) + + return new EditorView({ + state, + parent: container, + }) +} +``` + +--- + +## Technical Choices + +### Why lark (not pyparsing) + +Both `lark` and `pyparsing` are mature Python parsing libraries. We chose **lark** for the following reasons: + +| Criterion | lark | pyparsing | +|-----------|------|-----------| +| **Grammar definition** | Declarative EBNF string | Python combinators | +| **Portability to Lezer** | Direct translation possible | Manual rewrite required | +| **Grammar readability** | Standard BNF-like notation | Embedded in Python code | +| **Maintenance** | Single grammar source | Two separate grammars to sync | + +**The key factor**: We need the same grammar for both: +1. **Server-side** (Python): Validation and execution +2. **Client-side** (JavaScript): Syntax highlighting and autocompletion + +With lark's declarative EBNF grammar, we can translate it to Lezer (CodeMirror 6's parser system) with minimal effort. With pyparsing, the grammar is embedded in Python logic, making extraction and translation significantly harder. + +**Example comparison:** + +```python +# lark - declarative grammar (easy to translate) +grammar = """ + scope: "column" NAME ":" NEWLINE INDENT rule+ DEDENT + NAME: /[a-zA-Z_][a-zA-Z0-9_]*/ +""" +``` + +```javascript +// Lezer - similar structure +@top Program { scope+ } +scope { "column" Name ":" newline indent rule+ dedent } +Name { @asciiLetter (@asciiLetter | @digit | "_")* } +``` + +```python +# pyparsing - grammar in Python code (hard to translate) +NAME = Word(alphas, alphanums + "_") +scope = Keyword("column") + NAME + ":" + LineEnd() + IndentedBlock(rule) +``` + +### Why CodeMirror 6 + +For the web-based DSL editor, we chose **CodeMirror 6**: + +| Criterion | CodeMirror 6 | Monaco | Ace | +|-----------|--------------|--------|-----| +| **Bundle size** | ~150KB | ~2MB | ~300KB | +| **Custom languages** | Lezer parser | Monarch tokenizer | Custom modes | +| **Theming** | CSS variables | JSON themes | CSS | +| **DaisyUI integration** | Native (CSS vars) | Requires mapping | Partial | +| **Architecture** | Modern, modular | Monolithic | Legacy | + +CodeMirror 6's use of CSS variables makes it trivial to integrate with DaisyUI's theming system, ensuring visual consistency across the application. + +### Shared Grammar Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Grammar Source (EBNF) │ +│ lark format in Python │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────────┐ +│ Python (Server) │ │ JavaScript (Client) │ +│ lark parser │ │ Lezer parser │ +│ │ │ (translated from lark) │ +│ • Validation │ │ │ +│ • Execution │ │ • Syntax highlighting │ +│ • Error messages │ │ • Autocompletion │ +│ • Preset resolution │ │ • Error markers │ +└─────────────────────────┘ └─────────────────────────────┘ +``` + +--- + +## Autocompletion API + +### Overview + +The autocompletion system provides context-aware suggestions for the DSL editor. Intelligence runs **server-side** via a REST API, while the editor (CodeMirror 5) handles display and user interaction. + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CodeMirror Editor │ +│ User types: style("err| │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ REST API: /myfasthtml/autocompletion │ +│ Request: { "text": "...", "cursor": {"line": 1, "ch": 15} }│ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Python: Autocompletion Engine │ +│ │ +│ 1. Detect SCOPE from previous lines │ +│ - Find scope declaration (column/row/cell) │ +│ - Extract scope name (e.g., "amount" for column) │ +│ │ +│ 2. Detect COMPLETION CONTEXT at cursor position │ +│ - Analyze current line up to cursor │ +│ - Determine what kind of token is expected │ +│ │ +│ 3. Generate suggestions │ +│ - Query DatagridMetadataProvider if needed │ +│ - Filter suggestions by prefix │ +│ │ +│ 4. Return CodeMirror-compatible response │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Response: { │ +│ "from": {"line": 1, "ch": 12}, │ +│ "to": {"line": 1, "ch": 15}, │ +│ "suggestions": [{"label": "error", "detail": "..."}] │ +│ } │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Scope Detection + +Before determining completion context, the engine must identify the **current scope** by scanning backwards from the cursor position to find the most recent scope declaration. + +**Scope Types:** + +| Scope Type | Pattern | Extracted Info | +|------------|---------|----------------| +| Column | `column :` or `column "":` | Column name | +| Row | `row :` | Row index | +| Cell | `cell (, ):` | Column name + row index | + +**Algorithm:** + +1. Start from cursor line +2. Scan backwards for a non-indented line matching scope pattern +3. Extract scope type and parameters +4. If no scope found, cursor is at top level (suggest scope keywords) + +**Example:** + +```python +column status: # ← Scope: ColumnScope("status") + style("error") if | # ← Cursor here +``` + +When the engine needs column values for `OPERATOR_VALUE` context, it uses the detected scope to call `provider.get_column_values("status")`. + +### DatagridMetadataProvider + +A helper class providing access to DataGrid metadata for context-aware suggestions: + +```python +class DatagridMetadataProvider: + """Provides DataGrid metadata for autocompletion.""" + + def get_tables(self) -> list[str]: + """List of available DataGrids (namespace.name format).""" + ... + + def get_columns(self, table_name: str) -> list[str]: + """Column names for a specific DataGrid.""" + ... + + def get_column_values(self, column_name: str) -> list[Any]: + """Distinct values for a column in the current scope.""" + ... + + def get_row_count(self, table_name: str) -> int: + """Number of rows in a DataGrid.""" + ... +``` + +**Notes:** +- DataGrid names follow the pattern `namespace.name` (multi-level namespaces supported) +- The provider is passed at initialization, not with each API call +- Column values are fetched lazily when the scope is detected + +### API Interface + +**Request:** + +```json +{ + "text": "column amount:\n style(\"err", + "cursor": {"line": 1, "ch": 15} +} +``` + +- `text`: Full DSL document content +- `cursor.line`: 0-based line number +- `cursor.ch`: 0-based character position in line + +**Response:** + +```json +{ + "from": {"line": 1, "ch": 12}, + "to": {"line": 1, "ch": 15}, + "suggestions": [ + {"label": "error", "detail": "Red background for errors"}, + {"label": "warning", "detail": "Yellow background for warnings"} + ] +} +``` + +- `from`/`to`: Range to replace (word boundaries using delimiters) +- `suggestions`: List of completion items with label and optional detail + +### Completion Contexts + +| Context | Trigger | Suggestions | +|---------|---------|-------------| +| `SCOPE_KEYWORD` | Start of non-indented line | `column`, `row`, `cell` | +| `COLUMN_NAME` | After `column ` | Column names from DataGrid | +| `ROW_INDEX` | After `row ` | First 10 indices + last index | +| `CELL_START` | After `cell ` | `(` | +| `CELL_COLUMN` | After `cell (` | Column names | +| `CELL_ROW` | After `cell (col, ` | First 10 indices + last index | +| `RULE_START` | Start of indented line (after scope) | `style(`, `format(`, `format.` | +| `STYLE_ARGS` | After `style(` (no quote) | Presets with quotes + named params (`bold=`, `color=`, ...) | +| `STYLE_PRESET` | Inside `style("` | Style presets | +| `STYLE_PARAM` | After comma in `style(...)` | `bold=`, `italic=`, `underline=`, `strikethrough=`, `color=`, `background_color=`, `font_size=` | +| `FORMAT_PRESET` | Inside `format("` | Format presets | +| `FORMAT_TYPE` | After `format.` | `number`, `date`, `boolean`, `text`, `enum` | +| `FORMAT_PARAM_DATE` | Inside `format.date(` | `format=` | +| `FORMAT_PARAM_TEXT` | Inside `format.text(` | `transform=`, `max_length=`, `ellipsis=` | +| `AFTER_STYLE_OR_FORMAT` | After `)` of style/format | `style(`, `format(`, `format.`, `if` | +| `CONDITION_START` | After `if ` | `value`, `col.`, `not` | +| `CONDITION_AFTER_NOT` | After `if not ` | `value`, `col.` | +| `COLUMN_REF` | After `col.` | Column names | +| `COLUMN_REF_QUOTED` | After `col."` | Column names (with closing quote) | +| `OPERATOR` | After operand (`value`, literal, ref) | `==`, `!=`, `<`, `<=`, `>`, `>=`, `contains`, `startswith`, `endswith`, `in`, `between`, `isempty`, `isnotempty` | +| `OPERATOR_VALUE` | After comparison operator | `col.`, `True`, `False`, column values (from detected scope) | +| `BETWEEN_AND` | After `between X ` | `and` | +| `BETWEEN_VALUE` | After `between X and ` | Same as `OPERATOR_VALUE` | +| `IN_LIST_START` | After `in ` | `[` | +| `IN_LIST_VALUE` | Inside `[` or after `,` in list | Column values (from detected scope) | +| `BOOLEAN_VALUE` | After `bold=`, `italic=`, etc. | `True`, `False` | +| `COLOR_VALUE` | After `color=`, `background_color=` | CSS colors + DaisyUI variables | +| `DATE_FORMAT_VALUE` | After `format=` in format.date | `"%Y-%m-%d"`, `"%d/%m/%Y"`, `"%m/%d/%Y"`, `"%d %b %Y"` | +| `TRANSFORM_VALUE` | After `transform=` | `"uppercase"`, `"lowercase"`, `"capitalize"` | +| `COMMENT` | After `#` | No suggestions | + +### Context Detection Flow + +``` +column | → SCOPE_KEYWORD: column, row, cell +column a| → COLUMN_NAME: amount, active, ... +column amount: + | → RULE_START: style(, format(, format. + style(| → STYLE_ARGS: "error", "warning", ..., bold=, color=, ... + style("| → STYLE_PRESET: error, warning, success, ... + style("e| → STYLE_PRESET (filtered): error + style("error", | → STYLE_PARAM: bold=, italic=, color=, ... + style("error", bold=| → BOOLEAN_VALUE: True, False + style("error", color=| → COLOR_VALUE: red, blue, var(--color-primary), ... + style("error")| → AFTER_STYLE_OR_FORMAT: format(, format., if + style("error") if | → CONDITION_START: value, col., not + style("error") if not | → CONDITION_AFTER_NOT: value, col. + style("error") if value | → OPERATOR: ==, <, >, contains, in, ... + style("error") if value == | → OPERATOR_VALUE: col., True, False, "draft", "pending", ... + style("error") if value in | → IN_LIST_START: [ + style("error") if value in [| → IN_LIST_VALUE: "draft", "pending", ... + style("error") if value in ["draft", | → IN_LIST_VALUE: "pending", ... + style("error") if value between | → OPERATOR_VALUE + style("error") if value between 0 | → BETWEEN_AND: and + style("error") if value between 0 and | → BETWEEN_VALUE + +row | → ROW_INDEX: 0, 1, 2, ..., 9, 149 (if 150 rows) +row 0: + | → RULE_START + +cell | → CELL_START: ( +cell (| → CELL_COLUMN: amount, status, ... +cell (amount, | → CELL_ROW: 0, 1, 2, ..., 9, 149 +cell (amount, 3): + | → RULE_START + + format.| → FORMAT_TYPE: number, date, boolean, text, enum + format.date(| → FORMAT_PARAM_DATE: format= + format.date(format=| → DATE_FORMAT_VALUE: "%Y-%m-%d", "%d/%m/%Y", ... + format.text(| → FORMAT_PARAM_TEXT: transform=, max_length=, ellipsis= + format.text(transform=| → TRANSFORM_VALUE: "uppercase", "lowercase", "capitalize" +``` + +### Suggestions Data + +**Style Presets (DaisyUI 5):** + +| Label | Detail | +|-------|--------| +| `primary` | Primary theme color | +| `secondary` | Secondary theme color | +| `accent` | Accent theme color | +| `neutral` | Neutral theme color | +| `info` | Info (blue) | +| `success` | Success (green) | +| `warning` | Warning (yellow) | +| `error` | Error (red) | + +**Format Presets:** + +| Label | Detail | +|-------|--------| +| `EUR` | Euro currency (1 234,56 €) | +| `USD` | US Dollar ($1,234.56) | +| `percentage` | Percentage (×100, adds %) | +| `short_date` | DD/MM/YYYY | +| `iso_date` | YYYY-MM-DD | +| `yes_no` | Yes/No | + +**CSS Colors (subset):** + +`red`, `blue`, `green`, `yellow`, `orange`, `purple`, `pink`, `gray`, `black`, `white` + +**DaisyUI Color Variables:** + +`var(--color-primary)`, `var(--color-secondary)`, `var(--color-accent)`, `var(--color-neutral)`, `var(--color-info)`, `var(--color-success)`, `var(--color-warning)`, `var(--color-error)`, `var(--color-base-100)`, `var(--color-base-200)`, `var(--color-base-300)`, `var(--color-base-content)` + +**Date Format Patterns:** + +| Label | Detail | +|-------|--------| +| `"%Y-%m-%d"` | ISO format (2026-01-29) | +| `"%d/%m/%Y"` | European (29/01/2026) | +| `"%m/%d/%Y"` | US format (01/29/2026) | +| `"%d %b %Y"` | Short month (29 Jan 2026) | +| `"%d %B %Y"` | Full month (29 January 2026) | + +### Word Boundary Detection + +To determine the range to replace (`from`/`to`), use delimiters: +- Quotes: `"`, `'` +- Parentheses: `(`, `)` +- Brackets: `[`, `]` +- Braces: `{`, `}` +- Operators: `=`, `,`, `:`, `<`, `>`, `!` +- Whitespace: space, tab, newline + +### Exclusions (Not Implemented) + +The following features are excluded from autocompletion for simplicity: + +- `(case)` modifier for case-sensitive comparisons +- Arithmetic expressions (`col.budget * 0.9`) +- `format.enum` source configuration +- `format.number` parameters (free input) +- `format.boolean` parameters (free input) + +--- + +## Implementation Status + +| Component | Status | Location | +|-----------|--------|----------| +| DSL Grammar (lark) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/grammar.py` | +| DSL Parser | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/parser.py` | +| DSL Transformer | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/transformer.py` | +| Scope Dataclasses | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/scopes.py` | +| Exceptions | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/exceptions.py` | +| Public API (`parse_dsl()`) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/__init__.py` | +| Unit Tests (Parser) | :white_check_mark: ~35 tests | `tests/core/formatting/test_dsl_parser.py` | +| **Autocompletion** | | | +| DatagridMetadataProvider | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` | +| Scope Detector | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` | +| Context Detector | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` | +| Suggestions Generator | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` | +| REST Endpoint | :x: Not implemented | `/myfasthtml/autocompletion` | +| Unit Tests (Completion) | :x: Not implemented | `tests/core/formatting/test_dsl_completion.py` | +| **Client-side** | | | +| Lezer Grammar | :x: Not implemented | `static/js/formatrules.grammar` | +| CodeMirror Extension | :x: Not implemented | `static/js/formatrules-editor.js` | +| DaisyUI Theme | :x: Not implemented | `static/js/daisy-theme.js` | + +--- + +## Implementation Notes + +### Dependencies + +The DSL module requires: +- `lark` - Python parsing library (added to `pyproject.toml`) + +### Key Implementation Details + +**Indentation handling (lark Indenter):** + +The DSL uses Python-style indentation. Lark's `Indenter` class requires the newline token (`_NL`) to include trailing whitespace so it can detect indentation levels: + +``` +_NL: /(\r?\n[\t ]*)+/ +``` + +This means `_NL` captures the newline AND the following spaces/tabs. The Indenter uses `token.rsplit('\n', 1)[1]` to extract the indentation string. + +**Comment handling:** + +Comments (`# ...`) are pre-processed in `parser.py` before parsing. Comment lines are replaced with empty strings to preserve line numbers in error messages: + +```python +lines = ["" if line.strip().startswith("#") else line for line in lines] +``` + +The grammar's `%ignore COMMENT` directive handles inline comments (at end of lines). + +**Module Structure:** + +``` +src/myfasthtml/core/formatting/dsl/ +├── __init__.py # Public API: parse_dsl() +├── grammar.py # Lark EBNF grammar string +├── parser.py # DSLParser class with Indenter +├── transformer.py # AST → dataclass conversion +├── scopes.py # ColumnScope, RowScope, CellScope, ScopedRule +└── exceptions.py # DSLSyntaxError, DSLValidationError +``` + +**Output structure:** + +`parse_dsl()` returns a list of `ScopedRule` objects: + +```python +@dataclass +class ScopedRule: + scope: ColumnScope | RowScope | CellScope + rule: FormatRule # From core/formatting/dataclasses.py +``` + +### Next Steps + +1. ~~**Add `lark` to dependencies** in `pyproject.toml`~~ Done +2. **Implement autocompletion API**: + - `DatagridMetadataProvider` class + - Scope detection (column/row/cell) + - Context detection + - Suggestions generation + - REST endpoint `/myfasthtml/autocompletion` +3. **Translate lark grammar to Lezer** for client-side parsing +4. **Build CodeMirror extension** with DaisyUI theme +5. ~~**Integrate with DataGrid** - connect DSL output to formatting engine~~ Done diff --git a/pyproject.toml b/pyproject.toml index 1c18d4d..237a819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "uvloop", "watchfiles", "websockets", + "lark", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 98a359d..fbadd8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/app.py b/src/app.py index 3d12ffa..3f5b370 100644 --- a/src/app.py +++ b/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): """ diff --git a/src/myfasthtml/assets/Readme.md b/src/myfasthtml/assets/Readme.md new file mode 100644 index 0000000..8399eaa --- /dev/null +++ b/src/myfasthtml/assets/Readme.md @@ -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 +``` \ No newline at end of file diff --git a/src/myfasthtml/assets/codemirror.min.css b/src/myfasthtml/assets/codemirror.min.css new file mode 100644 index 0000000..2ef25ea --- /dev/null +++ b/src/myfasthtml/assets/codemirror.min.css @@ -0,0 +1 @@ +.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0} \ No newline at end of file diff --git a/src/myfasthtml/assets/codemirror.min.js b/src/myfasthtml/assets/codemirror.min.js new file mode 100644 index 0000000..446451d --- /dev/null +++ b/src/myfasthtml/assets/codemirror.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2t)return i;o.to==t&&(o.from!=o.to&&"before"==n?r=i:Fe=i),o.from==t&&(o.from!=o.to&&"before"!=n?r=i:Fe=i)}return null!=r?r:Fe}Ee=/[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/,Re=/[stwN]/,ze=/[LRr]/,Ie=/[Lb1n]/,Be=/[1n]/;var Ee,Re,ze,Ie,Be,Ge=function(e,t){var n="ltr"==t?"L":"R";if(0==e.length||"ltr"==t&&!Ee.test(e))return!1;for(var r,i=e.length,o=[],l=0;l=e.size)throw new Error("There is no line "+(t+e.first)+" in the document.");for(var n=e;!n.lines;)for(var r=0;;++r){var i=n.children[r],o=i.chunkSize();if(t=e.first&&tn?F(n,W(e,n).text.length):(e=W(e,(n=t).line).text.length,null==(t=n.ch)||e=this.string.length},g.prototype.sol=function(){return this.pos==this.lineStart},g.prototype.peek=function(){return this.string.charAt(this.pos)||void 0},g.prototype.next=function(){if(this.post},g.prototype.eatSpace=function(){for(var e=this.pos;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e},g.prototype.skipToEnd=function(){this.pos=this.string.length},g.prototype.skipTo=function(e){e=this.string.indexOf(e,this.pos);if(-1e.options.maxHighlightLength&&ft(e.doc.mode,r.state),o=At(e,t,r),i&&(r.state=i),t.stateAfter=r.save(!i),t.styles=o.styles,o.classes?t.styleClasses=o.classes:t.styleClasses&&(t.styleClasses=null),n===e.doc.highlightFrontier&&(e.doc.modeFrontier=Math.max(e.doc.modeFrontier,++e.doc.highlightFrontier))),t.styles}function Wt(n,r,e){var t=n.doc,i=n.display;if(!t.mode.startState)return new Ot(t,!0,r);var o=function(e,t,n){for(var r,i,o=e.doc,l=n?-1:t-(e.doc.mode.innerMode?1e3:100),s=t;lt.first&&W(t,o-1).stateAfter,s=l?Ot.fromSaved(t,l,o):new Ot(t,gt(t.mode),o);return t.iter(o,r,function(e){Ht(n,e.text,s);var t=s.line;e.stateAfter=t==r-1||t%5==0||t>=i.viewFrom&&tt.start)return o}throw new Error("Mode "+e.name+" failed to advance stream.")}Ot.prototype.lookAhead=function(e){var t=this.doc.getLine(this.line+e);return null!=t&&e>this.maxLookAhead&&(this.maxLookAhead=e),t},Ot.prototype.baseToken=function(e){if(!this.baseTokens)return null;for(;this.baseTokens[this.baseTokenPos]<=e;)this.baseTokenPos+=2;var t=this.baseTokens[this.baseTokenPos+1];return{type:t&&t.replace(/( |^)overlay .*/,""),size:this.baseTokens[this.baseTokenPos]-e}},Ot.prototype.nextLine=function(){this.line++,0e.options.maxHighlightLength?(s=!1,l&&Ht(e,t,r,c.pos),c.pos=t.length,null):zt(Pt(n,c,r.state,h),o);if(!h||(d=h[0].name)&&(f="m-"+(f?d+" "+f:d)),!s||u!=f){for(;a=t:l.to>t),(r=r||[]).push(new Ut(s,l.from,o?null:l.to)))}return r}(n,r,o),s=function(e,t,n){var r;if(e)for(var i=0;i=t:l.to>t))&&(l.from!=t||"bookmark"!=s.type||n&&!l.marker.insertLeft)||(o=null==l.from||(s.inclusiveLeft?l.from<=t:l.frome.lastLine())return t;var n,r=W(e,t);if(!on(e,r))return t;for(;n=Jt(r);)r=n.find(1,!0).line;return H(r)+1}function on(e,t){var n=Gt&&t.markedSpans;if(n)for(var r,i=0;in.maxLineLength&&(n.maxLineLength=t,n.maxLine=e)})}var un=function(e,t,n){this.text=e,Yt(this,t),this.height=n?n(this):1};un.prototype.lineNo=function(){return H(this)},$e(un);var cn={},hn={};function dn(e,t){if(!e||/^\s*$/.test(e))return null;t=t.addModeClass?hn:cn;return t[e]||(t[e]=e.replace(/\S+/g,"cm-$&"))}function fn(e,t){var n=ne("span",null,null,x?"padding-right: .1px":null),r={pre:ne("pre",[n],"CodeMirror-line"),content:n,col:0,pos:0,cm:e,trailingSpace:!1,splitSpaces:e.getOption("lineWrapping")};t.measure={};for(var i=0;i<=(t.rest?t.rest.length:0);i++){var o=i?t.rest[i-1]:t.line,l=void 0,l=(r.pos=0,r.addToken=gn,function(e){if(null!=tt)return tt;var t=y(e,document.createTextNode("AخA")),n=le(t,0,1).getBoundingClientRect(),t=le(t,1,2).getBoundingClientRect();return te(e),n&&n.left!=n.right&&(tt=t.right-n.right<3)}(e.display.measure)&&(l=Ve(o,e.doc.direction))&&(r.addToken=function(h,d){return function(e,t,n,r,i,o,l){n=n?n+" cm-force-border":"cm-force-border";for(var s=e.pos,a=s+t.length;;){for(var u=void 0,c=0;cs&&u.from<=s);c++);if(u.to>=a)return h(e,t,n,r,i,o,l);h(e,t.slice(0,u.to-s),n,r,null,o,l),r=null,t=t.slice(u.to-s),s=u.to}}}(r.addToken,l)),r.map=[],t!=e.display.externalMeasured&&H(o));!function(e,t,n){var r=e.markedSpans,i=e.text,o=0;if(r)for(var l,s,a,u,c,h,d,f=i.length,p=0,g=1,m="",v=0;;){if(v==p){a=u=c=s="",h=d=null,v=1/0;for(var y=[],b=void 0,w=0;wp||C.collapsed&&x.to==p&&x.from==p)){if(null!=x.to&&x.to!=p&&v>x.to&&(v=x.to,u=""),C.className&&(a+=" "+C.className),C.css&&(s=(s?s+";":"")+C.css),C.startStyle&&x.from==p&&(c+=" "+C.startStyle),C.endStyle&&x.to==v&&(b=b||[]).push(C.endStyle,x.to),C.title&&((d=d||{}).title=C.title),C.attributes)for(var S in C.attributes)(d=d||{})[S]=C.attributes[S];C.collapsed&&(!h||qt(h.marker,C)<0)&&(h=x)}else x.from>p&&v>x.from&&(v=x.from)}if(b)for(var L=0;Ln)return{map:e.measure.maps[i],cache:e.measure.caches[i],before:!0}}}function zn(e,t,n,r){return Gn(e,Bn(e,t),n,r)}function In(e,t){if(t>=e.display.viewFrom&&t=e.lineN&&tt)&&(i=(o=a-s)-1,a<=t&&(l="right")),null!=i){if(r=e[u+2],s==a&&n==(r.insertLeft?"left":"right")&&(l=n),"left"==n&&0==i)for(;u&&e[u-2]==e[u-3]&&e[u-1].insertLeft;)r=e[2+(u-=3)],l="left";if("right"==n&&i==a-s)for(;u=i.text.length?(t=i.text.length,e="before"):t<=0&&(t=0,e="after"),!a)return s("before"==e?t-1:t,"before"==e);function u(e,t,n){return s(n?e-1:e,1==a[t].level!=n)}var c=Pe(a,t,e),h=Fe,c=u(t,c,"before"==e);return null!=h&&(c.other=u(t,h,"before"!=e)),c}function tr(e,t){var n=0,t=(t=E(e.doc,t),e.options.lineWrapping||(n=cr(e.display)*t.ch),W(e.doc,t.line)),e=ln(t)+Dn(e.display);return{left:n,right:n,top:e,bottom:e+t.height}}function nr(e,t,n,r,i){e=F(e,t,n);return e.xRel=i,r&&(e.outside=r),e}function rr(e,t,n){var r=e.doc;if((n+=e.display.viewOffset)<0)return nr(r.first,0,null,-1,-1);var i=bt(r,n),o=r.first+r.size-1;if(o=a.bottom?1:0)}return c=We(e.text,c,1),nr(t,c,g,f,r-p)}(e,l,i,t,n),a=function(e,t){var n,r=Gt&&e.markedSpans;if(r)for(var i=0;it)&&(!n||qt(n,o.marker)<0)&&(n=o.marker)}return n}(l,s.ch+(0r},i,e)}}function or(e,t,n,r){return ir(e,t,n=n||Bn(e,t),Zn(e,t,Gn(e,n,r),"line").top)}function lr(e,t,n,r){return!(e.bottom<=n)&&(e.top>n||(r?e.left:e.right)>t)}function sr(n,r,i,o,l,s,a){var e,t=He(function(e){var e=l[e],t=1!=e.level;return lr(er(n,F(i,t?e.to:e.from,t?"before":"after"),"line",r,o),s,a,!0)},0,l.length-1),u=l[t];return 0a&&(u=l[t-1])),u}function ar(e,t,n,r,i,o,l){for(var l=ir(e,t,r,l),s=l.begin,a=l.end,u=(/\s/.test(t.text.charAt(a-1))&&a--,null),c=null,h=0;h=a||f.to<=s||(d=(d=Gn(e,r,1!=f.level?Math.min(a,f.to)-1:Math.max(s,f.from)).right)a?{from:u.from,to:a,level:u.level}:u}function ur(e){if(null!=e.cachedTextHeight)return e.cachedTextHeight;if(null==Un){Un=M("pre",null,"CodeMirror-line-like");for(var t=0;t<49;++t)Un.appendChild(document.createTextNode("x")),Un.appendChild(M("br"));Un.appendChild(document.createTextNode("x"))}y(e.measure,Un);var n=Un.offsetHeight/50;return 3=e.display.viewTo)return null;if((t-=e.display.viewFrom)<0)return null;for(var n=e.display.view,r=0;rt)&&(o.updateLineNumbers=t),e.curOp.viewChanged=!0,t>=o.viewTo?Gt&&nn(e.doc,t)o.viewFrom?yr(e):(o.viewFrom+=r,o.viewTo+=r):t<=o.viewFrom&&n>=o.viewTo?yr(e):t<=o.viewFrom?(l=br(e,n,n+r,1))?(o.view=o.view.slice(l.index),o.viewFrom=l.lineN,o.viewTo+=r):yr(e):n>=o.viewTo?(l=br(e,t,t,-1))?(o.view=o.view.slice(0,l.index),o.viewTo=l.lineN):yr(e):(l=br(e,t,t,-1),i=br(e,n,n+r,1),l&&i?(o.view=o.view.slice(0,l.index).concat(yn(e,l.lineN,i.lineN)).concat(o.view.slice(i.index)),o.viewTo+=r):yr(e)),o.externalMeasured);l&&(n=i.lineN&&t=r.viewTo||null!=(i=r.view[mr(e,t)]).node&&-1==L(r=i.changes||(i.changes=[]),n)&&r.push(n)}function yr(e){e.display.viewFrom=e.display.viewTo=e.doc.first,e.display.view=[],e.display.viewOffset=0}function br(e,t,n,r){var i,o=mr(e,t),l=e.display.view;if(!Gt||n==e.doc.first+e.doc.size)return{index:o,lineN:n};for(var s=e.display.viewFrom,a=0;a=e.display.viewTo||s.to().linet||t==n&&l.to==t)&&(r(Math.max(l.from,t),Math.min(l.to,n),1==l.level?"rtl":"ltr",o),i=!0)}i||r(t,n,"ltr")}(C,g||0,null==m?b:m,function(e,t,n,r){var i,o,l,s,a,u="ltr"==n,c=w(e,u?"left":"right"),h=w(t-1,u?"right":"left"),d=null==g&&0==e,f=null==m&&t==b,p=0==r,r=!C||r==C.length-1;h.top-c.top<=3?(i=(k?d:f)&&p?S:(u?c:h).left,a=(k?f:d)&&r?L:(u?h:c).right,T(i,c.top,a-i,c.bottom)):(a=u?(o=k&&d&&p?S:c.left,l=k?L:x(e,n,"before"),s=k?S:x(t,n,"after"),k&&f&&r?L:h.right):(o=k?x(e,n,"before"):S,l=!k&&d&&p?L:c.right,s=!k&&f&&r?S:h.left,k?x(t,n,"after"):L),T(o,c.top,l-o,c.bottom),c.bottome.display.sizerWidth&&((a=Math.ceil(c/cr(e.display)))>e.display.maxLineLength&&(e.display.maxLineLength=a,e.display.maxLine=s.line,e.display.maxLineChanged=!0))}}2=o&&(i=bt(t,ln(W(t,n))-e.wrapper.clientHeight),o=n)),{from:i,to:Math.max(o,i+1)}}function Hr(e,t){var n=e.display,r=ur(e.display),i=(t.top<0&&(t.top=0),(e.curOp&&null!=e.curOp.scrollTop?e.curOp:n.scroller).scrollTop),o=En(e),l={},s=(t.bottom-t.top>o&&(t.bottom=t.top+o),e.doc.height+Wn(n)),a=t.tops-r,r=(t.topi+o&&((a=Math.min(t.top,(r?s:t.bottom)-o))!=i&&(l.scrollTop=a)),e.options.fixedGutter?0:n.gutters.offsetWidth),s=e.curOp&&null!=e.curOp.scrollLeft?e.curOp.scrollLeft:n.scroller.scrollLeft-r,o=Pn(e)-n.gutters.offsetWidth,i=t.right-t.left>o;return i&&(t.right=t.left+o),t.left<10?l.scrollLeft=0:t.lefto+s-3&&(l.scrollLeft=t.right+(i?0:10)-o),l}function Fr(e,t){null!=t&&(Rr(e),e.curOp.scrollTop=(null==e.curOp.scrollTop?e.doc:e.curOp).scrollTop+t)}function Pr(e){Rr(e);var t=e.getCursor();e.curOp.scrollToPos={from:t,to:t,margin:e.options.cursorScrollMargin}}function Er(e,t,n){null==t&&null==n||Rr(e),null!=t&&(e.curOp.scrollLeft=t),null!=n&&(e.curOp.scrollTop=n)}function Rr(e){var t=e.curOp.scrollToPos;t&&(e.curOp.scrollToPos=null,zr(e,tr(e,t.from),tr(e,t.to),t.margin))}function zr(e,t,n,r){t=Hr(e,{left:Math.min(t.left,n.left),top:Math.min(t.top,n.top)-r,right:Math.max(t.right,n.right),bottom:Math.max(t.bottom,n.bottom)+r});Er(e,t.scrollLeft,t.scrollTop)}function Ir(e,t){Math.abs(e.doc.scrollTop-t)<2||(d||ri(e,{top:t}),Br(e,t,!0),d&&ri(e),Qr(e,100))}function Br(e,t,n){t=Math.max(0,Math.min(e.display.scroller.scrollHeight-e.display.scroller.clientHeight,t)),e.display.scroller.scrollTop==t&&!n||(e.doc.scrollTop=t,e.display.scrollbars.setScrollTop(t),e.display.scroller.scrollTop!=t&&(e.display.scroller.scrollTop=t))}function Gr(e,t,n,r){t=Math.max(0,Math.min(t,e.display.scroller.scrollWidth-e.display.scroller.clientWidth)),(n?t==e.doc.scrollLeft:Math.abs(e.doc.scrollLeft-t)<2)&&!r||(e.doc.scrollLeft=t,li(e),e.display.scroller.scrollLeft!=t&&(e.display.scroller.scrollLeft=t),e.display.scrollbars.setScrollLeft(t))}function Ur(e){var t=e.display,n=t.gutters.offsetWidth,r=Math.round(e.doc.height+Wn(e.display));return{clientHeight:t.scroller.clientHeight,viewHeight:t.wrapper.clientHeight,scrollWidth:t.scroller.scrollWidth,clientWidth:t.scroller.clientWidth,viewWidth:t.wrapper.clientWidth,barLeft:e.options.fixedGutter?n:0,docHeight:r,scrollHeight:r+Fn(e)+t.barHeight,nativeBarWidth:t.nativeBarWidth,gutterWidth:n}}function Vr(e,t,n){this.cm=n;var r=this.vert=M("div",[M("div",null,null,"min-width: 1px")],"CodeMirror-vscrollbar"),i=this.horiz=M("div",[M("div",null,null,"height: 100%; min-height: 1px")],"CodeMirror-hscrollbar");r.tabIndex=i.tabIndex=-1,e(r),e(i),k(r,"scroll",function(){r.clientHeight&&t(r.scrollTop,"vertical")}),k(i,"scroll",function(){i.clientWidth&&t(i.scrollLeft,"horizontal")}),this.checkedZeroWidth=!1,w&&v<8&&(this.horiz.style.minHeight=this.vert.style.minWidth="18px")}function Kr(){}Vr.prototype.update=function(e){var t,n=e.scrollWidth>e.clientWidth+1,r=e.scrollHeight>e.clientHeight+1,i=e.nativeBarWidth;return r?(this.vert.style.display="block",this.vert.style.bottom=n?i+"px":"0",t=e.viewHeight-(n?i:0),this.vert.firstChild.style.height=Math.max(0,e.scrollHeight-e.clientHeight+t)+"px"):(this.vert.scrollTop=0,this.vert.style.display="",this.vert.firstChild.style.height="0"),n?(this.horiz.style.display="block",this.horiz.style.right=r?i+"px":"0",this.horiz.style.left=e.barLeft+"px",t=e.viewWidth-e.barLeft-(r?i:0),this.horiz.firstChild.style.width=Math.max(0,e.scrollWidth-e.clientWidth+t)+"px"):(this.horiz.style.display="",this.horiz.firstChild.style.width="0"),!this.checkedZeroWidth&&0=l.viewTo)||l.maxLineChanged&&o.options.lineWrapping,i.update=i.mustUpdate&&new ei(o,i.mustUpdate&&{top:i.scrollTop,ensure:i.scrollToPos},i.forceUpdate)}for(var s=0;s(i.defaultView.innerHeight||i.documentElement.clientHeight)&&(r=!1),null==r||X||(o=M("div","​",null,"position: absolute;\n top: "+(t.top-n.viewOffset-Dn(e.display))+"px;\n height: "+(t.bottom-t.top+Fn(e)+n.barHeight)+"px;\n left: "+t.left+"px; width: "+Math.max(2,t.right-t.left)+"px;"),e.display.lineSpace.appendChild(o),o.scrollIntoView(r),e.display.lineSpace.removeChild(o)))}(w,v));var S=b.maybeHiddenMarkers,L=b.maybeUnhiddenMarkers;if(S)for(var k=0;k=l.display.viewTo||(s=+new Date+l.options.workTime,a=Wt(l,c.highlightFrontier),u=[],c.iter(a.line,Math.min(c.first+c.size,l.display.viewTo+500),function(e){if(a.line>=l.display.viewFrom){for(var t=e.styles,n=e.text.length>l.options.maxHighlightLength?ft(c.mode,a.state):null,r=At(l,e,a,!0),n=(n&&(a.state=n),e.styles=r.styles,e.styleClasses),r=r.classes,i=(r?e.styleClasses=r:n&&(e.styleClasses=null),!t||t.length!=e.styles.length||n!=r&&(!n||!r||n.bgClass!=r.bgClass||n.textClass!=r.textClass)),o=0;!i&&os)return Qr(l,l.options.workDelay),!0}),c.highlightFrontier=a.line,c.modeFrontier=Math.max(c.modeFrontier,a.line),u.length&&h(l,function(){for(var e=0;e=n.viewFrom&&t.visible.to<=n.viewTo&&(null==n.updateLineNumbers||n.updateLineNumbers>=n.viewTo)&&n.renderedView==n.view&&0==wr(e))return!1;si(e)&&(yr(e),t.dims=hr(e));var i=r.first+r.size,o=Math.max(t.visible.from-e.options.viewportMargin,r.first),l=Math.min(i,t.visible.to+e.options.viewportMargin),r=(n.viewFroml&&n.viewTo-l<20&&(l=Math.min(i,n.viewTo)),Gt&&(o=nn(e.doc,o),l=rn(e.doc,l)),o!=n.viewFrom||l!=n.viewTo||n.lastWrapHeight!=t.wrapperHeight||n.lastWrapWidth!=t.wrapperWidth),i=(i=o,o=l,0==(c=(l=e).display).view.length||i>=c.viewTo||o<=c.viewFrom?(c.view=yn(l,i,o),c.viewFrom=i):(c.viewFrom>i?c.view=yn(l,i,c.viewFrom).concat(c.view):c.viewFromo&&(c.view=c.view.slice(0,mr(l,o)))),c.viewTo=o,n.viewOffset=ln(W(e.doc,n.viewFrom)),e.display.mover.style.top=n.viewOffset+"px",wr(e));if(!r&&0==i&&!t.force&&n.renderedView==n.view&&(null==n.updateLineNumbers||n.updateLineNumbers>=n.viewTo))return!1;var l=function(e){if(e.hasFocus())return null;if(!(n=N(ue(e)))||!re(e.display.lineDiv,n))return null;var t,n={activeElt:n};return window.getSelection&&(t=he(e).getSelection()).anchorNode&&t.extend&&re(e.display.lineDiv,t.anchorNode)&&(n.anchorNode=t.anchorNode,n.anchorOffset=t.anchorOffset,n.focusNode=t.focusNode,n.focusOffset=t.focusOffset),n}(e),s=(4=e.display.viewFrom&&t.visible.to<=e.display.viewTo)break;if(!ti(e,t))break;Ar(e);var i=Ur(e);xr(e),jr(e,i),oi(e,i),t.force=!1}t.signal(e,"update",e),e.display.viewFrom==e.display.reportedViewFrom&&e.display.viewTo==e.display.reportedViewTo||(t.signal(e,"viewportChange",e,e.display.viewFrom,e.display.viewTo),e.display.reportedViewFrom=e.display.viewFrom,e.display.reportedViewTo=e.display.viewTo)}function ri(e,t){var n,t=new ei(e,t);ti(e,t)&&(Ar(e),ni(e,t),n=Ur(e),xr(e),jr(e,n),oi(e,n),t.finish())}function ii(e){var t=e.gutters.offsetWidth;e.sizer.style.marginLeft=t+"px",b(e,"gutterChanged",e)}function oi(e,t){e.display.sizer.style.minHeight=t.docHeight+"px",e.display.heightForcer.style.top=t.docHeight+"px",e.display.gutters.style.height=t.docHeight+e.display.barHeight+Fn(e)+"px"}function li(e){var t=e.display,n=t.view;if(t.alignWidgets||t.gutters.firstChild&&e.options.fixedGutter){for(var r=dr(t)-t.scroller.scrollLeft+e.doc.scrollLeft,i=t.gutters.offsetWidth,o=r+"px",l=0;ll.clientWidth,a=l.scrollHeight>l.clientHeight;if(r&&s||n&&a){if(n&&C&&x)e:for(var u=t.target,c=o.view;u!=l;u=u.parentNode)for(var h=0;hs-(e.cm?e.cm.options.historyEventDelay:500)||"*"==t.origin.charAt(0)))&&(o=(o=l).lastOp==r?(Wi(o.done),z(o.done)):o.done.length&&!z(o.done).ranges?z(o.done):1l.undoDepth;)l.done.shift(),l.done[0].ranges||l.done.shift()}l.done.push(n),l.generation=++l.maxGeneration,l.lastModTime=l.lastSelTime=s,l.lastOp=l.lastSelOp=r,l.lastOrigin=l.lastSelOrigin=t.origin,i||O(e,"historyAdded")}function Fi(e,t,n,r){var i,o,l,s=e.history,a=r&&r.origin;n==s.lastSelOp||a&&s.lastSelOrigin==a&&(s.lastModTime==s.lastSelTime&&s.lastOrigin==a||(e=e,i=a,o=z(s.done),l=t,"*"==(i=i.charAt(0))||"+"==i&&o.ranges.length==l.ranges.length&&o.somethingSelected()==l.somethingSelected()&&new Date-e.history.lastSelTime<=(e.cm?e.cm.options.historyEventDelay:500)))?s.done[s.done.length-1]=t:Pi(t,s.done),s.lastSelTime=+new Date,s.lastSelOrigin=a,s.lastSelOp=n,r&&!1!==r.clearRedo&&Wi(s.undone)}function Pi(e,t){var n=z(t);n&&n.ranges&&n.equals(e)||t.push(e)}function Ei(t,n,e,r){var i=n["spans_"+t.id],o=0;t.iter(Math.max(t.first,e),Math.min(t.first+t.size,r),function(e){e.markedSpans&&((i=i||(n["spans_"+t.id]={}))[o]=e.markedSpans),++o})}function Ri(e,t){var n=t["spans_"+e.id];if(!n)return null;for(var r=[],i=0;i=t.ch:s.to>t.ch))){if(i&&(O(a,"beforeCursorEnter"),a.explicitlyCleared)){if(o.markedSpans){--l;continue}break}if(a.atomic){if(n){var s=a.find(r<0?1:-1),h=void 0;if((s=(r<0?c:u)?Qi(e,s,-r,s&&s.line==t.line?o:null):s)&&s.line==t.line&&(h=P(s,n))&&(r<0?h<0:0e.first?E(e,F(t.line-1)):null:0e.lastLine())){t.from.linei?{from:t.from,to:F(i,W(e,i).text.length),text:[t.text[0]],origin:t.origin}:t).removed=mt(e,t.from,t.to),n=n||xi(e,t),e.cm){var i=e.cm,o=t,l=r,s=i.doc,a=i.display,u=o.from,c=o.to,h=!1,d=u.line,f=(i.options.lineWrapping||(d=H(tn(W(s,u.line))),s.iter(d,c.line+1,function(e){if(e==a.maxLine)return h=!0})),-1a.maxLineLength&&(a.maxLine=e,a.maxLineLength=t,a.maxLineChanged=!0,h=!1)}),h&&(i.curOp.updateMaxLine=!0)),s),p=u.line;if(f.modeFrontier=Math.min(f.modeFrontier,p),!(f.highlightFrontiert.display.maxLineLength&&(t.display.maxLine=u,t.display.maxLineLength=c,t.display.maxLineChanged=!0)}null!=r&&t&&this.collapsed&&R(t,r,i+1),this.lines.length=0,this.explicitlyCleared=!0,this.atomic&&this.doc.cantEdit&&(this.doc.cantEdit=!1,t&&$i(t.doc)),t&&b(t,"markerCleared",t,this,r,i),n&&Zr(t),this.parent&&this.parent.clear()}},mo.prototype.find=function(e,t){var n,r;null==e&&"bookmark"==this.type&&(e=1);for(var i=0;i=e.ch)&&t.push(i.marker.parent||i.marker)}return t},findMarks:function(i,o,l){i=E(this,i),o=E(this,o);var s=[],a=i.line;return this.iter(i.line,o.line+1,function(e){var t=e.markedSpans;if(t)for(var n=0;n=r.to||null==r.from&&a!=i.line||null!=r.from&&a==o.line&&r.from>=o.ch||l&&!l(r.marker)||s.push(r.marker.parent||r.marker)}++a}),s},getAllMarks:function(){var r=[];return this.iter(function(e){var t=e.markedSpans;if(t)for(var n=0;nt&&(t=e.from),null!=e.to&&e.toe.text.length?null:t}function Ko(e,t,n){e=Vo(e,t.ch,n);return null==e?null:new F(t.line,e,n<0?"after":"before")}function jo(e,t,n,r,i){if(e){"rtl"==t.doc.direction&&(i=-i);var o,l,s,a,e=Ve(n,t.doc.direction);if(e)return o=i<0==(1==(e=i<0?z(e):e[0]).level)?"after":"before",0=n.text.length?(s.ch=n.text.length,s.sticky="before"):s.ch<=0&&(s.ch=0,s.sticky="after");var r=Pe(a,s.ch,s.sticky),i=a[r];if("ltr"==t.doc.direction&&i.level%2==0&&(0s.ch:i.from=i.from&&d>=c.begin))return new F(s.line,d,h?"before":"after")}function f(e,t,n){for(var r=function(e,t){return t?new F(s.line,u(e,1),"before"):new F(s.line,e,"after")};0<=e&&el.doc.first&&((n=W(l.doc,e.line-1).text)&&(e=new F(e.line,1),l.replaceRange(t.charAt(0)+l.doc.lineSeparator()+n.charAt(n.length-1),F(e.line-1,n.length-1),e,"+transpose")))),i.push(new G(e,e)));l.setSelections(i)})},newlineAndIndent:function(r){return h(r,function(){for(var e=(t=r.listSelections()).length-1;0<=e;e--)r.replaceRange(r.doc.lineSeparator(),t[e].anchor,t[e].head,"+input");for(var t=r.listSelections(),n=0;nc&&t.push(new G(F(s,c),F(s,we(u,l,n))))}t.length||t.push(new G(f,f)),U(g,vi(d,y.ranges.slice(0,v).concat(t),v),{origin:"*mouse",scroll:!1}),d.scrollIntoView(e)}else{var h,r=m,i=ul(d,e,p.unit),e=r.anchor,e=0=n.to||o.linea.bottom?20:0)&&setTimeout(I(d,function(){u==i&&(l.scroller.scrollTop+=r,e(t))}),50))}:n)(e)}),i=I(d,n);d.state.selectingText=i,k(l.wrapper.ownerDocument,"mousemove",r),k(l.wrapper.ownerDocument,"mouseup",i)})(i,s,o,a)):Qe(e)==h.scroller&&D(e):2==n?(t&&Gi(c.doc,t),setTimeout(function(){return h.input.focus()},20)):3==n&&(Q?c.display.input.onContextMenu(e):Mr(c)))))}function ul(e,t,n){if("char"==n)return new G(t,t);if("word"==n)return e.findWordAt(t);if("line"==n)return new G(F(t.line,0),E(e.doc,F(t.line+1,0)));n=n(e,t);return new G(n.from,n.to)}function cl(e,t,n,r){var i,o;if(t.touches)i=t.touches[0].clientX,o=t.touches[0].clientY;else try{i=t.clientX,o=t.clientY}catch(e){return!1}if(i>=Math.floor(e.display.gutters.getBoundingClientRect().right))return!1;r&&D(t);var l=e.display,r=l.lineDiv.getBoundingClientRect();if(o>r.bottom||!Ye(e,n))return qe(t);o-=r.top-l.viewOffset;for(var s=0;s=i)return O(e,n,e,bt(e.doc,o),e.display.gutterSpecs[s].className,t),qe(t)}}function hl(e,t){return cl(e,t,"gutterClick",!0)}function dl(e,t){var n,r;An(e.display,t)||(r=t,Ye(n=e,"gutterContextMenu")&&cl(n,r,"gutterContextMenu",!1))||A(e,t,"contextmenu")||Q||e.display.input.onContextMenu(t)}function fl(e){e.display.wrapper.className=e.display.wrapper.className.replace(/\s*cm-s-\S+/g,"")+e.options.theme.replace(/(^|\s)\s*/g," cm-s-"),Yn(e)}ol.prototype.compare=function(e,t,n){return this.time+400>e&&0==P(t,this.pos)&&n==this.button};var pl={toString:function(){return"CodeMirror.Init"}},gl={},ml={};function vl(e,t,n){!t!=!(n&&n!=pl)&&(n=e.display.dragFunctions,(t=t?k:T)(e.display.scroller,"dragstart",n.start),t(e.display.scroller,"dragenter",n.enter),t(e.display.scroller,"dragover",n.over),t(e.display.scroller,"dragleave",n.leave),t(e.display.scroller,"drop",n.drop))}function yl(e){e.options.lineWrapping?(ie(e.display.wrapper,"CodeMirror-wrap"),e.display.sizer.style.minWidth="",e.display.sizerWidth=null):(ee(e.display.wrapper,"CodeMirror-wrap"),an(e)),pr(e),R(e),Yn(e),setTimeout(function(){return jr(e)},100)}function p(e,t){var n=this;if(!(this instanceof p))return new p(e,t);this.options=t=t?fe(t):{},fe(gl,t,!1);var r,i=t.value,o=("string"==typeof i?i=new f(i,t.mode,null,t.lineSeparator,t.direction):t.mode&&(i.modeOption=t.mode),this.doc=i,new p.inputStyles[t.inputStyle](this)),e=this.display=new hi(e,i,o,t),l=(fl(e.wrapper.CodeMirror=this),t.lineWrapping&&(this.display.wrapper.className+=" CodeMirror-wrap"),$r(this),this.state={keyMaps:[],overlays:[],modeGen:0,overwrite:!1,delayingBlurEvent:!1,focused:!1,suppressEdits:!1,pasteIncoming:-1,cutIncoming:-1,selectingText:!1,draggingText:!1,highlight:new pe,keySeq:null,specialChars:null},t.autofocus&&!_&&e.input.focus(),w&&v<11&&setTimeout(function(){return n.display.input.reset(!0)},20),this),s=l.display;k(s.scroller,"mousedown",I(l,al)),k(s.scroller,"dblclick",w&&v<11?I(l,function(e){var t;A(l,e)||(!(t=gr(l,e))||hl(l,e)||An(l.display,e)||(D(e),e=l.findWordAt(t),Gi(l.doc,e.anchor,e.head)))}):function(e){return A(l,e)||D(e)}),k(s.scroller,"contextmenu",function(e){return dl(l,e)}),k(s.input.getField(),"contextmenu",function(e){s.scroller.contains(e.target)||dl(l,e)});var a,u={end:0};function c(){s.activeTouch&&(a=setTimeout(function(){return s.activeTouch=null},1e3),(u=s.activeTouch).end=+new Date)}function h(e,t){if(null==t.left)return 1;var n=t.left-e.left,t=t.top-e.top;return 400o.first?S(W(o,t-1).text,null,l):0:"add"==n?c=a+e.options.indentUnit:"subtract"==n?c=a-e.options.indentUnit:"number"==typeof n&&(c=a+n);var c=Math.max(0,c),h="",d=0;if(e.options.indentWithTabs)for(var f=Math.floor(c/l);f;--f)d+=l,h+="\t";if(dl,a=rt(t),u=null;if(s&&1l?"cut":"+input")});to(e.doc,f),b(e,"inputRead",e,f)}t&&!s&&kl(e,t),Pr(e),e.curOp.updateInput<2&&(e.curOp.updateInput=h),e.curOp.typing=!0,e.state.pasteIncoming=e.state.cutIncoming=-1}function Ll(e,t){var n=e.clipboardData&&e.clipboardData.getData("Text");return n&&(e.preventDefault(),t.isReadOnly()||t.options.disableInput||!t.hasFocus()||h(t,function(){return Sl(t,n,0,null,"paste")}),1)}function kl(e,t){if(e.options.electricChars&&e.options.smartIndent)for(var n=e.doc.sel,r=n.ranges.length-1;0<=r;r--){var i=n.ranges[r];if(!(100=n.first+n.size||(r=new F(e,r.ch,r.sticky),!(s=W(n,e))))return;r=jo(l,n.cm,s,r.line,a)}else r=t;return 1}if("char"==o||"codepoint"==o)u();else if("column"==o)u(!0);else if("word"==o||"group"==o)for(var c=null,h="group"==o,d=n.cm&&n.cm.getHelper(r,"wordChars"),f=!0;!(i<0)||u(!f);f=!1){var p=s.text.charAt(r.ch)||"\n",p=Ne(p,d)?"w":h&&"\n"==p?"n":!h||/\s/.test(p)?null:"p";if(!h||f||p||(p="s"),c&&c!=p){i<0&&(i=1,u(),r.sticky="after");break}if(p&&(c=p),0=s.height){l.hitSide=!0;break}o+=5*n}return l}function r(e){this.cm=e,this.lastAnchorNode=this.lastAnchorOffset=this.lastFocusNode=this.lastFocusOffset=null,this.polling=new pe,this.composing=null,this.gracePeriod=!1,this.readDOMTimeout=null}function Dl(e,t){var n=In(e,t.line);if(!n||n.hidden)return null;var r=W(e.doc,t.line),n=Rn(n,r,t.line),r=Ve(r,e.doc.direction),e="left",r=(r&&(e=Pe(r,t.ch)%2?"right":"left"),Kn(n.map,t.ch,e));return r.offset="right"==r.collapse?r.end:r.start,r}function Wl(e,t){return t&&(e.bad=!0),e}function Hl(e,t,n){var r;if(t==e.display.lineDiv){if(!(r=e.display.lineDiv.childNodes[n]))return Wl(e.clipPos(F(e.display.viewTo-1)),!0);t=null,n=0}else for(r=t;;r=r.parentNode){if(!r||r==e.display.lineDiv)return null;if(r.parentNode&&r.parentNode==e.display.lineDiv)break}for(var i=0;i=t.display.viewTo||n.line=t.display.viewFrom&&Dl(t,r)||{node:i[0].measure.map[2],offset:0},r=n.linet.firstLine()&&(i=F(i.line-1,W(t.doc,i.line-1).length)),r.ch==W(t.doc,r.line).text.length&&r.linen.viewTo-1)return!1;var o,l=i.line==n.viewFrom||0==(l=mr(t,i.line))?(e=H(n.view[0].line),n.view[0].node):(e=H(n.view[l].line),n.view[l-1].node.nextSibling),r=mr(t,r.line),n=r==n.view.length-1?(o=n.viewTo-1,n.lineDiv.lastChild):(o=H(n.view[r+1].line)-1,n.view[r+1].node.previousSibling);if(!l)return!1;for(var s=t.doc.splitLines(function(o,e,t,l,s){var n="",a=!1,u=o.doc.lineSeparator(),c=!1;function h(){a&&(n+=u,c&&(n+=u),a=c=!1)}function d(e){e&&(h(),n+=e)}for(;!function e(t){if(1==t.nodeType){var n=t.getAttribute("cm-text");if(n)d(n);else if(n=t.getAttribute("cm-marker"))(n=o.findMarks(F(l,0),F(s+1,0),(i=+n,function(e){return e.id==i}))).length&&(n=n[0].find(0))&&d(mt(o.doc,n.from,n.to).join(u));else if("false"!=t.getAttribute("contenteditable")&&(n=/^(pre|div|p|li|table|br)$/i.test(t.nodeName),/^br$/i.test(t.nodeName)||0!=t.textContent.length)){n&&h();for(var r=0;ri.ch&&p.charCodeAt(p.length-c-1)==g.charCodeAt(g.length-c-1);)u--,c++;s[s.length-1]=p.slice(0,p.length-c).replace(/^\u200b+/,""),s[0]=s[0].slice(u).replace(/\u200b+$/,"");r=F(e,u),l=F(o,a.length?z(a).length-c:0);return 1n&&(wl(this,i.head.line,e,!0),n=i.head.line,r==this.doc.sel.primIndex&&Pr(this));else{for(var o=i.from(),i=i.to(),l=Math.max(n,o.line),n=Math.min(this.lastLine(),i.line-(i.ch?0:1))+1,s=l;s>1;if((l?n[2*l-1]:0)>=o)i=l;else{if(!(n[2*l+1]l)&&e.top>t.offsetHeight?a=e.top-t.offsetHeight:e.bottom+t.offsetHeight<=l&&(a=e.bottom),u+t.offsetWidth>o&&(u=o-t.offsetWidth)),t.style.top=a+"px",t.style.left=t.style.right="","right"==i?(u=s.sizer.clientWidth-t.offsetWidth,t.style.right="0px"):("left"==i?u=0:"middle"==i&&(u=(s.sizer.clientWidth-t.offsetWidth)/2),t.style.left=u+"px"),n&&(r=this,l={left:u,top:a,right:u+t.offsetWidth,bottom:a+t.offsetHeight},null!=(l=Hr(r,l)).scrollTop&&Ir(r,l.scrollTop),null!=l.scrollLeft&&Gr(r,l.scrollLeft))},triggerOnKeyDown:t(nl),triggerOnKeyPress:t(il),triggerOnKeyUp:rl,triggerOnMouseDown:t(al),execCommand:function(e){if(Yo.hasOwnProperty(e))return Yo[e].call(null,this)},triggerElectric:t(function(e){kl(this,e)}),findPosH:function(e,t,n,r){for(var i=1,o=(t<0&&(i=-1,t=-t),E(this.doc,e)),l=0;l { + 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; diff --git a/src/myfasthtml/assets/placeholder.min.js b/src/myfasthtml/assets/placeholder.min.js new file mode 100644 index 0000000..6735d69 --- /dev/null +++ b/src/myfasthtml/assets/placeholder.min.js @@ -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)})}); \ No newline at end of file diff --git a/src/myfasthtml/assets/show-hint.min.css b/src/myfasthtml/assets/show-hint.min.css new file mode 100644 index 0000000..b5e651c --- /dev/null +++ b/src/myfasthtml/assets/show-hint.min.css @@ -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} \ No newline at end of file diff --git a/src/myfasthtml/assets/show-hint.min.js b/src/myfasthtml/assets/show-hint.min.js new file mode 100644 index 0000000..37afbb3 --- /dev/null +++ b/src/myfasthtml/assets/show-hint.min.js @@ -0,0 +1 @@ +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(T){"use strict";var F="CodeMirror-hint-active";function n(t,e){var i;this.cm=t,this.options=e,this.widget=null,this.debounce=0,this.tick=0,this.startPos=this.cm.getCursor("start"),this.startLen=this.cm.getLine(this.startPos.line).length-this.cm.getSelection().length,this.options.updateOnCursorActivity&&t.on("cursorActivity",(i=this).activityFunc=function(){i.cursorActivity()})}T.showHint=function(t,e,i){if(!e)return t.showHint(i);i&&i.async&&(e.async=!0);var n={hint:e};if(i)for(var o in i)n[o]=i[o];return t.showHint(n)},T.defineExtension("showHint",function(t){t=function(t,e,i){var n=t.options.hintOptions,o={};for(s in c)o[s]=c[s];if(n)for(var s in n)void 0!==n[s]&&(o[s]=n[s]);if(i)for(var s in i)void 0!==i[s]&&(o[s]=i[s]);o.hint.resolve&&(o.hint=o.hint.resolve(t,e));return o}(this,this.getCursor("start"),t);var e=this.listSelections();if(!(1l.clientHeight+1;setTimeout(function(){f=s.getScrollInfo()});0b&&(l.style.width=b-5+"px",k-=H.right-H.left-b),l.style.left=(m=Math.max(p.left-k-y,0))+"px"),e)for(var x=l.firstChild;x;x=x.nextSibling)x.style.paddingRight=s.display.nativeBarWidth+"px";s.addKeyMap(this.keyMap=function(t,n){var o={Up:function(){n.moveFocus(-1)},Down:function(){n.moveFocus(1)},PageUp:function(){n.moveFocus(1-n.menuSize(),!0)},PageDown:function(){n.moveFocus(n.menuSize()-1,!0)},Home:function(){n.setFocus(0)},End:function(){n.setFocus(n.length-1)},Enter:n.pick,Tab:n.pick,Esc:n.close},e=(/Mac/.test(navigator.platform)&&(o["Ctrl-P"]=function(){n.moveFocus(-1)},o["Ctrl-N"]=function(){n.moveFocus(1)}),t.options.customKeys),s=e?{}:o;function i(t,e){var i="string"!=typeof e?function(t){return e(t,n)}:o.hasOwnProperty(e)?o[e]:e;s[t]=i}if(e)for(var c in e)e.hasOwnProperty(c)&&i(c,e[c]);var r=t.options.extraKeys;if(r)for(var c in r)r.hasOwnProperty(c)&&i(c,r[c]);return s}(o,{moveFocus:function(t,e){i.changeActive(i.selectedHint+t,e)},setFocus:function(t){i.changeActive(t)},menuSize:function(){return i.screenAmount()},length:n.length,close:function(){o.close()},pick:function(){i.pick()},data:t})),o.options.closeOnUnfocus&&(s.on("blur",this.onBlur=function(){C=setTimeout(function(){o.close()},100)}),s.on("focus",this.onFocus=function(){clearTimeout(C)})),s.on("scroll",this.onScroll=function(){var t=s.getScrollInfo(),e=s.getWrapperElement().getBoundingClientRect(),i=(f=f||s.getScrollInfo(),g+f.top-t.top),n=i-(r.pageYOffset||(c.documentElement||c.body).scrollTop);if(v||(n+=l.offsetHeight),n<=e.top||n>=e.bottom)return o.close();l.style.top=i+"px",l.style.left=m+f.left-t.left+"px"}),T.on(l,"dblclick",function(t){t=O(l,t.target||t.srcElement);t&&null!=t.hintId&&(i.changeActive(t.hintId),i.pick())}),T.on(l,"click",function(t){t=O(l,t.target||t.srcElement);t&&null!=t.hintId&&(i.changeActive(t.hintId),o.options.completeOnSingleClick&&i.pick())}),T.on(l,"mousedown",function(){setTimeout(function(){s.focus()},20)});var S=this.getSelectedHintRange();return 0===S.from&&0===S.to||this.scrollToActive(),T.signal(t,"select",n[this.selectedHint],l.childNodes[this.selectedHint]),!0}function r(t,e,i,n){t.async?t(e,n,i):(t=t(e,i))&&t.then?t.then(n):n(t)}n.prototype={close:function(){this.active()&&(this.cm.state.completionActive=null,this.tick=null,this.options.updateOnCursorActivity&&this.cm.off("cursorActivity",this.activityFunc),this.widget&&this.data&&T.signal(this.data,"close"),this.widget&&this.widget.close(),T.signal(this.cm,"endCompletion",this.cm))},active:function(){return this.cm.state.completionActive==this},pick:function(t,e){var i=t.list[e],n=this;this.cm.operation(function(){i.hint?i.hint(n.cm,t,i):n.cm.replaceRange(M(i),i.from||t.from,i.to||t.to,"complete"),T.signal(t,"pick",i),n.cm.scrollIntoView()}),this.options.closeOnPick&&this.close()},cursorActivity:function(){this.debounce&&(s(this.debounce),this.debounce=0);var t,e=this.startPos,i=(this.data&&(e=this.data.from),this.cm.getCursor()),n=this.cm.getLine(i.line);i.line!=this.startPos.line||n.length-i.ch!=this.startLen-this.startPos.ch||i.ch=this.data.list.length?t=e?this.data.list.length-1:0:t<0&&(t=e?0:this.data.list.length-1),this.selectedHint!=t&&((e=this.hints.childNodes[this.selectedHint])&&(e.className=e.className.replace(" "+F,""),e.removeAttribute("aria-selected")),(e=this.hints.childNodes[this.selectedHint=t]).className+=" "+F,e.setAttribute("aria-selected","true"),this.completion.cm.getInputField().setAttribute("aria-activedescendant",e.id),this.scrollToActive(),T.signal(this.data,"select",this.data.list[this.selectedHint],e))},scrollToActive:function(){var t=this.getSelectedHintRange(),e=this.hints.childNodes[t.from],t=this.hints.childNodes[t.to],i=this.hints.firstChild;e.offsetTopthis.hints.scrollTop+this.hints.clientHeight&&(this.hints.scrollTop=t.offsetTop+t.offsetHeight-this.hints.clientHeight+i.offsetTop)},screenAmount:function(){return Math.floor(this.hints.clientHeight/this.hints.firstChild.offsetHeight)||1},getSelectedHintRange:function(){var t=this.completion.options.scrollMargin||0;return{from:Math.max(0,this.selectedHint-t),to:Math.min(this.data.list.length-1,this.selectedHint+t)}}},T.registerHelper("hint","auto",{resolve:function(t,e){var i,c=t.getHelpers(e,"hint");return c.length?((e=function(t,n,o){var s=function(t,e){if(!t.somethingSelected())return e;for(var i=[],n=0;n,]/,closeOnPick:!0,closeOnUnfocus:!0,updateOnCursorActivity:!0,completeOnSingleClick:!0,container:null,customKeys:null,extraKeys:null,paddingForScrollbar:!0,moveOnOverlap:!0};T.defineOption("hintOptions",null)}); \ No newline at end of file diff --git a/src/myfasthtml/controls/CommandsDebugger.py b/src/myfasthtml/controls/CommandsDebugger.py index 9596b3c..884759e 100644 --- a/src/myfasthtml/controls/CommandsDebugger.py +++ b/src/myfasthtml/controls/CommandsDebugger.py @@ -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): diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 98cc4fd..87a6dc8 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -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() diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py index 1954cda..9e3c5ed 100644 --- a/src/myfasthtml/controls/DataGridColumnsManager.py +++ b/src/myfasthtml/controls/DataGridColumnsManager.py @@ -137,17 +137,29 @@ class DataGridColumnsManager(MultipleInstance): value=col_def.col_id, readonly=True), + 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("Visible"), - Input(name="visible", - type="checkbox", - cls=f"checkbox checkbox-{size}", - checked="true" if col_def.visible else None), - Label("type"), Select( *[Option(option.value, value=option.value, selected=option == col_def.type) for option in ColumnType], @@ -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" ), diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index afbfa16..473561d 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -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() diff --git a/src/myfasthtml/controls/DslEditor.py b/src/myfasthtml/controls/DslEditor.py new file mode 100644 index 0000000..df6a6db --- /dev/null +++ b/src/myfasthtml/controls/DslEditor.py @@ -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() diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index 26e7904..f385bf0 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -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): diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index 621c148..a99b4e7 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -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: diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py index b02623e..0f7457c 100644 --- a/src/myfasthtml/controls/datagrid_objects.py +++ b/src/myfasthtml/controls/datagrid_objects.py @@ -19,7 +19,7 @@ class DataGridColumnState: type: ColumnType = ColumnType.Text visible: bool = True width: int = DATAGRID_DEFAULT_COLUMN_WIDTH - format: list = field(default_factory=list) # + format: list = field(default_factory=list) # @dataclass @@ -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)) diff --git a/src/myfasthtml/core/DataGridsRegistry.py b/src/myfasthtml/core/DataGridsRegistry.py new file mode 100644 index 0000000..4905361 --- /dev/null +++ b/src/myfasthtml/core/DataGridsRegistry.py @@ -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()} diff --git a/src/myfasthtml/core/completions.py b/src/myfasthtml/core/completions.py new file mode 100644 index 0000000..35dcfc9 --- /dev/null +++ b/src/myfasthtml/core/completions.py @@ -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 = {} diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index bca860f..1c5a096 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -14,6 +14,7 @@ FILTER_INPUT_CID = "__filter_input__" class Routes: Commands = "/commands" Bindings = "/bindings" + Completions = "/completions" class ColumnType(Enum): diff --git a/src/myfasthtml/core/dbengine_utils.py b/src/myfasthtml/core/dbengine_utils.py new file mode 100644 index 0000000..62bd2be --- /dev/null +++ b/src/myfasthtml/core/dbengine_utils.py @@ -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) \ No newline at end of file diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 8f883a7..24180d8 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -14,7 +14,8 @@ class DbManager(SingleInstance): def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True): super().__init__(parent, auto_register=auto_register) - self.db = DbEngine(root=root) + if not hasattr(self, "db"): # hack to manage singleton inheritance + self.db = DbEngine(root=root) def save(self, entry, obj): self.db.save(self.get_tenant(), self.get_user(), entry, obj) diff --git a/src/myfasthtml/core/dsl/__init__.py b/src/myfasthtml/core/dsl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/myfasthtml/core/dsl/base.py b/src/myfasthtml/core/dsl/base.py new file mode 100644 index 0000000..3181826 --- /dev/null +++ b/src/myfasthtml/core/dsl/base.py @@ -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, + } diff --git a/src/myfasthtml/core/dsl/base_completion.py b/src/myfasthtml/core/dsl/base_completion.py new file mode 100644 index 0000000..3c87d1f --- /dev/null +++ b/src/myfasthtml/core/dsl/base_completion.py @@ -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__ diff --git a/src/myfasthtml/core/dsl/base_provider.py b/src/myfasthtml/core/dsl/base_provider.py new file mode 100644 index 0000000..4b2992a --- /dev/null +++ b/src/myfasthtml/core/dsl/base_provider.py @@ -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"]) + """ + ... diff --git a/src/myfasthtml/core/dsl/lark_to_lezer.py b/src/myfasthtml/core/dsl/lark_to_lezer.py new file mode 100644 index 0000000..b86aa37 --- /dev/null +++ b/src/myfasthtml/core/dsl/lark_to_lezer.py @@ -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), + } diff --git a/src/myfasthtml/core/dsl/types.py b/src/myfasthtml/core/dsl/types.py new file mode 100644 index 0000000..9f32561 --- /dev/null +++ b/src/myfasthtml/core/dsl/types.py @@ -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 = "" diff --git a/src/myfasthtml/core/dsl/utils.py b/src/myfasthtml/core/dsl/utils.py new file mode 100644 index 0000000..b3b9adb --- /dev/null +++ b/src/myfasthtml/core/dsl/utils.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/__init__.py b/src/myfasthtml/core/formatting/dsl/__init__.py new file mode 100644 index 0000000..b085d79 --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/__init__.py @@ -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", +] diff --git a/src/myfasthtml/core/formatting/dsl/completion/__init__.py b/src/myfasthtml/core/formatting/dsl/completion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/myfasthtml/core/formatting/dsl/completion/contexts.py b/src/myfasthtml/core/formatting/dsl/completion/contexts.py new file mode 100644 index 0000000..ce4df25 --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/completion/contexts.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/completion/engine.py b/src/myfasthtml/core/formatting/dsl/completion/engine.py new file mode 100644 index 0000000..650cdb8 --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/completion/engine.py @@ -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) diff --git a/src/myfasthtml/core/formatting/dsl/completion/presets.py b/src/myfasthtml/core/formatting/dsl/completion/presets.py new file mode 100644 index 0000000..db9243b --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/completion/presets.py @@ -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"), +] diff --git a/src/myfasthtml/core/formatting/dsl/completion/provider.py b/src/myfasthtml/core/formatting/dsl/completion/provider.py new file mode 100644 index 0000000..cd7fe0e --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/completion/provider.py @@ -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 + """ + ... diff --git a/src/myfasthtml/core/formatting/dsl/completion/suggestions.py b/src/myfasthtml/core/formatting/dsl/completion/suggestions.py new file mode 100644 index 0000000..e1df93a --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/completion/suggestions.py @@ -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 [] diff --git a/src/myfasthtml/core/formatting/dsl/definition.py b/src/myfasthtml/core/formatting/dsl/definition.py new file mode 100644 index 0000000..21ca043 --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/definition.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/exceptions.py b/src/myfasthtml/core/formatting/dsl/exceptions.py new file mode 100644 index 0000000..d4d93fc --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/exceptions.py @@ -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 "")) diff --git a/src/myfasthtml/core/formatting/dsl/grammar.py b/src/myfasthtml/core/formatting/dsl/grammar.py new file mode 100644 index 0000000..69350c0 --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/grammar.py @@ -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 +""" diff --git a/src/myfasthtml/core/formatting/dsl/parser.py b/src/myfasthtml/core/formatting/dsl/parser.py new file mode 100644 index 0000000..a5faa4c --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/parser.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/scopes.py b/src/myfasthtml/core/formatting/dsl/scopes.py new file mode 100644 index 0000000..9236d7e --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/scopes.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/transformer.py b/src/myfasthtml/core/formatting/dsl/transformer.py new file mode 100644 index 0000000..462c7d6 --- /dev/null +++ b/src/myfasthtml/core/formatting/dsl/transformer.py @@ -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 diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 0efa89f..f8f6c2f 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -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=}") diff --git a/src/myfasthtml/core/network_utils.py b/src/myfasthtml/core/vis_network_utils.py similarity index 100% rename from src/myfasthtml/core/network_utils.py rename to src/myfasthtml/core/vis_network_utils.py diff --git a/src/myfasthtml/examples/binding_checkbox.py b/src/myfasthtml/examples/binding_checkbox.py index 608724c..3979908 100644 --- a/src/myfasthtml/examples/binding_checkbox.py +++ b/src/myfasthtml/examples/binding_checkbox.py @@ -48,4 +48,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_datalist.py b/src/myfasthtml/examples/binding_datalist.py index 2517961..613ab07 100644 --- a/src/myfasthtml/examples/binding_datalist.py +++ b/src/myfasthtml/examples/binding_datalist.py @@ -69,4 +69,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_input.py b/src/myfasthtml/examples/binding_input.py index 50f6b87..2ab6393 100644 --- a/src/myfasthtml/examples/binding_input.py +++ b/src/myfasthtml/examples/binding_input.py @@ -30,4 +30,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_radio.py b/src/myfasthtml/examples/binding_radio.py index 0998280..c1fee26 100644 --- a/src/myfasthtml/examples/binding_radio.py +++ b/src/myfasthtml/examples/binding_radio.py @@ -44,4 +44,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_range.py b/src/myfasthtml/examples/binding_range.py index 89bdf2d..52b21ca 100644 --- a/src/myfasthtml/examples/binding_range.py +++ b/src/myfasthtml/examples/binding_range.py @@ -37,4 +37,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_select.py b/src/myfasthtml/examples/binding_select.py index a150b63..bef48f0 100644 --- a/src/myfasthtml/examples/binding_select.py +++ b/src/myfasthtml/examples/binding_select.py @@ -43,4 +43,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_select_multiple.py b/src/myfasthtml/examples/binding_select_multiple.py index 2d733e7..f1796b0 100644 --- a/src/myfasthtml/examples/binding_select_multiple.py +++ b/src/myfasthtml/examples/binding_select_multiple.py @@ -44,4 +44,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/binding_textarea.py b/src/myfasthtml/examples/binding_textarea.py index 1aa8022..19dcdcd 100644 --- a/src/myfasthtml/examples/binding_textarea.py +++ b/src/myfasthtml/examples/binding_textarea.py @@ -30,4 +30,4 @@ def get(): if __name__ == "__main__": debug_routes(app) - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/clickme.py b/src/myfasthtml/examples/clickme.py index 6ade14a..d90dbc1 100644 --- a/src/myfasthtml/examples/clickme.py +++ b/src/myfasthtml/examples/clickme.py @@ -26,4 +26,4 @@ def get_homepage(): if __name__ == "__main__": - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/command_with_htmx_params.py b/src/myfasthtml/examples/command_with_htmx_params.py index f01dfc0..1530e0a 100644 --- a/src/myfasthtml/examples/command_with_htmx_params.py +++ b/src/myfasthtml/examples/command_with_htmx_params.py @@ -25,4 +25,4 @@ def index(): if __name__ == "__main__": - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/examples/formatter_config.py b/src/myfasthtml/examples/formatter_config.py new file mode 100644 index 0000000..301eb6a --- /dev/null +++ b/src/myfasthtml/examples/formatter_config.py @@ -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) \ No newline at end of file diff --git a/src/myfasthtml/examples/helloworld.py b/src/myfasthtml/examples/helloworld.py index c65f0ff..89ac83e 100644 --- a/src/myfasthtml/examples/helloworld.py +++ b/src/myfasthtml/examples/helloworld.py @@ -12,4 +12,4 @@ def get_homepage(): if __name__ == "__main__": - serve(port=5002) + serve(port=5010) diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index e61e6e8..82580b7 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -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) diff --git a/tests/core/dsl/__init__.py b/tests/core/dsl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/dsl/test_base_completion.py b/tests/core/dsl/test_base_completion.py new file mode 100644 index 0000000..4ce91a6 --- /dev/null +++ b/tests/core/dsl/test_base_completion.py @@ -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 diff --git a/tests/core/dsl/test_lark_to_lezer.py b/tests/core/dsl/test_lark_to_lezer.py new file mode 100644 index 0000000..aa1f353 --- /dev/null +++ b/tests/core/dsl/test_lark_to_lezer.py @@ -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 diff --git a/tests/core/dsl/test_types.py b/tests/core/dsl/test_types.py new file mode 100644 index 0000000..1663172 --- /dev/null +++ b/tests/core/dsl/test_types.py @@ -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 == "" diff --git a/tests/core/dsl/test_utils.py b/tests/core/dsl/test_utils.py new file mode 100644 index 0000000..ffa46b7 --- /dev/null +++ b/tests/core/dsl/test_utils.py @@ -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("") == "" diff --git a/tests/core/formatting/dsl/__init__.py b/tests/core/formatting/dsl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/formatting/dsl/test_completion.py b/tests/core/formatting/dsl/test_completion.py new file mode 100644 index 0000000..68de035 --- /dev/null +++ b/tests/core/formatting/dsl/test_completion.py @@ -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 diff --git a/tests/core/formatting/test_dsl_parser.py b/tests/core/formatting/test_dsl_parser.py new file mode 100644 index 0000000..39969e3 --- /dev/null +++ b/tests/core/formatting/test_dsl_parser.py @@ -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) diff --git a/tests/core/formatting/test_formatting_dsl_definition.py b/tests/core/formatting/test_formatting_dsl_definition.py new file mode 100644 index 0000000..3ac5d1f --- /dev/null +++ b/tests/core/formatting/test_formatting_dsl_definition.py @@ -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" diff --git a/tests/core/test_datagrid_registry.py b/tests/core/test_datagrid_registry.py new file mode 100644 index 0000000..614379a --- /dev/null +++ b/tests/core/test_datagrid_registry.py @@ -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", "") == [] diff --git a/tests/core/test_network_utils.py b/tests/core/test_network_utils.py index b819029..e78b87a 100644 --- a/tests/core/test_network_utils.py +++ b/tests/core/test_network_utils.py @@ -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: