I can validate formatting in editor

This commit is contained in:
2026-02-01 21:49:46 +01:00
parent d7ec99c3d9
commit 0620cb678b
23 changed files with 794 additions and 501 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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
View 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
View 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)})});

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -0,0 +1,7 @@
from myfasthtml.controls.DslEditor import DslEditor
class DataGridFormattingEditor(DslEditor):
def on_dsl_change(self, dsl):
pass

View File

@@ -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):
@@ -90,6 +95,11 @@ class DataGridsManager(SingleInstance):
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)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload) tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
@@ -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()),

View File

@@ -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,

View File

@@ -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)
) )

View File

@@ -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):

View File

@@ -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 = {}

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View 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 = {}

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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
}]}

View File

@@ -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

View File

@@ -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)