I can validate formatting in editor
This commit is contained in:
7
Makefile
7
Makefile
@@ -20,10 +20,13 @@ clean-tests:
|
|||||||
rm -rf tests/*.db
|
rm -rf tests/*.db
|
||||||
rm -rf tests/.myFastHtmlDb
|
rm -rf tests/.myFastHtmlDb
|
||||||
|
|
||||||
|
clean-app:
|
||||||
|
rm -rf src/.myFastHtmlDb
|
||||||
|
|
||||||
# Alias to clean everything
|
# Alias to clean everything
|
||||||
clean: clean-build clean-tests
|
clean: clean-build clean-tests clean-app
|
||||||
|
|
||||||
clean-all : clean
|
clean-all : clean
|
||||||
rm -rf src/.sesskey
|
rm -rf src/.sesskey
|
||||||
rm -rf src/Users.db
|
rm -rf src/Users.db
|
||||||
rm -rf src/.myFastHtmlDb
|
|
||||||
|
|||||||
@@ -961,7 +961,7 @@ The autocompletion system provides context-aware suggestions for the DSL editor.
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ REST API: /myfasthtml/autocompletion │
|
│ REST API: /myfasthtml/completions │
|
||||||
│ Request: { "text": "...", "cursor": {"line": 1, "ch": 15} }│
|
│ Request: { "text": "...", "cursor": {"line": 1, "ch": 15} }│
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
@@ -1024,33 +1024,43 @@ When the engine needs column values for `OPERATOR_VALUE` context, it uses the de
|
|||||||
|
|
||||||
### DatagridMetadataProvider
|
### DatagridMetadataProvider
|
||||||
|
|
||||||
A helper class providing access to DataGrid metadata for context-aware suggestions:
|
A Protocol providing access to DataGrid metadata for context-aware suggestions:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class DatagridMetadataProvider:
|
class DatagridMetadataProvider(Protocol):
|
||||||
"""Provides DataGrid metadata for autocompletion."""
|
"""Provides DataGrid metadata for autocompletion."""
|
||||||
|
|
||||||
def get_tables(self) -> list[str]:
|
def list_tables(self) -> list[str]:
|
||||||
"""List of available DataGrids (namespace.name format)."""
|
"""List of available DataGrids (namespace.name format)."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_columns(self, table_name: str) -> list[str]:
|
def list_columns(self, table_name: str) -> list[str]:
|
||||||
"""Column names for a specific DataGrid."""
|
"""Column names for a specific DataGrid."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_column_values(self, column_name: str) -> list[Any]:
|
def list_column_values(self, table_name: str, column_name: str) -> list[Any]:
|
||||||
"""Distinct values for a column in the current scope."""
|
"""Distinct values for a column in the current scope."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_row_count(self, table_name: str) -> int:
|
def get_row_count(self, table_name: str) -> int:
|
||||||
"""Number of rows in a DataGrid."""
|
"""Number of rows in a DataGrid."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def list_style_presets(self) -> list[str]:
|
||||||
|
"""List of available style preset names."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_format_presets(self) -> list[str]:
|
||||||
|
"""List of available format preset names."""
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
|
- `DatagridMetadataProvider` is a Protocol (structural typing), not an abstract base class
|
||||||
- DataGrid names follow the pattern `namespace.name` (multi-level namespaces supported)
|
- DataGrid names follow the pattern `namespace.name` (multi-level namespaces supported)
|
||||||
- The provider is passed at initialization, not with each API call
|
- The provider is passed at initialization, not with each API call
|
||||||
- Column values are fetched lazily when the scope is detected
|
- Column values are fetched lazily when the scope is detected
|
||||||
|
- `DataGridsManager` implements this Protocol
|
||||||
|
|
||||||
### API Interface
|
### API Interface
|
||||||
|
|
||||||
@@ -1230,24 +1240,35 @@ The following features are excluded from autocompletion for simplicity:
|
|||||||
|
|
||||||
| Component | Status | Location |
|
| Component | Status | Location |
|
||||||
|-----------|--------|----------|
|
|-----------|--------|----------|
|
||||||
|
| **DSL Parser** | | |
|
||||||
| DSL Grammar (lark) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/grammar.py` |
|
| 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 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Unit Tests (Parser) | :white_check_mark: ~35 tests | `tests/core/formatting/dsl/test_dsl_parser.py` |
|
||||||
| **Autocompletion** | | |
|
| **Autocompletion** | | |
|
||||||
| DatagridMetadataProvider | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
|
| DatagridMetadataProvider | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/provider.py` |
|
||||||
| Scope Detector | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
|
| Scope Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
|
||||||
| Context Detector | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
|
| Context Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
|
||||||
| Suggestions Generator | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
|
| Suggestions Generator | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/suggestions.py` |
|
||||||
| REST Endpoint | :x: Not implemented | `/myfasthtml/autocompletion` |
|
| Completion Engine | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/engine.py` |
|
||||||
| Unit Tests (Completion) | :x: Not implemented | `tests/core/formatting/test_dsl_completion.py` |
|
| Presets | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/presets.py` |
|
||||||
|
| Unit Tests (Completion) | :white_check_mark: ~50 tests | `tests/core/formatting/dsl/test_completion.py` |
|
||||||
|
| REST Endpoint | :white_check_mark: Implemented | `src/myfasthtml/core/utils.py` → `/myfasthtml/completions` |
|
||||||
| **Client-side** | | |
|
| **Client-side** | | |
|
||||||
| Lezer Grammar | :x: Not implemented | `static/js/formatrules.grammar` |
|
| Lark to Lezer converter | :white_check_mark: Implemented | `src/myfasthtml/core/dsl/lark_to_lezer.py` |
|
||||||
| CodeMirror Extension | :x: Not implemented | `static/js/formatrules-editor.js` |
|
| CodeMirror 5 assets | :white_check_mark: Implemented | `assets/codemirror.min.js`, `show-hint.min.js` |
|
||||||
| DaisyUI Theme | :x: Not implemented | `static/js/daisy-theme.js` |
|
| DslEditor control | :white_check_mark: Implemented | `src/myfasthtml/controls/DslEditor.py` |
|
||||||
|
| initDslEditor() JS | :white_check_mark: Implemented | `assets/myfasthtml.js` (static completions only) |
|
||||||
|
| Dynamic completions (server calls) | :white_check_mark: Implemented | `assets/myfasthtml.js` → `/myfasthtml/completions` |
|
||||||
|
| DaisyUI Theme | :o: Deferred | - |
|
||||||
|
| **Syntax Validation (Linting)** | | |
|
||||||
|
| REST Endpoint | :white_check_mark: Implemented | `src/myfasthtml/core/utils.py` → `/myfasthtml/validations` |
|
||||||
|
| CodeMirror lint integration | :white_check_mark: Implemented | `assets/myfasthtml.js` → `dslLint()` |
|
||||||
|
| Lint CSS/JS assets | :white_check_mark: Added | `assets/lint.min.js`, `assets/lint.css` |
|
||||||
|
| Warnings (semantic validation) | :o: Future | Not yet implemented (see note below) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1289,7 +1310,15 @@ src/myfasthtml/core/formatting/dsl/
|
|||||||
├── parser.py # DSLParser class with Indenter
|
├── parser.py # DSLParser class with Indenter
|
||||||
├── transformer.py # AST → dataclass conversion
|
├── transformer.py # AST → dataclass conversion
|
||||||
├── scopes.py # ColumnScope, RowScope, CellScope, ScopedRule
|
├── scopes.py # ColumnScope, RowScope, CellScope, ScopedRule
|
||||||
└── exceptions.py # DSLSyntaxError, DSLValidationError
|
├── exceptions.py # DSLSyntaxError, DSLValidationError
|
||||||
|
├── definition.py # FormattingDSL class for DslEditor
|
||||||
|
└── completion/ # Autocompletion module
|
||||||
|
├── __init__.py
|
||||||
|
├── contexts.py # Context enum, detect_scope(), detect_context()
|
||||||
|
├── suggestions.py # get_suggestions() for each context
|
||||||
|
├── engine.py # FormattingCompletionEngine, get_completions()
|
||||||
|
├── presets.py # Static suggestions (keywords, operators, colors)
|
||||||
|
└── provider.py # DatagridMetadataProvider protocol
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output structure:**
|
**Output structure:**
|
||||||
@@ -1303,15 +1332,80 @@ class ScopedRule:
|
|||||||
rule: FormatRule # From core/formatting/dataclasses.py
|
rule: FormatRule # From core/formatting/dataclasses.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Syntax Validation (Linting)
|
||||||
|
|
||||||
|
The DSL editor provides real-time syntax validation via CodeMirror's lint addon.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ CodeMirror Editor │
|
||||||
|
│ User types invalid syntax │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│ (debounced, 500ms)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ REST API: /myfasthtml/validations │
|
||||||
|
│ Request: { e_id, text, line, ch } │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Python: parse_dsl(text) │
|
||||||
|
│ - If OK: return {"errors": []} │
|
||||||
|
│ - If DSLSyntaxError: return error with line/column/message │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ CodeMirror Lint Display │
|
||||||
|
│ - Gutter marker (error icon) │
|
||||||
|
│ - Underline on error position │
|
||||||
|
│ - Tooltip with error message on hover │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**API Response Format:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"line": 5,
|
||||||
|
"column": 12,
|
||||||
|
"message": "Expected 'column', 'row' or 'cell'",
|
||||||
|
"severity": "error"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Line and column are 1-based (from lark parser). The JavaScript client converts to 0-based for CodeMirror.
|
||||||
|
|
||||||
|
**Future: Warnings Support**
|
||||||
|
|
||||||
|
Currently, only syntax errors (parse failures) are reported. Semantic warnings are planned for a future release:
|
||||||
|
|
||||||
|
| Warning Type | Example | Status |
|
||||||
|
|--------------|---------|--------|
|
||||||
|
| Unknown style preset | `style("unknown_preset")` | :o: Future |
|
||||||
|
| Unknown format preset | `format("invalid")` | :o: Future |
|
||||||
|
| Unknown parameter | `style(invalid_param=True)` | :o: Future |
|
||||||
|
| Non-existent column reference | `col.nonexistent` | :o: Future |
|
||||||
|
|
||||||
|
These warnings would be non-blocking (DSL still parses) but displayed with a different severity in the editor.
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
1. ~~**Add `lark` to dependencies** in `pyproject.toml`~~ Done
|
1. ~~**Add `lark` to dependencies** in `pyproject.toml`~~ Done
|
||||||
2. **Implement autocompletion API**:
|
2. ~~**Implement autocompletion API**~~ Done
|
||||||
- `DatagridMetadataProvider` class
|
3. ~~**Translate lark grammar to Lezer**~~ Done (`lark_to_lezer.py`)
|
||||||
- Scope detection (column/row/cell)
|
4. ~~**Build CodeMirror extension**~~ Done (`DslEditor.py` + `initDslEditor()`)
|
||||||
- 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
|
5. ~~**Integrate with DataGrid** - connect DSL output to formatting engine~~ Done
|
||||||
|
6. ~~**Implement dynamic client-side completions**~~ Done
|
||||||
|
- Engine ID: `DSLDefinition.get_id()` passed via `completionEngineId` in JS config
|
||||||
|
- Trigger: Hybrid (Ctrl+Space + auto after `.` `(` `"` and space)
|
||||||
|
- Files modified:
|
||||||
|
- `src/myfasthtml/controls/DslEditor.py:134` - Added `completionEngineId`
|
||||||
|
- `src/myfasthtml/assets/myfasthtml.js:2138-2300` - Async fetch to `/myfasthtml/completions`
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
```
|
```
|
||||||
cd src/myfasthtml/assets
|
cd src/myfasthtml/assets
|
||||||
|
|
||||||
# codemirror version 5 . Attenntion the version number is the url is misleading !
|
# Url to get codemirror resources : https://cdnjs.com/libraries/codemirror
|
||||||
|
|
||||||
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.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/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.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/hint/show-hint.min.css
|
||||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/placeholder.min.js
|
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/placeholder.min.js
|
||||||
|
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/addon/lint/lint.min.css
|
||||||
|
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/addon/lint/lint.min.js
|
||||||
```
|
```
|
||||||
1
src/myfasthtml/assets/lint.min.css
vendored
Normal file
1
src/myfasthtml/assets/lint.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.CodeMirror-lint-markers{width:16px}.CodeMirror-lint-tooltip{background-color:#ffd;border:1px solid #000;border-radius:4px 4px 4px 4px;color:#000;font-family:monospace;font-size:10pt;overflow:hidden;padding:2px 5px;position:fixed;white-space:pre;white-space:pre-wrap;z-index:100;max-width:600px;opacity:0;transition:opacity .4s;-moz-transition:opacity .4s;-webkit-transition:opacity .4s;-o-transition:opacity .4s;-ms-transition:opacity .4s}.CodeMirror-lint-mark{background-position:left bottom;background-repeat:repeat-x}.CodeMirror-lint-mark-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=)}.CodeMirror-lint-mark-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==)}.CodeMirror-lint-marker{background-position:center center;background-repeat:no-repeat;cursor:pointer;display:inline-block;height:16px;width:16px;vertical-align:middle;position:relative}.CodeMirror-lint-message{padding-left:18px;background-position:top left;background-repeat:no-repeat}.CodeMirror-lint-marker-warning,.CodeMirror-lint-message-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=)}.CodeMirror-lint-marker-error,.CodeMirror-lint-message-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=)}.CodeMirror-lint-marker-multiple{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-position:right bottom;width:100%;height:100%}.CodeMirror-lint-line-error{background-color:rgba(183,76,81,.08)}.CodeMirror-lint-line-warning{background-color:rgba(255,211,0,.1)}
|
||||||
1
src/myfasthtml/assets/lint.min.js
vendored
Normal file
1
src/myfasthtml/assets/lint.min.js
vendored
Normal file
@@ -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(h){"use strict";var g="CodeMirror-lint-markers",v="CodeMirror-lint-line-";function u(t){t.parentNode&&t.parentNode.removeChild(t)}function C(t,e,n,o){t=t,e=e,n=n,(i=document.createElement("div")).className="CodeMirror-lint-tooltip cm-s-"+t.options.theme,i.appendChild(n.cloneNode(!0)),(t.state.lint.options.selfContain?t.getWrapperElement():document.body).appendChild(i),h.on(document,"mousemove",a),a(e),null!=i.style.opacity&&(i.style.opacity=1);var i,r=i;function a(t){if(!i.parentNode)return h.off(document,"mousemove",a);i.style.top=Math.max(0,t.clientY-i.offsetHeight-5)+"px",i.style.left=t.clientX+5+"px"}function s(){var t;h.off(o,"mouseout",s),r&&((t=r).parentNode&&(null==t.style.opacity&&u(t),t.style.opacity=0,setTimeout(function(){u(t)},600)),r=null)}var l=setInterval(function(){if(r)for(var t=o;;t=t.parentNode){if((t=t&&11==t.nodeType?t.host:t)==document.body)return;if(!t){s();break}}if(!r)return clearInterval(l)},400);h.on(o,"mouseout",s)}function a(l,t,e){for(var n in this.marked=[],(t=t instanceof Function?{getAnnotations:t}:t)&&!0!==t||(t={}),this.options={},this.linterOptions=t.options||{},o)this.options[n]=o[n];for(var n in t)o.hasOwnProperty(n)?null!=t[n]&&(this.options[n]=t[n]):t.options||(this.linterOptions[n]=t[n]);this.timeout=null,this.hasGutter=e,this.onMouseOver=function(t){var e=l,n=t.target||t.srcElement;if(/\bCodeMirror-lint-mark-/.test(n.className)){for(var n=n.getBoundingClientRect(),o=(n.left+n.right)/2,n=(n.top+n.bottom)/2,i=e.findMarksAt(e.coordsChar({left:o,top:n},"client")),r=[],a=0;a<i.length;++a){var s=i[a].__annotation;s&&r.push(s)}r.length&&!function(t,e,n){for(var o=n.target||n.srcElement,i=document.createDocumentFragment(),r=0;r<e.length;r++){var a=e[r];i.appendChild(M(a))}C(t,n,i,o)}(e,r,t)}},this.waitingFor=0}var o={highlightLines:!1,tooltips:!0,delay:500,lintOnChange:!0,getAnnotations:null,async:!1,selfContain:null,formatAnnotation:null,onUpdateLinting:null};function y(t){var n,e=t.state.lint;e.hasGutter&&t.clearGutter(g),e.options.highlightLines&&(n=t).eachLine(function(t){var e=t.wrapClass&&/\bCodeMirror-lint-line-\w+\b/.exec(t.wrapClass);e&&n.removeLineClass(t,"wrap",e[0])});for(var o=0;o<e.marked.length;++o)e.marked[o].clear();e.marked.length=0}function M(t){var e=(e=t.severity)||"error",n=document.createElement("div");return n.className="CodeMirror-lint-message CodeMirror-lint-message-"+e,void 0!==t.messageHTML?n.innerHTML=t.messageHTML:n.appendChild(document.createTextNode(t.message)),n}function s(e){var t,n,o,i,r,a,s=e.state.lint;function l(){a=-1,o.off("change",l)}!s||(t=(i=s.options).getAnnotations||e.getHelper(h.Pos(0,0),"lint"))&&(i.async||t.async?(i=t,r=(o=e).state.lint,a=++r.waitingFor,o.on("change",l),i(o.getValue(),function(t,e){o.off("change",l),r.waitingFor==a&&(e&&t instanceof h&&(t=e),o.operation(function(){c(o,t)}))},r.linterOptions,o)):(n=t(e.getValue(),s.linterOptions,e))&&(n.then?n.then(function(t){e.operation(function(){c(e,t)})}):e.operation(function(){c(e,n)})))}function c(t,e){var n=t.state.lint;if(n){for(var o,i,r=n.options,a=(y(t),function(t){for(var e=[],n=0;n<t.length;++n){var o=t[n],i=o.from.line;(e[i]||(e[i]=[])).push(o)}return e}(e)),s=0;s<a.length;++s)if(u=a[s]){for(var l=[],u=u.filter(function(t){return!(-1<l.indexOf(t.message))&&l.push(t.message)}),c=null,f=n.hasGutter&&document.createDocumentFragment(),m=0;m<u.length;++m){var p=u[m],d=p.severity;i=d=d||"error",c="error"==(o=c)?o:i,r.formatAnnotation&&(p=r.formatAnnotation(p)),n.hasGutter&&f.appendChild(M(p)),p.to&&n.marked.push(t.markText(p.from,p.to,{className:"CodeMirror-lint-mark CodeMirror-lint-mark-"+d,__annotation:p}))}n.hasGutter&&t.setGutterMarker(s,g,function(e,n,t,o,i){var r=document.createElement("div"),a=r;return r.className="CodeMirror-lint-marker CodeMirror-lint-marker-"+t,o&&((a=r.appendChild(document.createElement("div"))).className="CodeMirror-lint-marker CodeMirror-lint-marker-multiple"),0!=i&&h.on(a,"mouseover",function(t){C(e,t,n,a)}),r}(t,f,c,1<a[s].length,r.tooltips)),r.highlightLines&&t.addLineClass(s,"wrap",v+c)}r.onUpdateLinting&&r.onUpdateLinting(e,a,t)}}function l(t){var e=t.state.lint;e&&(clearTimeout(e.timeout),e.timeout=setTimeout(function(){s(t)},e.options.delay))}h.defineOption("lint",!1,function(t,e,n){if(n&&n!=h.Init&&(y(t),!1!==t.state.lint.options.lintOnChange&&t.off("change",l),h.off(t.getWrapperElement(),"mouseover",t.state.lint.onMouseOver),clearTimeout(t.state.lint.timeout),delete t.state.lint),e){for(var o=t.getOption("gutters"),i=!1,r=0;r<o.length;++r)o[r]==g&&(i=!0);n=t.state.lint=new a(t,e,i);n.options.lintOnChange&&t.on("change",l),0!=n.options.tooltips&&"gutter"!=n.options.tooltips&&h.on(t.getWrapperElement(),"mouseover",n.onMouseOver),s(t)}}),h.defineExtension("performLint",function(){s(this)})});
|
||||||
@@ -2141,9 +2141,11 @@ function initDslEditor(config) {
|
|||||||
textareaId,
|
textareaId,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
autocompletion,
|
autocompletion,
|
||||||
|
linting,
|
||||||
placeholder,
|
placeholder,
|
||||||
readonly,
|
readonly,
|
||||||
updateCommandId,
|
updateCommandId,
|
||||||
|
dslId,
|
||||||
dsl
|
dsl
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
@@ -2162,68 +2164,146 @@ function initDslEditor(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
* Build completion list from DSL config
|
* DSL autocompletion hint (async via server)
|
||||||
* -------------------------------------------------- */
|
* -------------------------------------------------- */
|
||||||
|
|
||||||
const completionItems = [];
|
// Characters that trigger auto-completion
|
||||||
|
const AUTO_TRIGGER_CHARS = [".", "(", '"', " "];
|
||||||
|
|
||||||
if (dsl && dsl.completions) {
|
function dslHint(cm, callback) {
|
||||||
const pushAll = (items) => {
|
const cursor = cm.getCursor();
|
||||||
if (!Array.isArray(items)) return;
|
const text = cm.getValue();
|
||||||
items.forEach(item => completionItems.push(item));
|
|
||||||
};
|
|
||||||
|
|
||||||
pushAll(dsl.completions.keywords);
|
// Build URL with query params
|
||||||
pushAll(dsl.completions.operators);
|
const params = new URLSearchParams({
|
||||||
pushAll(dsl.completions.functions);
|
e_id: dslId,
|
||||||
pushAll(dsl.completions.types);
|
text: text,
|
||||||
pushAll(dsl.completions.literals);
|
line: cursor.line,
|
||||||
|
ch: cursor.ch
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(`/myfasthtml/completions?${params}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data || !data.suggestions || data.suggestions.length === 0) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({
|
||||||
|
list: data.suggestions.map(s => ({
|
||||||
|
text: s.label,
|
||||||
|
displayText: s.detail ? `${s.label} - ${s.detail}` : s.label
|
||||||
|
})),
|
||||||
|
from: CodeMirror.Pos(data.from.line, data.from.ch),
|
||||||
|
to: CodeMirror.Pos(data.to.line, data.to.ch)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("DslEditor: Completion error", err);
|
||||||
|
callback(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark hint function as async for CodeMirror
|
||||||
|
dslHint.async = true;
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
* DSL autocompletion hint
|
* DSL linting (async via server)
|
||||||
* -------------------------------------------------- */
|
* -------------------------------------------------- */
|
||||||
|
|
||||||
function dslHint(cm) {
|
function dslLint(text, updateOutput, options, cm) {
|
||||||
const cursor = cm.getCursor();
|
const cursor = cm.getCursor();
|
||||||
const line = cm.getLine(cursor.line);
|
|
||||||
const ch = cursor.ch;
|
|
||||||
|
|
||||||
let start = ch;
|
const params = new URLSearchParams({
|
||||||
while (start > 0 && /\w/.test(line.charAt(start - 1))) {
|
e_id: dslId,
|
||||||
start--;
|
text: text,
|
||||||
}
|
line: cursor.line,
|
||||||
|
ch: cursor.ch
|
||||||
|
});
|
||||||
|
|
||||||
const word = line.slice(start, ch);
|
fetch(`/myfasthtml/validations?${params}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data || !data.errors || data.errors.length === 0) {
|
||||||
|
updateOutput([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const matches = completionItems.filter(item =>
|
// Convert server errors to CodeMirror lint format
|
||||||
item.startsWith(word)
|
// Server returns 1-based positions, CodeMirror expects 0-based
|
||||||
);
|
const annotations = data.errors.map(err => ({
|
||||||
|
from: CodeMirror.Pos(err.line - 1, Math.max(0, err.column - 1)),
|
||||||
|
to: CodeMirror.Pos(err.line - 1, err.column),
|
||||||
|
message: err.message,
|
||||||
|
severity: err.severity || "error"
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
updateOutput(annotations);
|
||||||
list: matches,
|
})
|
||||||
from: CodeMirror.Pos(cursor.line, start),
|
.catch(err => {
|
||||||
to: CodeMirror.Pos(cursor.line, ch)
|
console.error("DslEditor: Linting error", err);
|
||||||
};
|
updateOutput([]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark lint function as async for CodeMirror
|
||||||
|
dslLint.async = true;
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
* Create CodeMirror editor
|
* Create CodeMirror editor
|
||||||
* -------------------------------------------------- */
|
* -------------------------------------------------- */
|
||||||
|
|
||||||
const editor = CodeMirror(editorContainer, {
|
const enableCompletion = autocompletion && dslId;
|
||||||
|
// Only enable linting if the lint addon is loaded
|
||||||
|
const lintAddonLoaded = typeof CodeMirror.lint !== "undefined" ||
|
||||||
|
(CodeMirror.defaults && "lint" in CodeMirror.defaults);
|
||||||
|
const enableLinting = linting && dslId && lintAddonLoaded;
|
||||||
|
|
||||||
|
const editorOptions = {
|
||||||
value: textarea.value || "",
|
value: textarea.value || "",
|
||||||
lineNumbers: !!lineNumbers,
|
lineNumbers: !!lineNumbers,
|
||||||
readOnly: !!readonly,
|
readOnly: !!readonly,
|
||||||
placeholder: placeholder || "",
|
placeholder: placeholder || "",
|
||||||
extraKeys: autocompletion ? {
|
extraKeys: enableCompletion ? {
|
||||||
"Ctrl-Space": "autocomplete"
|
"Ctrl-Space": "autocomplete"
|
||||||
} : {},
|
} : {},
|
||||||
hintOptions: autocompletion ? {
|
hintOptions: enableCompletion ? {
|
||||||
hint: dslHint,
|
hint: dslHint,
|
||||||
completeSingle: false
|
completeSingle: false
|
||||||
} : undefined
|
} : undefined
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Add linting options if enabled and addon is available
|
||||||
|
if (enableLinting) {
|
||||||
|
// Include linenumbers gutter if lineNumbers is enabled
|
||||||
|
editorOptions.gutters = lineNumbers
|
||||||
|
? ["CodeMirror-linenumbers", "CodeMirror-lint-markers"]
|
||||||
|
: ["CodeMirror-lint-markers"];
|
||||||
|
editorOptions.lint = {
|
||||||
|
getAnnotations: dslLint,
|
||||||
|
async: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = CodeMirror(editorContainer, editorOptions);
|
||||||
|
|
||||||
|
/* --------------------------------------------------
|
||||||
|
* Auto-trigger completion on specific characters
|
||||||
|
* -------------------------------------------------- */
|
||||||
|
|
||||||
|
if (enableCompletion) {
|
||||||
|
editor.on("inputRead", function (cm, change) {
|
||||||
|
if (change.origin !== "+input") return;
|
||||||
|
|
||||||
|
const lastChar = change.text[change.text.length - 1];
|
||||||
|
const lastCharOfInput = lastChar.slice(-1);
|
||||||
|
|
||||||
|
if (AUTO_TRIGGER_CHARS.includes(lastCharOfInput)) {
|
||||||
|
cm.showHint({completeSingle: false});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
* Debounced update + HTMX transport
|
* Debounced update + HTMX transport
|
||||||
@@ -2278,11 +2358,10 @@ function initDslEditor(config) {
|
|||||||
setContent: (content) => editor.setValue(content)
|
setContent: (content) => editor.setValue(content)
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(`DslEditor initialized (CM5 + HTMX): ${elementId} with ${dsl?.name || "DSL"}`);
|
console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function updateDatagridSelection(datagridId) {
|
function updateDatagridSelection(datagridId) {
|
||||||
const selectionManager = document.getElementById(`tsm_${datagridId}`);
|
const selectionManager = document.getElementById(`tsm_${datagridId}`);
|
||||||
if (!selectionManager) return;
|
if (!selectionManager) return;
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ from pandas import DataFrame
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.CycleStateControl import CycleStateControl
|
from myfasthtml.controls.CycleStateControl import CycleStateControl
|
||||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
||||||
|
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
|
||||||
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
||||||
from myfasthtml.controls.DslEditor import DslEditor
|
from myfasthtml.controls.DslEditor import DslEditorConf
|
||||||
from myfasthtml.controls.Mouse import Mouse
|
from myfasthtml.controls.Mouse import Mouse
|
||||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||||
@@ -180,7 +181,7 @@ class DataGrid(MultipleInstance):
|
|||||||
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
||||||
|
|
||||||
# add Panel
|
# add Panel
|
||||||
self._panel = Panel(self, conf=PanelConf(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._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("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
|
||||||
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
|
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
|
||||||
@@ -206,7 +207,12 @@ class DataGrid(MultipleInstance):
|
|||||||
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
||||||
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
|
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
|
||||||
|
|
||||||
self._formatting_editor = DslEditor(self, dsl=FormattingDSL())
|
editor_conf = DslEditorConf()
|
||||||
|
self._formatting_editor = DataGridFormattingEditor(self,
|
||||||
|
conf=editor_conf,
|
||||||
|
dsl=FormattingDSL(),
|
||||||
|
save_state=self._settings.save_state,
|
||||||
|
_id="#formatting_editor")
|
||||||
|
|
||||||
# other definitions
|
# other definitions
|
||||||
self._mouse_support = {
|
self._mouse_support = {
|
||||||
|
|||||||
7
src/myfasthtml/controls/DataGridFormattingEditor.py
Normal file
7
src/myfasthtml/controls/DataGridFormattingEditor.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from myfasthtml.controls.DslEditor import DslEditor
|
||||||
|
|
||||||
|
|
||||||
|
class DataGridFormattingEditor(DslEditor):
|
||||||
|
|
||||||
|
def on_dsl_change(self, dsl):
|
||||||
|
pass
|
||||||
@@ -14,6 +14,11 @@ from myfasthtml.controls.helpers import mk
|
|||||||
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
|
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
|
from myfasthtml.core.dsls import DslsManager
|
||||||
|
from myfasthtml.core.formatting.dsl.completion.engine import FormattingCompletionEngine
|
||||||
|
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
|
||||||
|
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
|
||||||
|
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||||
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
||||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
||||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||||
@@ -72,7 +77,7 @@ class Commands(BaseCommands):
|
|||||||
key="SelectNode")
|
key="SelectNode")
|
||||||
|
|
||||||
|
|
||||||
class DataGridsManager(SingleInstance):
|
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
if not getattr(self, "_is_new_instance", False):
|
if not getattr(self, "_is_new_instance", False):
|
||||||
@@ -89,6 +94,11 @@ class DataGridsManager(SingleInstance):
|
|||||||
# Global presets shared across all DataGrids
|
# Global presets shared across all DataGrids
|
||||||
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
||||||
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
||||||
|
|
||||||
|
# register the auto-completion for the formatter DSL
|
||||||
|
DslsManager.register(FormattingDSL().get_id(),
|
||||||
|
FormattingCompletionEngine(self),
|
||||||
|
DSLParser())
|
||||||
|
|
||||||
def upload_from_source(self):
|
def upload_from_source(self):
|
||||||
file_upload = FileUpload(self)
|
file_upload = FileUpload(self)
|
||||||
@@ -104,6 +114,7 @@ class DataGridsManager(SingleInstance):
|
|||||||
dg_conf = DatagridConf(namespace=namespace, name=name)
|
dg_conf = DatagridConf(namespace=namespace, name=name)
|
||||||
dg = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created
|
dg = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created
|
||||||
dg.init_from_dataframe(df)
|
dg.init_from_dataframe(df)
|
||||||
|
self._registry.put(namespace, name, dg.get_id())
|
||||||
document = DocumentDefinition(
|
document = DocumentDefinition(
|
||||||
document_id=str(uuid.uuid4()),
|
document_id=str(uuid.uuid4()),
|
||||||
namespace=namespace,
|
namespace=namespace,
|
||||||
@@ -154,6 +165,26 @@ class DataGridsManager(SingleInstance):
|
|||||||
self._tree.clear()
|
self._tree.clear()
|
||||||
return self._tree
|
return self._tree
|
||||||
|
|
||||||
|
# === DatagridMetadataProvider ===
|
||||||
|
|
||||||
|
def list_tables(self):
|
||||||
|
return self._registry.get_all_tables()
|
||||||
|
|
||||||
|
def list_columns(self, table_name):
|
||||||
|
return self._registry.get_columns(table_name)
|
||||||
|
|
||||||
|
def list_column_values(self, table_name, column_name):
|
||||||
|
return self._registry.get_column_values(table_name, column_name)
|
||||||
|
|
||||||
|
def get_row_count(self, table_name):
|
||||||
|
return self._registry.get_row_count(table_name)
|
||||||
|
|
||||||
|
def list_style_presets(self) -> list[str]:
|
||||||
|
return list(self.style_presets.keys())
|
||||||
|
|
||||||
|
def list_format_presets(self) -> list[str]:
|
||||||
|
return list(self.formatter_presets.keys())
|
||||||
|
|
||||||
# === Presets Management ===
|
# === Presets Management ===
|
||||||
|
|
||||||
def get_style_presets(self) -> dict:
|
def get_style_presets(self) -> dict:
|
||||||
@@ -194,6 +225,8 @@ class DataGridsManager(SingleInstance):
|
|||||||
if name in self.formatter_presets:
|
if name in self.formatter_presets:
|
||||||
del self.formatter_presets[name]
|
del self.formatter_presets[name]
|
||||||
|
|
||||||
|
# === UI ===
|
||||||
|
|
||||||
def mk_main_icons(self):
|
def mk_main_icons(self):
|
||||||
return Div(
|
return Div(
|
||||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from fasthtml.components import *
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.dsl.base import DSLDefinition
|
from myfasthtml.core.dsl.base import DSLDefinition
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
@@ -25,19 +26,22 @@ logger = logging.getLogger("DslEditor")
|
|||||||
@dataclass
|
@dataclass
|
||||||
class DslEditorConf:
|
class DslEditorConf:
|
||||||
"""Configuration for DslEditor."""
|
"""Configuration for DslEditor."""
|
||||||
|
name: str = None
|
||||||
line_numbers: bool = True
|
line_numbers: bool = True
|
||||||
autocompletion: bool = True
|
autocompletion: bool = True
|
||||||
|
linting: bool = True
|
||||||
placeholder: str = ""
|
placeholder: str = ""
|
||||||
readonly: bool = False
|
readonly: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DslEditorState:
|
class DslEditorState(DbObject):
|
||||||
"""Non-persisted state for DslEditor."""
|
"""Non-persisted state for DslEditor."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, owner, name, save_state):
|
||||||
self.content: str = ""
|
with self.initializing():
|
||||||
self.auto_save: bool = True
|
super().__init__(owner, name=name, save_state=save_state)
|
||||||
|
self.content: str = ""
|
||||||
|
self.auto_save: bool = True
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
@@ -87,13 +91,14 @@ class DslEditor(MultipleInstance):
|
|||||||
parent,
|
parent,
|
||||||
dsl: DSLDefinition,
|
dsl: DSLDefinition,
|
||||||
conf: Optional[DslEditorConf] = None,
|
conf: Optional[DslEditorConf] = None,
|
||||||
|
save_state: bool = True,
|
||||||
_id: Optional[str] = None,
|
_id: Optional[str] = None,
|
||||||
):
|
):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
self._dsl = dsl
|
self._dsl = dsl
|
||||||
self.conf = conf or DslEditorConf()
|
self.conf = conf or DslEditorConf()
|
||||||
self._state = DslEditorState()
|
self._state = DslEditorState(self, name=conf.name, save_state=save_state)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
|
|
||||||
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
|
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
|
||||||
@@ -114,9 +119,9 @@ class DslEditor(MultipleInstance):
|
|||||||
self.on_content_changed()
|
self.on_content_changed()
|
||||||
logger.debug(f"Content updated: {len(content)} chars")
|
logger.debug(f"Content updated: {len(content)} chars")
|
||||||
|
|
||||||
def toggle_auto_save(self) -> None:
|
def toggle_auto_save(self):
|
||||||
self._state.auto_save = not self._state.auto_save
|
self._state.auto_save = not self._state.auto_save
|
||||||
self._mk_auto_save()
|
return self._mk_auto_save()
|
||||||
|
|
||||||
def on_content_changed(self) -> None:
|
def on_content_changed(self) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -128,9 +133,11 @@ class DslEditor(MultipleInstance):
|
|||||||
"textareaId": f"ta_{self._id}",
|
"textareaId": f"ta_{self._id}",
|
||||||
"lineNumbers": self.conf.line_numbers,
|
"lineNumbers": self.conf.line_numbers,
|
||||||
"autocompletion": self.conf.autocompletion,
|
"autocompletion": self.conf.autocompletion,
|
||||||
|
"linting": self.conf.linting,
|
||||||
"placeholder": self.conf.placeholder,
|
"placeholder": self.conf.placeholder,
|
||||||
"readonly": self.conf.readonly,
|
"readonly": self.conf.readonly,
|
||||||
"updateCommandId": str(self.commands.update_content().id),
|
"updateCommandId": str(self.commands.update_content().id),
|
||||||
|
"dslId": self._dsl.get_id(),
|
||||||
"dsl": {
|
"dsl": {
|
||||||
"name": self._dsl.name,
|
"name": self._dsl.name,
|
||||||
"completions": self._dsl.completions,
|
"completions": self._dsl.completions,
|
||||||
|
|||||||
@@ -159,31 +159,31 @@ class Panel(MultipleInstance):
|
|||||||
enabled = self.conf.left if side == "left" else self.conf.right
|
enabled = self.conf.left if side == "left" else self.conf.right
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
content = self._right if side == "right" else self._left
|
content = self._right if side == "right" else self._left
|
||||||
show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title
|
show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title
|
||||||
title = self.conf.left_title if side == "left" else self.conf.right_title
|
title = self.conf.left_title if side == "left" else self.conf.right_title
|
||||||
|
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls=f"mf-resizer mf-resizer-{side}",
|
cls=f"mf-resizer mf-resizer-{side}",
|
||||||
data_command_id=self.commands.update_side_width(side).id,
|
data_command_id=self.commands.update_side_width(side).id,
|
||||||
data_side=side
|
data_side=side
|
||||||
)
|
)
|
||||||
|
|
||||||
hide_icon = mk.icon(
|
hide_icon = mk.icon(
|
||||||
subtract20_regular,
|
subtract20_regular,
|
||||||
size=20,
|
size=20,
|
||||||
command=self.commands.set_side_visible(side, False),
|
command=self.commands.set_side_visible(side, False),
|
||||||
cls="mf-panel-hide-icon"
|
cls="mf-panel-hide-icon"
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_cls = f"mf-panel-{side}"
|
panel_cls = f"mf-panel-{side}"
|
||||||
if not visible:
|
if not visible:
|
||||||
panel_cls += " mf-hidden"
|
panel_cls += " mf-hidden"
|
||||||
if show_title:
|
if show_title:
|
||||||
panel_cls += " mf-panel-with-title"
|
panel_cls += " mf-panel-with-title"
|
||||||
|
|
||||||
# Left panel: content then resizer (resizer on the right)
|
# Left panel: content then resizer (resizer on the right)
|
||||||
# Right panel: resizer then content (resizer on the left)
|
# Right panel: resizer then content (resizer on the left)
|
||||||
if show_title:
|
if show_title:
|
||||||
@@ -202,6 +202,7 @@ class Panel(MultipleInstance):
|
|||||||
body,
|
body,
|
||||||
resizer,
|
resizer,
|
||||||
cls=panel_cls,
|
cls=panel_cls,
|
||||||
|
style=f"width: {self._state.left_width}px;",
|
||||||
id=self._ids.panel(side)
|
id=self._ids.panel(side)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -209,6 +210,7 @@ class Panel(MultipleInstance):
|
|||||||
resizer,
|
resizer,
|
||||||
body,
|
body,
|
||||||
cls=panel_cls,
|
cls=panel_cls,
|
||||||
|
style=f"width: {self._state.right_width}px;",
|
||||||
id=self._ids.panel(side)
|
id=self._ids.panel(side)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -218,6 +220,7 @@ class Panel(MultipleInstance):
|
|||||||
Div(content, id=self._ids.content(side)),
|
Div(content, id=self._ids.content(side)),
|
||||||
resizer,
|
resizer,
|
||||||
cls=panel_cls,
|
cls=panel_cls,
|
||||||
|
style=f"width: {self._state.left_width}px;",
|
||||||
id=self._ids.panel(side)
|
id=self._ids.panel(side)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -226,6 +229,7 @@ class Panel(MultipleInstance):
|
|||||||
hide_icon,
|
hide_icon,
|
||||||
Div(content, id=self._ids.content(side)),
|
Div(content, id=self._ids.content(side)),
|
||||||
cls=panel_cls,
|
cls=panel_cls,
|
||||||
|
style=f"width: {self._state.left_width}px;",
|
||||||
id=self._ids.panel(side)
|
id=self._ids.panel(side)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,14 +254,14 @@ class Panel(MultipleInstance):
|
|||||||
enabled = self.conf.left if side == "left" else self.conf.right
|
enabled = self.conf.left if side == "left" else self.conf.right
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right
|
show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right
|
||||||
if not show_display:
|
if not show_display:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
||||||
|
|
||||||
return mk.icon(
|
return mk.icon(
|
||||||
more_horizontal20_regular,
|
more_horizontal20_regular,
|
||||||
command=self.commands.set_side_visible(side, True),
|
command=self.commands.set_side_visible(side, True),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from myfasthtml.core.dbmanager import DbManager
|
from myfasthtml.core.dbmanager import DbManager
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
|
|
||||||
DATAGRIDS_REGISTRY_ENTRY_KEY = "DataGridsRegistryEntry"
|
DATAGRIDS_REGISTRY_ENTRY_KEY = "data_grids_registry"
|
||||||
|
|
||||||
|
|
||||||
class DataGridsRegistry(SingleInstance):
|
class DataGridsRegistry(SingleInstance):
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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 = {}
|
|
||||||
@@ -15,6 +15,7 @@ class Routes:
|
|||||||
Commands = "/commands"
|
Commands = "/commands"
|
||||||
Bindings = "/bindings"
|
Bindings = "/bindings"
|
||||||
Completions = "/completions"
|
Completions = "/completions"
|
||||||
|
Validations = "/validations"
|
||||||
|
|
||||||
|
|
||||||
class ColumnType(Enum):
|
class ColumnType(Enum):
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ class DbObject:
|
|||||||
|
|
||||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
|
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self._name = name or owner.get_full_id()
|
self._name = name or owner.get_id()
|
||||||
|
if self._name.startswith(("#", "-")) and owner.get_parent() is not None:
|
||||||
|
self._name = owner.get_parent().get_id() + self._name
|
||||||
self._db_manager = db_manager or DbManager(self._owner)
|
self._db_manager = db_manager or DbManager(self._owner)
|
||||||
self._save_state = save_state
|
self._save_state = save_state
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from myfasthtml.core.dsl.lark_to_lezer import (
|
|||||||
lark_to_lezer_grammar,
|
lark_to_lezer_grammar,
|
||||||
extract_completions_from_grammar,
|
extract_completions_from_grammar,
|
||||||
)
|
)
|
||||||
|
from myfasthtml.core.utils import make_safe_id
|
||||||
|
|
||||||
|
|
||||||
class DSLDefinition(ABC):
|
class DSLDefinition(ABC):
|
||||||
@@ -82,3 +83,6 @@ class DSLDefinition(ABC):
|
|||||||
"lezerGrammar": self.lezer_grammar,
|
"lezerGrammar": self.lezer_grammar,
|
||||||
"completions": self.completions,
|
"completions": self.completions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return make_safe_id(self.name)
|
||||||
|
|||||||
31
src/myfasthtml/core/dsls.py
Normal file
31
src/myfasthtml/core/dsls.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||||
|
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DslDefinition:
|
||||||
|
completion: BaseCompletionEngine
|
||||||
|
validation: DSLParser # To do, this parser is not generic (specific to the Formatting DSL)
|
||||||
|
|
||||||
|
|
||||||
|
class DslsManager:
|
||||||
|
dsls: dict[str, DslDefinition] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def register(dsl_id: str, completion: BaseCompletionEngine, validation: DSLParser):
|
||||||
|
# then engine_id is actually the DSL id
|
||||||
|
DslsManager.dsls[dsl_id] = DslDefinition(completion, validation)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_completion_engine(engine_id) -> BaseCompletionEngine:
|
||||||
|
return DslsManager.dsls[engine_id].completion
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_validation_parser(engine_id) -> DSLParser:
|
||||||
|
return DslsManager.dsls[engine_id].validation
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset():
|
||||||
|
DslsManager.dsls = {}
|
||||||
@@ -22,7 +22,7 @@ class DatagridMetadataProvider(Protocol):
|
|||||||
DataGrid names follow the pattern namespace.name (multi-level namespaces).
|
DataGrid names follow the pattern namespace.name (multi-level namespaces).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_tables(self) -> list[str]:
|
def list_tables(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return the list of available DataGrid names.
|
Return the list of available DataGrid names.
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class DatagridMetadataProvider(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_columns(self, table_name: str) -> list[str]:
|
def list_columns(self, table_name: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return the column names for a specific DataGrid.
|
Return the column names for a specific DataGrid.
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class DatagridMetadataProvider(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_column_values(self, table_name, column_name: str) -> list[Any]:
|
def list_column_values(self, table_name, column_name: str) -> list[Any]:
|
||||||
"""
|
"""
|
||||||
Return the distinct values for a column in the current DataGrid.
|
Return the distinct values for a column in the current DataGrid.
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class DatagridMetadataProvider(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_style_presets(self) -> list[str]:
|
def list_style_presets(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return the list of available style preset names.
|
Return the list of available style preset names.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class DatagridMetadataProvider(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_format_presets(self) -> list[str]:
|
def list_format_presets(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return the list of available format preset names.
|
Return the list of available format preset names.
|
||||||
|
|
||||||
|
|||||||
@@ -160,9 +160,9 @@ def _get_column_suggestions(provider: DatagridMetadataProvider) -> list[Suggesti
|
|||||||
"""Get column name suggestions from provider."""
|
"""Get column name suggestions from provider."""
|
||||||
try:
|
try:
|
||||||
# Try to get columns from the first available table
|
# Try to get columns from the first available table
|
||||||
tables = provider.get_tables()
|
tables = provider.list_tables()
|
||||||
if tables:
|
if tables:
|
||||||
columns = provider.get_columns(tables[0])
|
columns = provider.list_columns(tables[0])
|
||||||
return [Suggestion(col, "Column", "column") for col in columns]
|
return [Suggestion(col, "Column", "column") for col in columns]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -174,9 +174,9 @@ def _get_column_suggestions_with_closing_quote(
|
|||||||
) -> list[Suggestion]:
|
) -> list[Suggestion]:
|
||||||
"""Get column name suggestions with closing quote."""
|
"""Get column name suggestions with closing quote."""
|
||||||
try:
|
try:
|
||||||
tables = provider.get_tables()
|
tables = provider.list_tables()
|
||||||
if tables:
|
if tables:
|
||||||
columns = provider.get_columns(tables[0])
|
columns = provider.list_columns(tables[0])
|
||||||
return [Suggestion(f'{col}"', "Column", "column") for col in columns]
|
return [Suggestion(f'{col}"', "Column", "column") for col in columns]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -189,7 +189,7 @@ def _get_style_preset_suggestions(provider: DatagridMetadataProvider) -> list[Su
|
|||||||
|
|
||||||
# Add provider presets if available
|
# Add provider presets if available
|
||||||
try:
|
try:
|
||||||
custom_presets = provider.get_style_presets()
|
custom_presets = provider.list_style_presets()
|
||||||
for preset in custom_presets:
|
for preset in custom_presets:
|
||||||
# Check if it's already in default presets
|
# Check if it's already in default presets
|
||||||
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
||||||
@@ -212,7 +212,7 @@ def _get_style_preset_suggestions_quoted(
|
|||||||
|
|
||||||
# Add provider presets if available
|
# Add provider presets if available
|
||||||
try:
|
try:
|
||||||
custom_presets = provider.get_style_presets()
|
custom_presets = provider.list_style_presets()
|
||||||
for preset in custom_presets:
|
for preset in custom_presets:
|
||||||
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
||||||
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
|
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
|
||||||
@@ -232,7 +232,7 @@ def _get_format_preset_suggestions(provider: DatagridMetadataProvider) -> list[S
|
|||||||
|
|
||||||
# Add provider presets if available
|
# Add provider presets if available
|
||||||
try:
|
try:
|
||||||
custom_presets = provider.get_format_presets()
|
custom_presets = provider.list_format_presets()
|
||||||
for preset in custom_presets:
|
for preset in custom_presets:
|
||||||
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
|
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
|
||||||
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
|
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
|
||||||
@@ -251,7 +251,7 @@ def _get_row_index_suggestions(provider: DatagridMetadataProvider) -> list[Sugge
|
|||||||
suggestions = []
|
suggestions = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tables = provider.get_tables()
|
tables = provider.list_tables()
|
||||||
if tables:
|
if tables:
|
||||||
row_count = provider.get_row_count(tables[0])
|
row_count = provider.get_row_count(tables[0])
|
||||||
if row_count > 0:
|
if row_count > 0:
|
||||||
@@ -285,7 +285,7 @@ def _get_column_value_suggestions(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
values = provider.get_column_values(scope.column_name)
|
values = provider.list_column_values(scope.column_name)
|
||||||
suggestions = []
|
suggestions = []
|
||||||
for value in values:
|
for value in values:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class BaseInstance:
|
|||||||
_id = f"{prefix}-{str(uuid.uuid4())}"
|
_id = f"{prefix}-{str(uuid.uuid4())}"
|
||||||
return _id
|
return _id
|
||||||
|
|
||||||
if _id.startswith("-") and parent is not None:
|
if _id.startswith(("-", "#")) and parent is not None:
|
||||||
return f"{parent.get_id()}{_id}"
|
return f"{parent.get_id()}{_id}"
|
||||||
|
|
||||||
return _id
|
return _id
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from rich.table import Table
|
|||||||
from starlette.routing import Mount
|
from starlette.routing import Mount
|
||||||
|
|
||||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||||
|
from myfasthtml.core.dsl.types import Position
|
||||||
|
from myfasthtml.core.dsls import DslsManager
|
||||||
|
from myfasthtml.core.formatting.dsl import DSLSyntaxError
|
||||||
from myfasthtml.test.MyFT import MyFT
|
from myfasthtml.test.MyFT import MyFT
|
||||||
|
|
||||||
utils_app, utils_rt = fast_app()
|
utils_app, utils_rt = fast_app()
|
||||||
@@ -383,12 +386,41 @@ def post(session, b_id: str, values: dict):
|
|||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Completions)
|
@utils_rt(Routes.Completions)
|
||||||
def get(session, c_id, text: str, line: int, ch: int):
|
def get(session, e_id: str, text: str, line: int, ch: int):
|
||||||
"""
|
"""
|
||||||
Default routes for Domaine Specific Languages completion
|
Default routes for Domaine Specific Languages completion
|
||||||
:param session:
|
:param session:
|
||||||
:param c_id:
|
:param e_id: engine_id
|
||||||
:param values:
|
:param text:
|
||||||
|
:param line:
|
||||||
|
:param ch:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Entering {Routes.Bindings} with {session=}, {c_id=}, {values=}")
|
logger.debug(f"Entering {Routes.Completions} with {session=}, {e_id=}, {text=}, {line=}, {ch}")
|
||||||
|
completion = DslsManager.get_completion_engine(e_id)
|
||||||
|
result = completion.get_completions(text, Position(line, ch))
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@utils_rt(Routes.Validations)
|
||||||
|
def get(session, e_id: str, text: str, line: int, ch: int):
|
||||||
|
"""
|
||||||
|
Default routes for Domaine Specific Languages syntax validation
|
||||||
|
:param session:
|
||||||
|
:param e_id:
|
||||||
|
:param text:
|
||||||
|
:param line:
|
||||||
|
:param ch:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
logger.debug(f"Entering {Routes.Validations} with {session=}, {e_id=}, {text=}, {line=}, {ch}")
|
||||||
|
validation = DslsManager.get_validation_parser(e_id)
|
||||||
|
try:
|
||||||
|
validation.parse(text)
|
||||||
|
return {"errors": []}
|
||||||
|
except DSLSyntaxError as e:
|
||||||
|
return {"errors": [{
|
||||||
|
"line": e.line or 1,
|
||||||
|
"column": e.column or 1,
|
||||||
|
"message": e.message
|
||||||
|
}]}
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ def create_app(daisyui: Optional[bool] = True,
|
|||||||
|
|
||||||
Script(src="/myfasthtml/show-hint.min.js"),
|
Script(src="/myfasthtml/show-hint.min.js"),
|
||||||
Link(href="/myfasthtml/show-hint.min.css", rel="stylesheet", type="text/css"),
|
Link(href="/myfasthtml/show-hint.min.css", rel="stylesheet", type="text/css"),
|
||||||
|
|
||||||
|
Script(src="/myfasthtml/lint.min.js"),
|
||||||
|
Link(href="/myfasthtml/lint.min.css", rel="stylesheet", type="text/css"),
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeware = create_auth_beforeware() if protect_routes else None
|
beforeware = create_auth_beforeware() if protect_routes else None
|
||||||
|
|||||||
@@ -5,23 +5,19 @@ Tests the parsing of DSL text into ScopedRule objects.
|
|||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.formatting.dsl import (
|
|
||||||
parse_dsl,
|
|
||||||
ColumnScope,
|
|
||||||
RowScope,
|
|
||||||
CellScope,
|
|
||||||
ScopedRule,
|
|
||||||
DSLSyntaxError,
|
|
||||||
)
|
|
||||||
from myfasthtml.core.formatting.dataclasses import (
|
from myfasthtml.core.formatting.dataclasses import (
|
||||||
Condition,
|
NumberFormatter,
|
||||||
Style,
|
DateFormatter,
|
||||||
FormatRule,
|
BooleanFormatter,
|
||||||
NumberFormatter,
|
TextFormatter,
|
||||||
DateFormatter,
|
EnumFormatter,
|
||||||
BooleanFormatter,
|
)
|
||||||
TextFormatter,
|
from myfasthtml.core.formatting.dsl import (
|
||||||
EnumFormatter,
|
parse_dsl,
|
||||||
|
ColumnScope,
|
||||||
|
RowScope,
|
||||||
|
CellScope,
|
||||||
|
DSLSyntaxError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,102 +27,102 @@ from myfasthtml.core.formatting.dataclasses import (
|
|||||||
|
|
||||||
|
|
||||||
class TestColumnScope:
|
class TestColumnScope:
|
||||||
"""Tests for column scope parsing."""
|
"""Tests for column scope parsing."""
|
||||||
|
|
||||||
def test_i_can_parse_column_scope(self):
|
def test_i_can_parse_column_scope(self):
|
||||||
"""Test parsing a simple column scope."""
|
"""Test parsing a simple column scope."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error")
|
style("error")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert isinstance(rules[0].scope, ColumnScope)
|
assert isinstance(rules[0].scope, ColumnScope)
|
||||||
assert rules[0].scope.column == "amount"
|
assert rules[0].scope.column == "amount"
|
||||||
|
|
||||||
def test_i_can_parse_column_scope_with_quoted_name(self):
|
def test_i_can_parse_column_scope_with_quoted_name(self):
|
||||||
"""Test parsing a column scope with quoted name containing spaces."""
|
"""Test parsing a column scope with quoted name containing spaces."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column "total amount":
|
column "total amount":
|
||||||
style("error")
|
style("error")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert isinstance(rules[0].scope, ColumnScope)
|
assert isinstance(rules[0].scope, ColumnScope)
|
||||||
assert rules[0].scope.column == "total amount"
|
assert rules[0].scope.column == "total amount"
|
||||||
|
|
||||||
|
|
||||||
class TestRowScope:
|
class TestRowScope:
|
||||||
"""Tests for row scope parsing."""
|
"""Tests for row scope parsing."""
|
||||||
|
|
||||||
def test_i_can_parse_row_scope(self):
|
def test_i_can_parse_row_scope(self):
|
||||||
"""Test parsing a row scope."""
|
"""Test parsing a row scope."""
|
||||||
dsl = """
|
dsl = """
|
||||||
row 0:
|
row 0:
|
||||||
style("neutral")
|
style("neutral")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert isinstance(rules[0].scope, RowScope)
|
assert isinstance(rules[0].scope, RowScope)
|
||||||
assert rules[0].scope.row == 0
|
assert rules[0].scope.row == 0
|
||||||
|
|
||||||
def test_i_can_parse_row_scope_with_large_index(self):
|
def test_i_can_parse_row_scope_with_large_index(self):
|
||||||
"""Test parsing a row scope with a large index."""
|
"""Test parsing a row scope with a large index."""
|
||||||
dsl = """
|
dsl = """
|
||||||
row 999:
|
row 999:
|
||||||
style("highlight")
|
style("highlight")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert rules[0].scope.row == 999
|
assert rules[0].scope.row == 999
|
||||||
|
|
||||||
|
|
||||||
class TestCellScope:
|
class TestCellScope:
|
||||||
"""Tests for cell scope parsing."""
|
"""Tests for cell scope parsing."""
|
||||||
|
|
||||||
def test_i_can_parse_cell_scope_with_coords(self):
|
def test_i_can_parse_cell_scope_with_coords(self):
|
||||||
"""Test parsing a cell scope with coordinates."""
|
"""Test parsing a cell scope with coordinates."""
|
||||||
dsl = """
|
dsl = """
|
||||||
cell (amount, 3):
|
cell (amount, 3):
|
||||||
style("highlight")
|
style("highlight")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert isinstance(rules[0].scope, CellScope)
|
assert isinstance(rules[0].scope, CellScope)
|
||||||
assert rules[0].scope.column == "amount"
|
assert rules[0].scope.column == "amount"
|
||||||
assert rules[0].scope.row == 3
|
assert rules[0].scope.row == 3
|
||||||
assert rules[0].scope.cell_id is None
|
assert rules[0].scope.cell_id is None
|
||||||
|
|
||||||
def test_i_can_parse_cell_scope_with_quoted_column(self):
|
def test_i_can_parse_cell_scope_with_quoted_column(self):
|
||||||
"""Test parsing a cell scope with quoted column name."""
|
"""Test parsing a cell scope with quoted column name."""
|
||||||
dsl = """
|
dsl = """
|
||||||
cell ("total amount", 5):
|
cell ("total amount", 5):
|
||||||
style("highlight")
|
style("highlight")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert rules[0].scope.column == "total amount"
|
assert rules[0].scope.column == "total amount"
|
||||||
assert rules[0].scope.row == 5
|
assert rules[0].scope.row == 5
|
||||||
|
|
||||||
def test_i_can_parse_cell_scope_with_id(self):
|
def test_i_can_parse_cell_scope_with_id(self):
|
||||||
"""Test parsing a cell scope with cell ID."""
|
"""Test parsing a cell scope with cell ID."""
|
||||||
dsl = """
|
dsl = """
|
||||||
cell tcell_grid1-3-2:
|
cell tcell_grid1-3-2:
|
||||||
style("highlight")
|
style("highlight")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert isinstance(rules[0].scope, CellScope)
|
assert isinstance(rules[0].scope, CellScope)
|
||||||
assert rules[0].scope.cell_id == "tcell_grid1-3-2"
|
assert rules[0].scope.cell_id == "tcell_grid1-3-2"
|
||||||
assert rules[0].scope.column is None
|
assert rules[0].scope.column is None
|
||||||
assert rules[0].scope.row is None
|
assert rules[0].scope.row is None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -135,61 +131,61 @@ cell tcell_grid1-3-2:
|
|||||||
|
|
||||||
|
|
||||||
class TestStyleParsing:
|
class TestStyleParsing:
|
||||||
"""Tests for style expression parsing."""
|
"""Tests for style expression parsing."""
|
||||||
|
|
||||||
def test_i_can_parse_style_with_preset(self):
|
def test_i_can_parse_style_with_preset(self):
|
||||||
"""Test parsing style with preset only."""
|
"""Test parsing style with preset only."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error")
|
style("error")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert rules[0].rule.style is not None
|
assert rules[0].rule.style is not None
|
||||||
assert rules[0].rule.style.preset == "error"
|
assert rules[0].rule.style.preset == "error"
|
||||||
|
|
||||||
def test_i_can_parse_style_without_preset(self):
|
def test_i_can_parse_style_without_preset(self):
|
||||||
"""Test parsing style without preset, with direct properties."""
|
"""Test parsing style without preset, with direct properties."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style(color="red", bold=True)
|
style(color="red", bold=True)
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
style = rules[0].rule.style
|
style = rules[0].rule.style
|
||||||
assert style.preset is None
|
assert style.preset is None
|
||||||
assert style.color == "red"
|
assert style.color == "red"
|
||||||
assert style.font_weight == "bold"
|
assert style.font_weight == "bold"
|
||||||
|
|
||||||
def test_i_can_parse_style_with_preset_and_options(self):
|
def test_i_can_parse_style_with_preset_and_options(self):
|
||||||
"""Test parsing style with preset and additional options."""
|
"""Test parsing style with preset and additional options."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error", bold=True, italic=True)
|
style("error", bold=True, italic=True)
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
style = rules[0].rule.style
|
style = rules[0].rule.style
|
||||||
assert style.preset == "error"
|
assert style.preset == "error"
|
||||||
assert style.font_weight == "bold"
|
assert style.font_weight == "bold"
|
||||||
assert style.font_style == "italic"
|
assert style.font_style == "italic"
|
||||||
|
|
||||||
@pytest.mark.parametrize("option,attr_name,attr_value", [
|
@pytest.mark.parametrize("option,attr_name,attr_value", [
|
||||||
("bold=True", "font_weight", "bold"),
|
("bold=True", "font_weight", "bold"),
|
||||||
("italic=True", "font_style", "italic"),
|
("italic=True", "font_style", "italic"),
|
||||||
("underline=True", "text_decoration", "underline"),
|
("underline=True", "text_decoration", "underline"),
|
||||||
("strikethrough=True", "text_decoration", "line-through"),
|
("strikethrough=True", "text_decoration", "line-through"),
|
||||||
])
|
])
|
||||||
def test_i_can_parse_style_options(self, option, attr_name, attr_value):
|
def test_i_can_parse_style_options(self, option, attr_name, attr_value):
|
||||||
"""Test parsing individual style options."""
|
"""Test parsing individual style options."""
|
||||||
dsl = f"""
|
dsl = f"""
|
||||||
column amount:
|
column amount:
|
||||||
style({option})
|
style({option})
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
style = rules[0].rule.style
|
style = rules[0].rule.style
|
||||||
assert getattr(style, attr_name) == attr_value
|
assert getattr(style, attr_name) == attr_value
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -198,100 +194,100 @@ column amount:
|
|||||||
|
|
||||||
|
|
||||||
class TestFormatParsing:
|
class TestFormatParsing:
|
||||||
"""Tests for format expression parsing."""
|
"""Tests for format expression parsing."""
|
||||||
|
|
||||||
def test_i_can_parse_format_preset(self):
|
def test_i_can_parse_format_preset(self):
|
||||||
"""Test parsing format with preset."""
|
"""Test parsing format with preset."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
format("EUR")
|
format("EUR")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert formatter is not None
|
assert formatter is not None
|
||||||
assert formatter.preset == "EUR"
|
assert formatter.preset == "EUR"
|
||||||
|
|
||||||
def test_i_can_parse_format_preset_with_options(self):
|
def test_i_can_parse_format_preset_with_options(self):
|
||||||
"""Test parsing format preset with options."""
|
"""Test parsing format preset with options."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
format("EUR", precision=3)
|
format("EUR", precision=3)
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert formatter.preset == "EUR"
|
assert formatter.preset == "EUR"
|
||||||
assert formatter.precision == 3
|
assert formatter.precision == 3
|
||||||
|
|
||||||
@pytest.mark.parametrize("format_type,formatter_class", [
|
@pytest.mark.parametrize("format_type,formatter_class", [
|
||||||
("number", NumberFormatter),
|
("number", NumberFormatter),
|
||||||
("date", DateFormatter),
|
("date", DateFormatter),
|
||||||
("boolean", BooleanFormatter),
|
("boolean", BooleanFormatter),
|
||||||
("text", TextFormatter),
|
("text", TextFormatter),
|
||||||
("enum", EnumFormatter),
|
("enum", EnumFormatter),
|
||||||
])
|
])
|
||||||
def test_i_can_parse_format_types(self, format_type, formatter_class):
|
def test_i_can_parse_format_types(self, format_type, formatter_class):
|
||||||
"""Test parsing explicit format types."""
|
"""Test parsing explicit format types."""
|
||||||
dsl = f"""
|
dsl = f"""
|
||||||
column amount:
|
column amount:
|
||||||
format.{format_type}()
|
format.{format_type}()
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert isinstance(formatter, formatter_class)
|
assert isinstance(formatter, formatter_class)
|
||||||
|
|
||||||
def test_i_can_parse_format_number_with_options(self):
|
def test_i_can_parse_format_number_with_options(self):
|
||||||
"""Test parsing format.number with all options."""
|
"""Test parsing format.number with all options."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
format.number(precision=2, suffix=" EUR", thousands_sep=" ")
|
format.number(precision=2, suffix=" EUR", thousands_sep=" ")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert isinstance(formatter, NumberFormatter)
|
assert isinstance(formatter, NumberFormatter)
|
||||||
assert formatter.precision == 2
|
assert formatter.precision == 2
|
||||||
assert formatter.suffix == " EUR"
|
assert formatter.suffix == " EUR"
|
||||||
assert formatter.thousands_sep == " "
|
assert formatter.thousands_sep == " "
|
||||||
|
|
||||||
def test_i_can_parse_format_date_with_options(self):
|
def test_i_can_parse_format_date_with_options(self):
|
||||||
"""Test parsing format.date with format option."""
|
"""Test parsing format.date with format option."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column created_at:
|
column created_at:
|
||||||
format.date(format="%d/%m/%Y")
|
format.date(format="%d/%m/%Y")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert isinstance(formatter, DateFormatter)
|
assert isinstance(formatter, DateFormatter)
|
||||||
assert formatter.format == "%d/%m/%Y"
|
assert formatter.format == "%d/%m/%Y"
|
||||||
|
|
||||||
def test_i_can_parse_format_boolean_with_options(self):
|
def test_i_can_parse_format_boolean_with_options(self):
|
||||||
"""Test parsing format.boolean with all options."""
|
"""Test parsing format.boolean with all options."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column active:
|
column active:
|
||||||
format.boolean(true_value="Oui", false_value="Non")
|
format.boolean(true_value="Oui", false_value="Non")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert isinstance(formatter, BooleanFormatter)
|
assert isinstance(formatter, BooleanFormatter)
|
||||||
assert formatter.true_value == "Oui"
|
assert formatter.true_value == "Oui"
|
||||||
assert formatter.false_value == "Non"
|
assert formatter.false_value == "Non"
|
||||||
|
|
||||||
def test_i_can_parse_format_enum_with_source(self):
|
def test_i_can_parse_format_enum_with_source(self):
|
||||||
"""Test parsing format.enum with source mapping."""
|
"""Test parsing format.enum with source mapping."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column status:
|
column status:
|
||||||
format.enum(source={"draft": "Brouillon", "published": "Publie"})
|
format.enum(source={"draft": "Brouillon", "published": "Publie"})
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert isinstance(formatter, EnumFormatter)
|
assert isinstance(formatter, EnumFormatter)
|
||||||
assert formatter.source == {"draft": "Brouillon", "published": "Publie"}
|
assert formatter.source == {"draft": "Brouillon", "published": "Publie"}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -300,89 +296,89 @@ column status:
|
|||||||
|
|
||||||
|
|
||||||
class TestConditionParsing:
|
class TestConditionParsing:
|
||||||
"""Tests for condition parsing."""
|
"""Tests for condition parsing."""
|
||||||
|
|
||||||
@pytest.mark.parametrize("operator,dsl_op", [
|
@pytest.mark.parametrize("operator,dsl_op", [
|
||||||
("==", "=="),
|
("==", "=="),
|
||||||
("!=", "!="),
|
("!=", "!="),
|
||||||
("<", "<"),
|
("<", "<"),
|
||||||
("<=", "<="),
|
("<=", "<="),
|
||||||
(">", ">"),
|
(">", ">"),
|
||||||
(">=", ">="),
|
(">=", ">="),
|
||||||
("contains", "contains"),
|
("contains", "contains"),
|
||||||
("startswith", "startswith"),
|
("startswith", "startswith"),
|
||||||
("endswith", "endswith"),
|
("endswith", "endswith"),
|
||||||
])
|
])
|
||||||
def test_i_can_parse_comparison_operators(self, operator, dsl_op):
|
def test_i_can_parse_comparison_operators(self, operator, dsl_op):
|
||||||
"""Test parsing all comparison operators."""
|
"""Test parsing all comparison operators."""
|
||||||
dsl = f"""
|
dsl = f"""
|
||||||
column amount:
|
column amount:
|
||||||
style("error") if value {dsl_op} 0
|
style("error") if value {dsl_op} 0
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition is not None
|
assert condition is not None
|
||||||
assert condition.operator == operator
|
assert condition.operator == operator
|
||||||
|
|
||||||
@pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"])
|
@pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"])
|
||||||
def test_i_can_parse_unary_conditions(self, unary_op):
|
def test_i_can_parse_unary_conditions(self, unary_op):
|
||||||
"""Test parsing unary conditions (isempty, isnotempty)."""
|
"""Test parsing unary conditions (isempty, isnotempty)."""
|
||||||
dsl = f"""
|
dsl = f"""
|
||||||
column name:
|
column name:
|
||||||
style("neutral") if value {unary_op}
|
style("neutral") if value {unary_op}
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.operator == unary_op
|
assert condition.operator == unary_op
|
||||||
|
|
||||||
def test_i_can_parse_condition_in(self):
|
def test_i_can_parse_condition_in(self):
|
||||||
"""Test parsing 'in' condition with list."""
|
"""Test parsing 'in' condition with list."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column status:
|
column status:
|
||||||
style("success") if value in ["approved", "validated"]
|
style("success") if value in ["approved", "validated"]
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.operator == "in"
|
assert condition.operator == "in"
|
||||||
assert condition.value == ["approved", "validated"]
|
assert condition.value == ["approved", "validated"]
|
||||||
|
|
||||||
def test_i_can_parse_condition_between(self):
|
def test_i_can_parse_condition_between(self):
|
||||||
"""Test parsing 'between' condition."""
|
"""Test parsing 'between' condition."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column score:
|
column score:
|
||||||
style("warning") if value between 30 and 70
|
style("warning") if value between 30 and 70
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.operator == "between"
|
assert condition.operator == "between"
|
||||||
assert condition.value == [30, 70]
|
assert condition.value == [30, 70]
|
||||||
|
|
||||||
def test_i_can_parse_condition_negation(self):
|
def test_i_can_parse_condition_negation(self):
|
||||||
"""Test parsing negated condition."""
|
"""Test parsing negated condition."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column status:
|
column status:
|
||||||
style("error") if not value == "approved"
|
style("error") if not value == "approved"
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.negate is True
|
assert condition.negate is True
|
||||||
assert condition.operator == "=="
|
assert condition.operator == "=="
|
||||||
|
|
||||||
def test_i_can_parse_condition_case_sensitive(self):
|
def test_i_can_parse_condition_case_sensitive(self):
|
||||||
"""Test parsing case-sensitive condition."""
|
"""Test parsing case-sensitive condition."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column name:
|
column name:
|
||||||
style("error") if value == "Error" (case)
|
style("error") if value == "Error" (case)
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.case_sensitive is True
|
assert condition.case_sensitive is True
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -391,31 +387,31 @@ column name:
|
|||||||
|
|
||||||
|
|
||||||
class TestLiteralParsing:
|
class TestLiteralParsing:
|
||||||
"""Tests for literal value parsing in conditions."""
|
"""Tests for literal value parsing in conditions."""
|
||||||
|
|
||||||
@pytest.mark.parametrize("literal,expected_value,expected_type", [
|
@pytest.mark.parametrize("literal,expected_value,expected_type", [
|
||||||
('"hello"', "hello", str),
|
('"hello"', "hello", str),
|
||||||
("'world'", "world", str),
|
("'world'", "world", str),
|
||||||
("42", 42, int),
|
("42", 42, int),
|
||||||
("-10", -10, int),
|
("-10", -10, int),
|
||||||
("3.14", 3.14, float),
|
("3.14", 3.14, float),
|
||||||
("-2.5", -2.5, float),
|
("-2.5", -2.5, float),
|
||||||
("True", True, bool),
|
("True", True, bool),
|
||||||
("False", False, bool),
|
("False", False, bool),
|
||||||
("true", True, bool),
|
("true", True, bool),
|
||||||
("false", False, bool),
|
("false", False, bool),
|
||||||
])
|
])
|
||||||
def test_i_can_parse_literals(self, literal, expected_value, expected_type):
|
def test_i_can_parse_literals(self, literal, expected_value, expected_type):
|
||||||
"""Test parsing various literal types in conditions."""
|
"""Test parsing various literal types in conditions."""
|
||||||
dsl = f"""
|
dsl = f"""
|
||||||
column amount:
|
column amount:
|
||||||
style("error") if value == {literal}
|
style("error") if value == {literal}
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.value == expected_value
|
assert condition.value == expected_value
|
||||||
assert isinstance(condition.value, expected_type)
|
assert isinstance(condition.value, expected_type)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -424,29 +420,29 @@ column amount:
|
|||||||
|
|
||||||
|
|
||||||
class TestReferenceParsing:
|
class TestReferenceParsing:
|
||||||
"""Tests for cell reference parsing in conditions."""
|
"""Tests for cell reference parsing in conditions."""
|
||||||
|
|
||||||
def test_i_can_parse_column_reference(self):
|
def test_i_can_parse_column_reference(self):
|
||||||
"""Test parsing column reference in condition."""
|
"""Test parsing column reference in condition."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column actual:
|
column actual:
|
||||||
style("error") if value > col.budget
|
style("error") if value > col.budget
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.value == {"col": "budget"}
|
assert condition.value == {"col": "budget"}
|
||||||
|
|
||||||
def test_i_can_parse_column_reference_with_quoted_name(self):
|
def test_i_can_parse_column_reference_with_quoted_name(self):
|
||||||
"""Test parsing column reference with quoted name."""
|
"""Test parsing column reference with quoted name."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column actual:
|
column actual:
|
||||||
style("error") if value > col."max budget"
|
style("error") if value > col."max budget"
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
condition = rules[0].rule.condition
|
condition = rules[0].rule.condition
|
||||||
assert condition.value == {"col": "max budget"}
|
assert condition.value == {"col": "max budget"}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -455,27 +451,27 @@ column actual:
|
|||||||
|
|
||||||
|
|
||||||
class TestComplexStructures:
|
class TestComplexStructures:
|
||||||
"""Tests for complex DSL structures."""
|
"""Tests for complex DSL structures."""
|
||||||
|
|
||||||
def test_i_can_parse_multiple_rules_in_scope(self):
|
def test_i_can_parse_multiple_rules_in_scope(self):
|
||||||
"""Test parsing multiple rules under one scope."""
|
"""Test parsing multiple rules under one scope."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error") if value < 0
|
style("error") if value < 0
|
||||||
style("success") if value > 1000
|
style("success") if value > 1000
|
||||||
format("EUR")
|
format("EUR")
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 3
|
assert len(rules) == 3
|
||||||
# All rules share the same scope
|
# All rules share the same scope
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
assert isinstance(rule.scope, ColumnScope)
|
assert isinstance(rule.scope, ColumnScope)
|
||||||
assert rule.scope.column == "amount"
|
assert rule.scope.column == "amount"
|
||||||
|
|
||||||
def test_i_can_parse_multiple_scopes(self):
|
def test_i_can_parse_multiple_scopes(self):
|
||||||
"""Test parsing multiple scopes."""
|
"""Test parsing multiple scopes."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
format("EUR")
|
format("EUR")
|
||||||
|
|
||||||
@@ -485,51 +481,51 @@ column status:
|
|||||||
row 0:
|
row 0:
|
||||||
style("neutral", bold=True)
|
style("neutral", bold=True)
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 3
|
assert len(rules) == 3
|
||||||
|
|
||||||
# First rule: column amount
|
# First rule: column amount
|
||||||
assert isinstance(rules[0].scope, ColumnScope)
|
assert isinstance(rules[0].scope, ColumnScope)
|
||||||
assert rules[0].scope.column == "amount"
|
assert rules[0].scope.column == "amount"
|
||||||
|
|
||||||
# Second rule: column status
|
# Second rule: column status
|
||||||
assert isinstance(rules[1].scope, ColumnScope)
|
assert isinstance(rules[1].scope, ColumnScope)
|
||||||
assert rules[1].scope.column == "status"
|
assert rules[1].scope.column == "status"
|
||||||
|
|
||||||
# Third rule: row 0
|
# Third rule: row 0
|
||||||
assert isinstance(rules[2].scope, RowScope)
|
assert isinstance(rules[2].scope, RowScope)
|
||||||
assert rules[2].scope.row == 0
|
assert rules[2].scope.row == 0
|
||||||
|
|
||||||
def test_i_can_parse_style_and_format_combined(self):
|
def test_i_can_parse_style_and_format_combined(self):
|
||||||
"""Test parsing style and format on same line."""
|
"""Test parsing style and format on same line."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error") format("EUR") if value < 0
|
style("error") format("EUR") if value < 0
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
rule = rules[0].rule
|
rule = rules[0].rule
|
||||||
assert rule.style is not None
|
assert rule.style is not None
|
||||||
assert rule.style.preset == "error"
|
assert rule.style.preset == "error"
|
||||||
assert rule.formatter is not None
|
assert rule.formatter is not None
|
||||||
assert rule.formatter.preset == "EUR"
|
assert rule.formatter.preset == "EUR"
|
||||||
assert rule.condition is not None
|
assert rule.condition is not None
|
||||||
assert rule.condition.operator == "<"
|
assert rule.condition.operator == "<"
|
||||||
|
|
||||||
def test_i_can_parse_comments(self):
|
def test_i_can_parse_comments(self):
|
||||||
"""Test that comments are ignored."""
|
"""Test that comments are ignored."""
|
||||||
dsl = """
|
dsl = """
|
||||||
# This is a comment
|
# This is a comment
|
||||||
column amount:
|
column amount:
|
||||||
# Another comment
|
# Another comment
|
||||||
style("error") if value < 0
|
style("error") if value < 0
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
assert len(rules) == 1
|
assert len(rules) == 1
|
||||||
assert rules[0].rule.style.preset == "error"
|
assert rules[0].rule.style.preset == "error"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -538,39 +534,39 @@ column amount:
|
|||||||
|
|
||||||
|
|
||||||
class TestSyntaxErrors:
|
class TestSyntaxErrors:
|
||||||
"""Tests for syntax error handling."""
|
"""Tests for syntax error handling."""
|
||||||
|
|
||||||
def test_i_cannot_parse_invalid_syntax(self):
|
def test_i_cannot_parse_invalid_syntax(self):
|
||||||
"""Test that invalid syntax raises DSLSyntaxError."""
|
"""Test that invalid syntax raises DSLSyntaxError."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount
|
column amount
|
||||||
style("error")
|
style("error")
|
||||||
"""
|
"""
|
||||||
with pytest.raises(DSLSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
parse_dsl(dsl)
|
parse_dsl(dsl)
|
||||||
|
|
||||||
def test_i_cannot_parse_missing_indent(self):
|
def test_i_cannot_parse_missing_indent(self):
|
||||||
"""Test that missing indentation raises DSLSyntaxError."""
|
"""Test that missing indentation raises DSLSyntaxError."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error")
|
style("error")
|
||||||
"""
|
"""
|
||||||
with pytest.raises(DSLSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
parse_dsl(dsl)
|
parse_dsl(dsl)
|
||||||
|
|
||||||
def test_i_cannot_parse_empty_scope(self):
|
def test_i_cannot_parse_empty_scope(self):
|
||||||
"""Test that empty scope raises DSLSyntaxError."""
|
"""Test that empty scope raises DSLSyntaxError."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
"""
|
"""
|
||||||
with pytest.raises(DSLSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
parse_dsl(dsl)
|
parse_dsl(dsl)
|
||||||
|
|
||||||
def test_i_cannot_parse_invalid_operator(self):
|
def test_i_cannot_parse_invalid_operator(self):
|
||||||
"""Test that invalid operator raises DSLSyntaxError."""
|
"""Test that invalid operator raises DSLSyntaxError."""
|
||||||
dsl = """
|
dsl = """
|
||||||
column amount:
|
column amount:
|
||||||
style("error") if value <> 0
|
style("error") if value <> 0
|
||||||
"""
|
"""
|
||||||
with pytest.raises(DSLSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
parse_dsl(dsl)
|
parse_dsl(dsl)
|
||||||
|
|||||||
Reference in New Issue
Block a user