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
});
fetch(`/myfasthtml/validations?${params}`)
.then(response => response.json())
.then(data => {
if (!data || !data.errors || data.errors.length === 0) {
updateOutput([]);
return;
}
const word = line.slice(start, ch);
// 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"
}));
const matches = completionItems.filter(item =>
item.startsWith(word)
);
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,17 +26,20 @@ 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):
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
@@ -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,24 +5,20 @@ 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,
)
from myfasthtml.core.formatting.dsl import (
parse_dsl,
ColumnScope,
RowScope,
CellScope,
DSLSyntaxError,
)
# =============================================================================