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/.myFastHtmlDb
clean-app:
rm -rf src/.myFastHtmlDb
# Alias to clean everything
clean: clean-build clean-tests
clean: clean-build clean-tests clean-app
clean-all : clean
rm -rf src/.sesskey
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} }│
└─────────────────────────────────────────────────────────────┘
@@ -1024,33 +1024,43 @@ When the engine needs column values for `OPERATOR_VALUE` context, it uses the de
### 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
class DatagridMetadataProvider:
class DatagridMetadataProvider(Protocol):
"""Provides DataGrid metadata for autocompletion."""
def get_tables(self) -> list[str]:
def list_tables(self) -> list[str]:
"""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."""
...
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."""
...
def get_row_count(self, table_name: str) -> int:
"""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:**
- `DatagridMetadataProvider` is a Protocol (structural typing), not an abstract base class
- DataGrid names follow the pattern `namespace.name` (multi-level namespaces supported)
- The provider is passed at initialization, not with each API call
- Column values are fetched lazily when the scope is detected
- `DataGridsManager` implements this Protocol
### API Interface
@@ -1230,24 +1240,35 @@ The following features are excluded from autocompletion for simplicity:
| Component | Status | Location |
|-----------|--------|----------|
| **DSL Parser** | | |
| DSL Grammar (lark) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/grammar.py` |
| DSL Parser | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/parser.py` |
| DSL Transformer | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/transformer.py` |
| Scope Dataclasses | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Exceptions | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/exceptions.py` |
| Public API (`parse_dsl()`) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/__init__.py` |
| Unit Tests (Parser) | :white_check_mark: ~35 tests | `tests/core/formatting/test_dsl_parser.py` |
| Unit Tests (Parser) | :white_check_mark: ~35 tests | `tests/core/formatting/dsl/test_dsl_parser.py` |
| **Autocompletion** | | |
| DatagridMetadataProvider | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
| Scope Detector | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
| Context Detector | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
| Suggestions Generator | :x: Not implemented | `src/myfasthtml/core/formatting/dsl/completion.py` |
| REST Endpoint | :x: Not implemented | `/myfasthtml/autocompletion` |
| Unit Tests (Completion) | :x: Not implemented | `tests/core/formatting/test_dsl_completion.py` |
| DatagridMetadataProvider | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/provider.py` |
| Scope Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Context Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Suggestions Generator | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/suggestions.py` |
| Completion Engine | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/engine.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** | | |
| Lezer Grammar | :x: Not implemented | `static/js/formatrules.grammar` |
| CodeMirror Extension | :x: Not implemented | `static/js/formatrules-editor.js` |
| DaisyUI Theme | :x: Not implemented | `static/js/daisy-theme.js` |
| Lark to Lezer converter | :white_check_mark: Implemented | `src/myfasthtml/core/dsl/lark_to_lezer.py` |
| CodeMirror 5 assets | :white_check_mark: Implemented | `assets/codemirror.min.js`, `show-hint.min.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
├── transformer.py # AST → dataclass conversion
├── 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:**
@@ -1303,15 +1332,80 @@ class ScopedRule:
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
1. ~~**Add `lark` to dependencies** in `pyproject.toml`~~ Done
2. **Implement autocompletion API**:
- `DatagridMetadataProvider` class
- Scope detection (column/row/cell)
- Context detection
- Suggestions generation
- REST endpoint `/myfasthtml/autocompletion`
3. **Translate lark grammar to Lezer** for client-side parsing
4. **Build CodeMirror extension** with DaisyUI theme
2. ~~**Implement autocompletion API**~~ Done
3. ~~**Translate lark grammar to Lezer**~~ Done (`lark_to_lezer.py`)
4. ~~**Build CodeMirror extension**~~ Done (`DslEditor.py` + `initDslEditor()`)
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
# 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.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/placeholder.min.js
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,
lineNumbers,
autocompletion,
linting,
placeholder,
readonly,
updateCommandId,
dslId,
dsl
} = 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) {
const pushAll = (items) => {
if (!Array.isArray(items)) return;
items.forEach(item => completionItems.push(item));
};
function dslHint(cm, callback) {
const cursor = cm.getCursor();
const text = cm.getValue();
pushAll(dsl.completions.keywords);
pushAll(dsl.completions.operators);
pushAll(dsl.completions.functions);
pushAll(dsl.completions.types);
pushAll(dsl.completions.literals);
// Build URL with query params
const params = new URLSearchParams({
e_id: dslId,
text: text,
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 line = cm.getLine(cursor.line);
const ch = cursor.ch;
let start = ch;
while (start > 0 && /\w/.test(line.charAt(start - 1))) {
start--;
}
const params = new URLSearchParams({
e_id: dslId,
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 =>
item.startsWith(word)
);
// Convert server errors to CodeMirror lint format
// 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 {
list: matches,
from: CodeMirror.Pos(cursor.line, start),
to: CodeMirror.Pos(cursor.line, ch)
};
updateOutput(annotations);
})
.catch(err => {
console.error("DslEditor: Linting error", err);
updateOutput([]);
});
}
// Mark lint function as async for CodeMirror
dslLint.async = true;
/* --------------------------------------------------
* 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 || "",
lineNumbers: !!lineNumbers,
readOnly: !!readonly,
placeholder: placeholder || "",
extraKeys: autocompletion ? {
extraKeys: enableCompletion ? {
"Ctrl-Space": "autocomplete"
} : {},
hintOptions: autocompletion ? {
hintOptions: enableCompletion ? {
hint: dslHint,
completeSingle: false
} : 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
@@ -2278,11 +2358,10 @@ function initDslEditor(config) {
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) {
const selectionManager = document.getElementById(`tsm_${datagridId}`);
if (!selectionManager) return;

View File

@@ -13,8 +13,9 @@ from pandas import DataFrame
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
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.Panel import Panel, PanelConf
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
# 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.bind_command("ToggleColumnsManager", 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("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
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.commands import Command
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.instances import InstancesManager, SingleInstance
from myfasthtml.icons.fluent_p1 import table_add20_regular
@@ -72,7 +77,7 @@ class Commands(BaseCommands):
key="SelectNode")
class DataGridsManager(SingleInstance):
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
def __init__(self, parent, _id=None):
if not getattr(self, "_is_new_instance", False):
@@ -90,6 +95,11 @@ class DataGridsManager(SingleInstance):
self.style_presets: dict = DEFAULT_STYLE_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):
file_upload = FileUpload(self)
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 = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created
dg.init_from_dataframe(df)
self._registry.put(namespace, name, dg.get_id())
document = DocumentDefinition(
document_id=str(uuid.uuid4()),
namespace=namespace,
@@ -154,6 +165,26 @@ class DataGridsManager(SingleInstance):
self._tree.clear()
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 ===
def get_style_presets(self) -> dict:
@@ -194,6 +225,8 @@ class DataGridsManager(SingleInstance):
if name in self.formatter_presets:
del self.formatter_presets[name]
# === UI ===
def mk_main_icons(self):
return Div(
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.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsl.base import DSLDefinition
from myfasthtml.core.instances import MultipleInstance
@@ -25,19 +26,22 @@ logger = logging.getLogger("DslEditor")
@dataclass
class DslEditorConf:
"""Configuration for DslEditor."""
name: str = None
line_numbers: bool = True
autocompletion: bool = True
linting: bool = True
placeholder: str = ""
readonly: bool = False
class DslEditorState:
class DslEditorState(DbObject):
"""Non-persisted state for DslEditor."""
def __init__(self):
self.content: str = ""
self.auto_save: bool = True
def __init__(self, owner, name, save_state):
with self.initializing():
super().__init__(owner, name=name, save_state=save_state)
self.content: str = ""
self.auto_save: bool = True
class Commands(BaseCommands):
@@ -87,13 +91,14 @@ class DslEditor(MultipleInstance):
parent,
dsl: DSLDefinition,
conf: Optional[DslEditorConf] = None,
save_state: bool = True,
_id: Optional[str] = None,
):
super().__init__(parent, _id=_id)
self._dsl = dsl
self.conf = conf or DslEditorConf()
self._state = DslEditorState()
self._state = DslEditorState(self, name=conf.name, save_state=save_state)
self.commands = Commands(self)
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
@@ -114,9 +119,9 @@ class DslEditor(MultipleInstance):
self.on_content_changed()
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._mk_auto_save()
return self._mk_auto_save()
def on_content_changed(self) -> None:
pass
@@ -128,9 +133,11 @@ class DslEditor(MultipleInstance):
"textareaId": f"ta_{self._id}",
"lineNumbers": self.conf.line_numbers,
"autocompletion": self.conf.autocompletion,
"linting": self.conf.linting,
"placeholder": self.conf.placeholder,
"readonly": self.conf.readonly,
"updateCommandId": str(self.commands.update_content().id),
"dslId": self._dsl.get_id(),
"dsl": {
"name": self._dsl.name,
"completions": self._dsl.completions,

View File

@@ -202,6 +202,7 @@ class Panel(MultipleInstance):
body,
resizer,
cls=panel_cls,
style=f"width: {self._state.left_width}px;",
id=self._ids.panel(side)
)
else:
@@ -209,6 +210,7 @@ class Panel(MultipleInstance):
resizer,
body,
cls=panel_cls,
style=f"width: {self._state.right_width}px;",
id=self._ids.panel(side)
)
else:
@@ -218,6 +220,7 @@ class Panel(MultipleInstance):
Div(content, id=self._ids.content(side)),
resizer,
cls=panel_cls,
style=f"width: {self._state.left_width}px;",
id=self._ids.panel(side)
)
else:
@@ -226,6 +229,7 @@ class Panel(MultipleInstance):
hide_icon,
Div(content, id=self._ids.content(side)),
cls=panel_cls,
style=f"width: {self._state.left_width}px;",
id=self._ids.panel(side)
)

View File

@@ -1,7 +1,7 @@
from myfasthtml.core.dbmanager import DbManager
from myfasthtml.core.instances import SingleInstance
DATAGRIDS_REGISTRY_ENTRY_KEY = "DataGridsRegistryEntry"
DATAGRIDS_REGISTRY_ENTRY_KEY = "data_grids_registry"
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"
Bindings = "/bindings"
Completions = "/completions"
Validations = "/validations"
class ColumnType(Enum):

View File

@@ -43,7 +43,9 @@ class DbObject:
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
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._save_state = save_state

View File

@@ -13,6 +13,7 @@ from myfasthtml.core.dsl.lark_to_lezer import (
lark_to_lezer_grammar,
extract_completions_from_grammar,
)
from myfasthtml.core.utils import make_safe_id
class DSLDefinition(ABC):
@@ -82,3 +83,6 @@ class DSLDefinition(ABC):
"lezerGrammar": self.lezer_grammar,
"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).
"""
def get_tables(self) -> list[str]:
def list_tables(self) -> list[str]:
"""
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.
@@ -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.
@@ -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.
@@ -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.

View File

@@ -160,9 +160,9 @@ def _get_column_suggestions(provider: DatagridMetadataProvider) -> list[Suggesti
"""Get column name suggestions from provider."""
try:
# Try to get columns from the first available table
tables = provider.get_tables()
tables = provider.list_tables()
if tables:
columns = provider.get_columns(tables[0])
columns = provider.list_columns(tables[0])
return [Suggestion(col, "Column", "column") for col in columns]
except Exception:
pass
@@ -174,9 +174,9 @@ def _get_column_suggestions_with_closing_quote(
) -> list[Suggestion]:
"""Get column name suggestions with closing quote."""
try:
tables = provider.get_tables()
tables = provider.list_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]
except Exception:
pass
@@ -189,7 +189,7 @@ def _get_style_preset_suggestions(provider: DatagridMetadataProvider) -> list[Su
# Add provider presets if available
try:
custom_presets = provider.get_style_presets()
custom_presets = provider.list_style_presets()
for preset in custom_presets:
# Check if it's already in default presets
if not any(s.label == preset for s in presets.STYLE_PRESETS):
@@ -212,7 +212,7 @@ def _get_style_preset_suggestions_quoted(
# Add provider presets if available
try:
custom_presets = provider.get_style_presets()
custom_presets = provider.list_style_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
@@ -232,7 +232,7 @@ def _get_format_preset_suggestions(provider: DatagridMetadataProvider) -> list[S
# Add provider presets if available
try:
custom_presets = provider.get_format_presets()
custom_presets = provider.list_format_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
@@ -251,7 +251,7 @@ def _get_row_index_suggestions(provider: DatagridMetadataProvider) -> list[Sugge
suggestions = []
try:
tables = provider.get_tables()
tables = provider.list_tables()
if tables:
row_count = provider.get_row_count(tables[0])
if row_count > 0:
@@ -285,7 +285,7 @@ def _get_column_value_suggestions(
return []
try:
values = provider.get_column_values(scope.column_name)
values = provider.list_column_values(scope.column_name)
suggestions = []
for value in values:
if value is None:

View File

@@ -116,7 +116,7 @@ class BaseInstance:
_id = f"{prefix}-{str(uuid.uuid4())}"
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 _id

View File

@@ -10,6 +10,9 @@ from rich.table import Table
from starlette.routing import Mount
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
utils_app, utils_rt = fast_app()
@@ -383,12 +386,41 @@ def post(session, b_id: str, values: dict):
@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
:param session:
:param c_id:
:param values:
:param e_id: engine_id
:param text:
:param line:
:param ch:
: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"),
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

View File

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