diff --git a/Makefile b/Makefile index 2a6a104..0f4c7c6 100644 --- a/Makefile +++ b/Makefile @@ -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 + diff --git a/docs/DataGrid Formatting DSL.md b/docs/DataGrid Formatting DSL.md index ab8b909..7c82ef6 100644 --- a/docs/DataGrid Formatting DSL.md +++ b/docs/DataGrid Formatting DSL.md @@ -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` diff --git a/src/myfasthtml/assets/Readme.md b/src/myfasthtml/assets/Readme.md index 8399eaa..d22bdef 100644 --- a/src/myfasthtml/assets/Readme.md +++ b/src/myfasthtml/assets/Readme.md @@ -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 ``` \ No newline at end of file diff --git a/src/myfasthtml/assets/lint.min.css b/src/myfasthtml/assets/lint.min.css new file mode 100644 index 0000000..371519e --- /dev/null +++ b/src/myfasthtml/assets/lint.min.css @@ -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)} \ No newline at end of file diff --git a/src/myfasthtml/assets/lint.min.js b/src/myfasthtml/assets/lint.min.js new file mode 100644 index 0000000..47c08be --- /dev/null +++ b/src/myfasthtml/assets/lint.min.js @@ -0,0 +1 @@ +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(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 { - 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; diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 87a6dc8..eb874fb 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -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 = { diff --git a/src/myfasthtml/controls/DataGridFormattingEditor.py b/src/myfasthtml/controls/DataGridFormattingEditor.py new file mode 100644 index 0000000..39edebf --- /dev/null +++ b/src/myfasthtml/controls/DataGridFormattingEditor.py @@ -0,0 +1,7 @@ +from myfasthtml.controls.DslEditor import DslEditor + + +class DataGridFormattingEditor(DslEditor): + + def on_dsl_change(self, dsl): + pass diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 473561d..32ad2d9 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -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): @@ -89,6 +94,11 @@ class DataGridsManager(SingleInstance): # Global presets shared across all DataGrids 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) @@ -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()), diff --git a/src/myfasthtml/controls/DslEditor.py b/src/myfasthtml/controls/DslEditor.py index df6a6db..e72d4d9 100644 --- a/src/myfasthtml/controls/DslEditor.py +++ b/src/myfasthtml/controls/DslEditor.py @@ -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, diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index a99b4e7..add738c 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -159,31 +159,31 @@ class Panel(MultipleInstance): enabled = self.conf.left if side == "left" else self.conf.right if not enabled: return None - + visible = self._state.left_visible if side == "left" else self._state.right_visible content = self._right if side == "right" else self._left show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title title = self.conf.left_title if side == "left" else self.conf.right_title - + resizer = Div( cls=f"mf-resizer mf-resizer-{side}", data_command_id=self.commands.update_side_width(side).id, data_side=side ) - + hide_icon = mk.icon( subtract20_regular, size=20, command=self.commands.set_side_visible(side, False), cls="mf-panel-hide-icon" ) - + panel_cls = f"mf-panel-{side}" if not visible: panel_cls += " mf-hidden" if show_title: panel_cls += " mf-panel-with-title" - + # Left panel: content then resizer (resizer on the right) # Right panel: resizer then content (resizer on the left) if show_title: @@ -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) ) @@ -250,14 +254,14 @@ class Panel(MultipleInstance): enabled = self.conf.left if side == "left" else self.conf.right if not enabled: return None - + show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right if not show_display: return None - + is_visible = self._state.left_visible if side == "left" else self._state.right_visible icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}" - + return mk.icon( more_horizontal20_regular, command=self.commands.set_side_visible(side, True), diff --git a/src/myfasthtml/core/DataGridsRegistry.py b/src/myfasthtml/core/DataGridsRegistry.py index 4905361..255c430 100644 --- a/src/myfasthtml/core/DataGridsRegistry.py +++ b/src/myfasthtml/core/DataGridsRegistry.py @@ -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): diff --git a/src/myfasthtml/core/completions.py b/src/myfasthtml/core/completions.py deleted file mode 100644 index 35dcfc9..0000000 --- a/src/myfasthtml/core/completions.py +++ /dev/null @@ -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 = {} diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index 1c5a096..9e90051 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -15,6 +15,7 @@ class Routes: Commands = "/commands" Bindings = "/bindings" Completions = "/completions" + Validations = "/validations" class ColumnType(Enum): diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 24180d8..76a79d7 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -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 diff --git a/src/myfasthtml/core/dsl/base.py b/src/myfasthtml/core/dsl/base.py index 3181826..16db1bf 100644 --- a/src/myfasthtml/core/dsl/base.py +++ b/src/myfasthtml/core/dsl/base.py @@ -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) diff --git a/src/myfasthtml/core/dsls.py b/src/myfasthtml/core/dsls.py new file mode 100644 index 0000000..25276db --- /dev/null +++ b/src/myfasthtml/core/dsls.py @@ -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 = {} diff --git a/src/myfasthtml/core/formatting/dsl/completion/provider.py b/src/myfasthtml/core/formatting/dsl/completion/provider.py index cd7fe0e..627c7e6 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/provider.py +++ b/src/myfasthtml/core/formatting/dsl/completion/provider.py @@ -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. diff --git a/src/myfasthtml/core/formatting/dsl/completion/suggestions.py b/src/myfasthtml/core/formatting/dsl/completion/suggestions.py index e1df93a..b5621f0 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/suggestions.py +++ b/src/myfasthtml/core/formatting/dsl/completion/suggestions.py @@ -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: diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index 0242a13..81024fa 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -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 diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index f8f6c2f..302e306 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -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 + }]} diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index 82580b7..2d99fdb 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -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 diff --git a/tests/core/formatting/test_dsl_parser.py b/tests/core/formatting/test_dsl_parser.py index 39969e3..6cdb116 100644 --- a/tests/core/formatting/test_dsl_parser.py +++ b/tests/core/formatting/test_dsl_parser.py @@ -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.""" - - def test_i_can_parse_column_scope(self): - """Test parsing a simple column scope.""" - dsl = """ + """Tests for column scope parsing.""" + + def test_i_can_parse_column_scope(self): + """Test parsing a simple column scope.""" + dsl = """ column amount: style("error") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert isinstance(rules[0].scope, ColumnScope) - assert rules[0].scope.column == "amount" - - def test_i_can_parse_column_scope_with_quoted_name(self): - """Test parsing a column scope with quoted name containing spaces.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert isinstance(rules[0].scope, ColumnScope) + assert rules[0].scope.column == "amount" + + def test_i_can_parse_column_scope_with_quoted_name(self): + """Test parsing a column scope with quoted name containing spaces.""" + dsl = """ column "total amount": style("error") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert isinstance(rules[0].scope, ColumnScope) - assert rules[0].scope.column == "total amount" + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert isinstance(rules[0].scope, ColumnScope) + assert rules[0].scope.column == "total amount" class TestRowScope: - """Tests for row scope parsing.""" - - def test_i_can_parse_row_scope(self): - """Test parsing a row scope.""" - dsl = """ + """Tests for row scope parsing.""" + + def test_i_can_parse_row_scope(self): + """Test parsing a row scope.""" + dsl = """ row 0: style("neutral") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert isinstance(rules[0].scope, RowScope) - assert rules[0].scope.row == 0 - - def test_i_can_parse_row_scope_with_large_index(self): - """Test parsing a row scope with a large index.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert isinstance(rules[0].scope, RowScope) + assert rules[0].scope.row == 0 + + def test_i_can_parse_row_scope_with_large_index(self): + """Test parsing a row scope with a large index.""" + dsl = """ row 999: style("highlight") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert rules[0].scope.row == 999 + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert rules[0].scope.row == 999 class TestCellScope: - """Tests for cell scope parsing.""" - - def test_i_can_parse_cell_scope_with_coords(self): - """Test parsing a cell scope with coordinates.""" - dsl = """ + """Tests for cell scope parsing.""" + + def test_i_can_parse_cell_scope_with_coords(self): + """Test parsing a cell scope with coordinates.""" + dsl = """ cell (amount, 3): style("highlight") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert isinstance(rules[0].scope, CellScope) - assert rules[0].scope.column == "amount" - assert rules[0].scope.row == 3 - assert rules[0].scope.cell_id is None - - def test_i_can_parse_cell_scope_with_quoted_column(self): - """Test parsing a cell scope with quoted column name.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert isinstance(rules[0].scope, CellScope) + assert rules[0].scope.column == "amount" + assert rules[0].scope.row == 3 + assert rules[0].scope.cell_id is None + + def test_i_can_parse_cell_scope_with_quoted_column(self): + """Test parsing a cell scope with quoted column name.""" + dsl = """ cell ("total amount", 5): style("highlight") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert rules[0].scope.column == "total amount" - assert rules[0].scope.row == 5 - - def test_i_can_parse_cell_scope_with_id(self): - """Test parsing a cell scope with cell ID.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert rules[0].scope.column == "total amount" + assert rules[0].scope.row == 5 + + def test_i_can_parse_cell_scope_with_id(self): + """Test parsing a cell scope with cell ID.""" + dsl = """ cell tcell_grid1-3-2: style("highlight") """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert isinstance(rules[0].scope, CellScope) - assert rules[0].scope.cell_id == "tcell_grid1-3-2" - assert rules[0].scope.column is None - assert rules[0].scope.row is None + 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 # ============================================================================= @@ -135,61 +131,61 @@ cell tcell_grid1-3-2: class TestStyleParsing: - """Tests for style expression parsing.""" - - def test_i_can_parse_style_with_preset(self): - """Test parsing style with preset only.""" - dsl = """ + """Tests for style expression parsing.""" + + def test_i_can_parse_style_with_preset(self): + """Test parsing style with preset only.""" + dsl = """ column amount: style("error") """ - rules = parse_dsl(dsl) - - assert rules[0].rule.style is not None - assert rules[0].rule.style.preset == "error" - - def test_i_can_parse_style_without_preset(self): - """Test parsing style without preset, with direct properties.""" - dsl = """ + rules = parse_dsl(dsl) + + assert rules[0].rule.style is not None + assert rules[0].rule.style.preset == "error" + + def test_i_can_parse_style_without_preset(self): + """Test parsing style without preset, with direct properties.""" + dsl = """ column amount: style(color="red", bold=True) """ - rules = parse_dsl(dsl) - - style = rules[0].rule.style - assert style.preset is None - assert style.color == "red" - assert style.font_weight == "bold" - - def test_i_can_parse_style_with_preset_and_options(self): - """Test parsing style with preset and additional options.""" - dsl = """ + rules = parse_dsl(dsl) + + style = rules[0].rule.style + assert style.preset is None + assert style.color == "red" + assert style.font_weight == "bold" + + def test_i_can_parse_style_with_preset_and_options(self): + """Test parsing style with preset and additional options.""" + dsl = """ column amount: style("error", bold=True, italic=True) """ - rules = parse_dsl(dsl) - - style = rules[0].rule.style - assert style.preset == "error" - assert style.font_weight == "bold" - assert style.font_style == "italic" - - @pytest.mark.parametrize("option,attr_name,attr_value", [ - ("bold=True", "font_weight", "bold"), - ("italic=True", "font_style", "italic"), - ("underline=True", "text_decoration", "underline"), - ("strikethrough=True", "text_decoration", "line-through"), - ]) - def test_i_can_parse_style_options(self, option, attr_name, attr_value): - """Test parsing individual style options.""" - dsl = f""" + rules = parse_dsl(dsl) + + style = rules[0].rule.style + assert style.preset == "error" + assert style.font_weight == "bold" + assert style.font_style == "italic" + + @pytest.mark.parametrize("option,attr_name,attr_value", [ + ("bold=True", "font_weight", "bold"), + ("italic=True", "font_style", "italic"), + ("underline=True", "text_decoration", "underline"), + ("strikethrough=True", "text_decoration", "line-through"), + ]) + def test_i_can_parse_style_options(self, option, attr_name, attr_value): + """Test parsing individual style options.""" + dsl = f""" column amount: style({option}) """ - rules = parse_dsl(dsl) - - style = rules[0].rule.style - assert getattr(style, attr_name) == attr_value + rules = parse_dsl(dsl) + + 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.""" - - def test_i_can_parse_format_preset(self): - """Test parsing format with preset.""" - dsl = """ + """Tests for format expression parsing.""" + + def test_i_can_parse_format_preset(self): + """Test parsing format with preset.""" + dsl = """ column amount: format("EUR") """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert formatter is not None - assert formatter.preset == "EUR" - - def test_i_can_parse_format_preset_with_options(self): - """Test parsing format preset with options.""" - dsl = """ + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert formatter is not None + assert formatter.preset == "EUR" + + def test_i_can_parse_format_preset_with_options(self): + """Test parsing format preset with options.""" + dsl = """ column amount: format("EUR", precision=3) """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert formatter.preset == "EUR" - assert formatter.precision == 3 - - @pytest.mark.parametrize("format_type,formatter_class", [ - ("number", NumberFormatter), - ("date", DateFormatter), - ("boolean", BooleanFormatter), - ("text", TextFormatter), - ("enum", EnumFormatter), - ]) - def test_i_can_parse_format_types(self, format_type, formatter_class): - """Test parsing explicit format types.""" - dsl = f""" + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert formatter.preset == "EUR" + assert formatter.precision == 3 + + @pytest.mark.parametrize("format_type,formatter_class", [ + ("number", NumberFormatter), + ("date", DateFormatter), + ("boolean", BooleanFormatter), + ("text", TextFormatter), + ("enum", EnumFormatter), + ]) + def test_i_can_parse_format_types(self, format_type, formatter_class): + """Test parsing explicit format types.""" + dsl = f""" column amount: format.{format_type}() """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert isinstance(formatter, formatter_class) - - def test_i_can_parse_format_number_with_options(self): - """Test parsing format.number with all options.""" - dsl = """ + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert isinstance(formatter, formatter_class) + + def test_i_can_parse_format_number_with_options(self): + """Test parsing format.number with all options.""" + dsl = """ column amount: format.number(precision=2, suffix=" EUR", thousands_sep=" ") """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert isinstance(formatter, NumberFormatter) - assert formatter.precision == 2 - assert formatter.suffix == " EUR" - assert formatter.thousands_sep == " " - - def test_i_can_parse_format_date_with_options(self): - """Test parsing format.date with format option.""" - dsl = """ + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert isinstance(formatter, NumberFormatter) + assert formatter.precision == 2 + assert formatter.suffix == " EUR" + assert formatter.thousands_sep == " " + + def test_i_can_parse_format_date_with_options(self): + """Test parsing format.date with format option.""" + dsl = """ column created_at: format.date(format="%d/%m/%Y") """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert isinstance(formatter, DateFormatter) - assert formatter.format == "%d/%m/%Y" - - def test_i_can_parse_format_boolean_with_options(self): - """Test parsing format.boolean with all options.""" - dsl = """ + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert isinstance(formatter, DateFormatter) + assert formatter.format == "%d/%m/%Y" + + def test_i_can_parse_format_boolean_with_options(self): + """Test parsing format.boolean with all options.""" + dsl = """ column active: format.boolean(true_value="Oui", false_value="Non") """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert isinstance(formatter, BooleanFormatter) - assert formatter.true_value == "Oui" - assert formatter.false_value == "Non" - - def test_i_can_parse_format_enum_with_source(self): - """Test parsing format.enum with source mapping.""" - dsl = """ + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert isinstance(formatter, BooleanFormatter) + assert formatter.true_value == "Oui" + assert formatter.false_value == "Non" + + def test_i_can_parse_format_enum_with_source(self): + """Test parsing format.enum with source mapping.""" + dsl = """ column status: format.enum(source={"draft": "Brouillon", "published": "Publie"}) """ - rules = parse_dsl(dsl) - - formatter = rules[0].rule.formatter - assert isinstance(formatter, EnumFormatter) - assert formatter.source == {"draft": "Brouillon", "published": "Publie"} + rules = parse_dsl(dsl) + + 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.""" - - @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""" + """Tests for condition parsing.""" + + @pytest.mark.parametrize("operator,dsl_op", [ + ("==", "=="), + ("!=", "!="), + ("<", "<"), + ("<=", "<="), + (">", ">"), + (">=", ">="), + ("contains", "contains"), + ("startswith", "startswith"), + ("endswith", "endswith"), + ]) + def test_i_can_parse_comparison_operators(self, operator, dsl_op): + """Test parsing all comparison operators.""" + dsl = f""" column amount: style("error") if value {dsl_op} 0 """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition is not None - assert condition.operator == operator - - @pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"]) - def test_i_can_parse_unary_conditions(self, unary_op): - """Test parsing unary conditions (isempty, isnotempty).""" - dsl = f""" + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition is not None + assert condition.operator == operator + + @pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"]) + def test_i_can_parse_unary_conditions(self, unary_op): + """Test parsing unary conditions (isempty, isnotempty).""" + dsl = f""" column name: style("neutral") if value {unary_op} """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.operator == unary_op - - def test_i_can_parse_condition_in(self): - """Test parsing 'in' condition with list.""" - dsl = """ + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition.operator == unary_op + + def test_i_can_parse_condition_in(self): + """Test parsing 'in' condition with list.""" + dsl = """ column status: style("success") if value in ["approved", "validated"] """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.operator == "in" - assert condition.value == ["approved", "validated"] - - def test_i_can_parse_condition_between(self): - """Test parsing 'between' condition.""" - dsl = """ + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition.operator == "in" + assert condition.value == ["approved", "validated"] + + def test_i_can_parse_condition_between(self): + """Test parsing 'between' condition.""" + dsl = """ column score: style("warning") if value between 30 and 70 """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.operator == "between" - assert condition.value == [30, 70] - - def test_i_can_parse_condition_negation(self): - """Test parsing negated condition.""" - dsl = """ + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition.operator == "between" + assert condition.value == [30, 70] + + def test_i_can_parse_condition_negation(self): + """Test parsing negated condition.""" + dsl = """ column status: style("error") if not value == "approved" """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.negate is True - assert condition.operator == "==" - - def test_i_can_parse_condition_case_sensitive(self): - """Test parsing case-sensitive condition.""" - dsl = """ + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition.negate is True + assert condition.operator == "==" + + def test_i_can_parse_condition_case_sensitive(self): + """Test parsing case-sensitive condition.""" + dsl = """ column name: style("error") if value == "Error" (case) """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.case_sensitive is True + rules = parse_dsl(dsl) + + 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.""" - - @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""" + """Tests for literal value parsing in conditions.""" + + @pytest.mark.parametrize("literal,expected_value,expected_type", [ + ('"hello"', "hello", str), + ("'world'", "world", str), + ("42", 42, int), + ("-10", -10, int), + ("3.14", 3.14, float), + ("-2.5", -2.5, float), + ("True", True, bool), + ("False", False, bool), + ("true", True, bool), + ("false", False, bool), + ]) + def test_i_can_parse_literals(self, literal, expected_value, expected_type): + """Test parsing various literal types in conditions.""" + dsl = f""" column amount: style("error") if value == {literal} """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.value == expected_value - assert isinstance(condition.value, expected_type) + rules = parse_dsl(dsl) + + 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.""" - - def test_i_can_parse_column_reference(self): - """Test parsing column reference in condition.""" - dsl = """ + """Tests for cell reference parsing in conditions.""" + + def test_i_can_parse_column_reference(self): + """Test parsing column reference in condition.""" + dsl = """ column actual: style("error") if value > col.budget """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.value == {"col": "budget"} - - def test_i_can_parse_column_reference_with_quoted_name(self): - """Test parsing column reference with quoted name.""" - dsl = """ + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition.value == {"col": "budget"} + + def test_i_can_parse_column_reference_with_quoted_name(self): + """Test parsing column reference with quoted name.""" + dsl = """ column actual: style("error") if value > col."max budget" """ - rules = parse_dsl(dsl) - - condition = rules[0].rule.condition - assert condition.value == {"col": "max budget"} + rules = parse_dsl(dsl) + + condition = rules[0].rule.condition + assert condition.value == {"col": "max budget"} # ============================================================================= @@ -455,27 +451,27 @@ column actual: class TestComplexStructures: - """Tests for complex DSL structures.""" - - def test_i_can_parse_multiple_rules_in_scope(self): - """Test parsing multiple rules under one scope.""" - dsl = """ + """Tests for complex DSL structures.""" + + def test_i_can_parse_multiple_rules_in_scope(self): + """Test parsing multiple rules under one scope.""" + dsl = """ column amount: style("error") if value < 0 style("success") if value > 1000 format("EUR") """ - rules = parse_dsl(dsl) - - assert len(rules) == 3 - # All rules share the same scope - for rule in rules: - assert isinstance(rule.scope, ColumnScope) - assert rule.scope.column == "amount" - - def test_i_can_parse_multiple_scopes(self): - """Test parsing multiple scopes.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 3 + # All rules share the same scope + for rule in rules: + assert isinstance(rule.scope, ColumnScope) + assert rule.scope.column == "amount" + + def test_i_can_parse_multiple_scopes(self): + """Test parsing multiple scopes.""" + dsl = """ column amount: format("EUR") @@ -485,51 +481,51 @@ column status: row 0: style("neutral", bold=True) """ - rules = parse_dsl(dsl) - - assert len(rules) == 3 - - # First rule: column amount - assert isinstance(rules[0].scope, ColumnScope) - assert rules[0].scope.column == "amount" - - # Second rule: column status - assert isinstance(rules[1].scope, ColumnScope) - assert rules[1].scope.column == "status" - - # Third rule: row 0 - assert isinstance(rules[2].scope, RowScope) - assert rules[2].scope.row == 0 - - def test_i_can_parse_style_and_format_combined(self): - """Test parsing style and format on same line.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 3 + + # First rule: column amount + assert isinstance(rules[0].scope, ColumnScope) + assert rules[0].scope.column == "amount" + + # Second rule: column status + assert isinstance(rules[1].scope, ColumnScope) + assert rules[1].scope.column == "status" + + # Third rule: row 0 + assert isinstance(rules[2].scope, RowScope) + assert rules[2].scope.row == 0 + + def test_i_can_parse_style_and_format_combined(self): + """Test parsing style and format on same line.""" + dsl = """ column amount: style("error") format("EUR") if value < 0 """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - rule = rules[0].rule - assert rule.style is not None - assert rule.style.preset == "error" - assert rule.formatter is not None - assert rule.formatter.preset == "EUR" - assert rule.condition is not None - assert rule.condition.operator == "<" - - def test_i_can_parse_comments(self): - """Test that comments are ignored.""" - dsl = """ + rules = parse_dsl(dsl) + + assert len(rules) == 1 + rule = rules[0].rule + assert rule.style is not None + assert rule.style.preset == "error" + assert rule.formatter is not None + assert rule.formatter.preset == "EUR" + assert rule.condition is not None + assert rule.condition.operator == "<" + + def test_i_can_parse_comments(self): + """Test that comments are ignored.""" + dsl = """ # This is a comment column amount: # Another comment style("error") if value < 0 """ - rules = parse_dsl(dsl) - - assert len(rules) == 1 - assert rules[0].rule.style.preset == "error" + rules = parse_dsl(dsl) + + assert len(rules) == 1 + assert rules[0].rule.style.preset == "error" # ============================================================================= @@ -538,39 +534,39 @@ column amount: class TestSyntaxErrors: - """Tests for syntax error handling.""" - - def test_i_cannot_parse_invalid_syntax(self): - """Test that invalid syntax raises DSLSyntaxError.""" - dsl = """ + """Tests for syntax error handling.""" + + def test_i_cannot_parse_invalid_syntax(self): + """Test that invalid syntax raises DSLSyntaxError.""" + dsl = """ column amount style("error") """ - with pytest.raises(DSLSyntaxError): - parse_dsl(dsl) - - def test_i_cannot_parse_missing_indent(self): - """Test that missing indentation raises DSLSyntaxError.""" - dsl = """ + with pytest.raises(DSLSyntaxError): + parse_dsl(dsl) + + def test_i_cannot_parse_missing_indent(self): + """Test that missing indentation raises DSLSyntaxError.""" + dsl = """ column amount: style("error") """ - with pytest.raises(DSLSyntaxError): - parse_dsl(dsl) - - def test_i_cannot_parse_empty_scope(self): - """Test that empty scope raises DSLSyntaxError.""" - dsl = """ + with pytest.raises(DSLSyntaxError): + parse_dsl(dsl) + + def test_i_cannot_parse_empty_scope(self): + """Test that empty scope raises DSLSyntaxError.""" + dsl = """ column amount: """ - with pytest.raises(DSLSyntaxError): - parse_dsl(dsl) - - def test_i_cannot_parse_invalid_operator(self): - """Test that invalid operator raises DSLSyntaxError.""" - dsl = """ + with pytest.raises(DSLSyntaxError): + parse_dsl(dsl) + + def test_i_cannot_parse_invalid_operator(self): + """Test that invalid operator raises DSLSyntaxError.""" + dsl = """ column amount: style("error") if value <> 0 """ - with pytest.raises(DSLSyntaxError): - parse_dsl(dsl) + with pytest.raises(DSLSyntaxError): + parse_dsl(dsl)