Skip to content

Commit 80821b8

Browse files
akoclaude
andcommitted
feat: keyword dispatch table + LEGACYDATAGRID grammar (Phase 2)
Refs: mendixlabs#539 Adds Phase 2 of v0.10.0: editorial policy data for which Mendix widget a generic MDL keyword resolves to per project version, plus the LEGACYDATAGRID keyword as the explicit escape hatch for native dojo-stack widgets. mdl/executor/keyword_dispatch.go: - keywordDispatchTable: hand-maintained policy data. Today: DATAGRID resolves to com.mendix.widget.web.datagrid.Datagrid (the pluggable Datagrid 2.x widget) on Mendix 9.0+. Pluggable Datagrid has been the default since well before 11.0; we don't downgrade older versions to a hypothetical native binding because no native builder exists. - ResolveKeyword(keyword, version) → (KeywordResolution, ok): public API for inspection commands (future `mxcli schema show`) and DESCRIBE-side BSON storage type → keyword resolution. Write-side routing in buildWidgetV3 stays in the existing switch — buildDataGridV3 already produces correct pluggable BSON. - versionInRange + compareVersion: SemVer-style segment comparison with segment-count-difference handling. mdl/grammar/{MDLLexer.g4, domains/MDLPage.g4, domains/MDLSettings.g4}: - New LEGACYDATAGRID lexer token + widgetTypeV3 alternation entry + keyword-rule entry. Parses as a peer of DATAGRID; routed at executor time to a clear "not yet implemented" error rather than silently producing pluggable BSON. mdl/executor/cmd_pages_builder_v3.go: - New `case "legacydatagrid"` returns mdlerrors.NewUnsupported with an actionable message: "LEGACYDATAGRID (native Forms$DataGrid) is not yet implemented. Use DATAGRID for the pluggable equivalent on Mendix 11+, or open the project in Studio Pro to add native datagrids manually." Native dojo-stack builder lands in a follow-up (Phase 2.1). Tests (3 new): - TestResolveKeyword covers DATAGRID dispatch on multiple versions, case- insensitive keyword lookup, unknown-keyword fallback, and below-range no-match. - TestCompareVersion covers segment-count differences and non-numeric segments. - TestVersionInRange covers inclusive bounds and unbounded sides. Out of scope for v0.10.0 Phase 2 (tracked separately): - Native Forms$DataGrid builder for LEGACYDATAGRID — needs a hand-coded builder for the dojo-stack widget (Phase 2.1). - DESCRIBE-side keyword resolution — emit LEGACYDATAGRID for Forms$DataGrid BSON found in BSON, DATAGRID for com.mendix.widget.web.datagrid.Datagrid. Reader work, separate from the dispatch policy. - LEGACYLISTVIEW / LEGACYDROPDOWN / LEGACYREFERENCESELECTOR — same pattern as LEGACYDATAGRID; ship in v0.11+ once the dojo builders exist. Known issue (unrelated to Phase 2): the DataGrid pages in 31-pluggable-datagrid-gallery-v010-examples.mdl trigger CE0463 ("widget definition has changed") in Studio Pro — same root cause as Gallery CE0463 tracked under mendixlabs#541. Embedded DataGrid template is stale relative to the installed widget version. Requires template re-extraction or the template-from-MPK pipeline from mendixlabs#540. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a8ee5cf commit 80821b8

7 files changed

Lines changed: 354 additions & 0 deletions

File tree

cmd/mxcli/lsp_completions_gen.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mdl/executor/cmd_pages_builder_v3.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,13 @@ func (pb *pageBuilder) buildSnippetV3(s *ast.CreateSnippetStmtV3) (*pages.Snippe
253253
}
254254

255255
// buildWidgetV3 converts a V3 AST widget to a pages.Widget.
256+
//
257+
// Keyword dispatch (Phase 2 — issue #539): the keywordDispatchTable encodes
258+
// our editorial policy for dual-stack keywords (e.g. DATAGRID → pluggable
259+
// Datagrid 2.x). Today the existing switch cases handle this correctly via
260+
// the hand-coded builders (buildDataGridV3 already produces pluggable BSON).
261+
// The dispatch table is consumed by inspection commands and DESCRIBE-side
262+
// keyword resolution rather than overriding write-side routing here.
256263
func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) {
257264
var widget pages.Widget
258265
var err error
@@ -262,6 +269,17 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) {
262269
widget, err = pb.buildDataViewV3(w)
263270
case "datagrid":
264271
widget, err = pb.buildDataGridV3(w)
272+
case "legacydatagrid":
273+
// LEGACYDATAGRID requests the dojo-based native Forms$DataGrid (the
274+
// pre-pluggable widget). The codebase doesn't yet have a builder for
275+
// it — pluggable Datagrid (the DATAGRID keyword default) covers the
276+
// modern path. Native implementation is tracked under Phase 2.1; for
277+
// now, return an actionable error so the silent-wrong-output path is
278+
// closed.
279+
return nil, mdlerrors.NewUnsupported(
280+
"LEGACYDATAGRID (native Forms$DataGrid) is not yet implemented. " +
281+
"Use DATAGRID for the pluggable equivalent on Mendix 11+, " +
282+
"or open the project in Studio Pro to add native datagrids manually.")
265283
case "listview":
266284
widget, err = pb.buildListViewV3(w)
267285
case "layoutgrid":

mdl/executor/keyword_dispatch.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import "strings"
6+
7+
// =============================================================================
8+
// MDL keyword dispatch — native vs pluggable widgets, version-aware
9+
// =============================================================================
10+
//
11+
// Some MDL widget keywords map to different Mendix widgets depending on the
12+
// project's Mendix version. The headline case is DATAGRID:
13+
//
14+
// Mendix 9.x – 10.x: native Forms$DataGrid
15+
// Mendix 11.0+: pluggable com.mendix.widget.web.datagrid.Datagrid (React)
16+
//
17+
// Studio Pro's project upgrade does NOT auto-convert native widgets to their
18+
// pluggable replacements. Migrated 11+ projects can have both stacks coexisting
19+
// on the same page. The grammar therefore exposes both stacks via distinct
20+
// keywords:
21+
//
22+
// DATAGRID — version-default (auto-picks per Mendix version)
23+
// LEGACYDATAGRID — always native (deprecated on 11+, still allowed)
24+
//
25+
// (PLUGGABLEWIDGET 'com.mendix.widget...' name remains the explicit escape hatch.)
26+
//
27+
// This table is hand-maintained editorial policy data — it expresses our
28+
// preference for which stack a generic keyword resolves to per Mendix version,
29+
// not a Mendix-published mapping.
30+
31+
// keywordBindingKind names the runtime stack a binding produces.
32+
type keywordBindingKind string
33+
34+
const (
35+
bindingKindNative keywordBindingKind = "native"
36+
bindingKindPluggable keywordBindingKind = "pluggable"
37+
)
38+
39+
// keywordBinding is one (version-range, target) entry for a keyword.
40+
type keywordBinding struct {
41+
// MinVersion is the inclusive lower bound (e.g. "11.0.0"). Empty means
42+
// "all versions ≤ MaxVersion".
43+
MinVersion string
44+
// MaxVersion is the inclusive upper bound (e.g. "10.99.99"). Empty means
45+
// "all versions ≥ MinVersion".
46+
MaxVersion string
47+
// Kind is "native" or "pluggable".
48+
Kind keywordBindingKind
49+
// WidgetID is the pluggable widget id (only set when Kind == pluggable).
50+
WidgetID string
51+
// DeprecatedFrom marks the version (inclusive) at which this binding
52+
// becomes deprecated. Empty means "not deprecated". Used by mxcli check
53+
// --post-migration to flag legacy-stack widgets on newer projects.
54+
DeprecatedFrom string
55+
}
56+
57+
// keywordMapping is the list of bindings for one MDL keyword.
58+
type keywordMapping struct {
59+
Keyword string
60+
Bindings []keywordBinding
61+
}
62+
63+
// keywordDispatchTable is the editorial policy data for native-vs-pluggable
64+
// keyword resolution. Maintained by hand; updated when a Mendix version
65+
// promotes a new pluggable widget to be the default for a generic keyword.
66+
//
67+
// Today the entries are minimal: DATAGRID always resolves to pluggable
68+
// (Datagrid 2.x has been the default since well before 11.0). LEGACYDATAGRID
69+
// is reserved for an explicit native-stack request, tracked separately under
70+
// Phase 2.1; the buildWidgetV3 switch returns a clear "not yet implemented"
71+
// for it until a native builder lands.
72+
//
73+
// The table structure leaves room for richer dispatch rules — future
74+
// version-aware splits, additional dual-stack widgets — without rewriting
75+
// the lookup logic.
76+
var keywordDispatchTable = []keywordMapping{
77+
{
78+
Keyword: "DATAGRID",
79+
Bindings: []keywordBinding{
80+
// Pluggable Datagrid 2.x has been the default since 9.18+; we
81+
// intentionally don't downgrade older versions to a hypothetical
82+
// native binding because we have no native builder to dispatch to.
83+
{MinVersion: "9.0.0", Kind: bindingKindPluggable, WidgetID: "com.mendix.widget.web.datagrid.Datagrid"},
84+
},
85+
},
86+
}
87+
88+
// resolveKeywordBinding returns the binding for the given keyword and project
89+
// version, or (nil, false) when the keyword has no entry in the dispatch
90+
// table. Callers fall back to existing per-keyword handling when no binding
91+
// is returned.
92+
//
93+
// Comparison is uppercase-insensitive on the keyword. Version comparison uses
94+
// SemVer-style segments (no pre-release / build metadata handling).
95+
func resolveKeywordBinding(keyword, version string) (*keywordBinding, bool) {
96+
upper := strings.ToUpper(keyword)
97+
for _, mapping := range keywordDispatchTable {
98+
if mapping.Keyword != upper {
99+
continue
100+
}
101+
for i := range mapping.Bindings {
102+
b := &mapping.Bindings[i]
103+
if versionInRange(version, b.MinVersion, b.MaxVersion) {
104+
return b, true
105+
}
106+
}
107+
}
108+
return nil, false
109+
}
110+
111+
// versionInRange returns true when version is within [min, max] (inclusive).
112+
// Empty min or max means "unbounded" on that side. Empty version returns true
113+
// only if both min and max are empty (a wildcard binding).
114+
func versionInRange(version, min, max string) bool {
115+
if version == "" {
116+
return min == "" && max == ""
117+
}
118+
if min != "" && compareVersion(version, min) < 0 {
119+
return false
120+
}
121+
if max != "" && compareVersion(version, max) > 0 {
122+
return false
123+
}
124+
return true
125+
}
126+
127+
// compareVersion returns -1, 0, +1 by lexicographic comparison of dotted
128+
// integer segments (e.g. "10.18.0" vs "11.0.0"). Non-numeric segments fall
129+
// back to string comparison. Handles segment-count differences by treating
130+
// missing segments as zero.
131+
func compareVersion(a, b string) int {
132+
aParts := strings.Split(a, ".")
133+
bParts := strings.Split(b, ".")
134+
n := len(aParts)
135+
if len(bParts) > n {
136+
n = len(bParts)
137+
}
138+
for i := 0; i < n; i++ {
139+
var ai, bi int
140+
if i < len(aParts) {
141+
ai = parseIntOrZero(aParts[i])
142+
}
143+
if i < len(bParts) {
144+
bi = parseIntOrZero(bParts[i])
145+
}
146+
if ai < bi {
147+
return -1
148+
}
149+
if ai > bi {
150+
return 1
151+
}
152+
}
153+
return 0
154+
}
155+
156+
// parseIntOrZero parses a version segment as a non-negative integer.
157+
// Returns 0 for non-numeric or negative inputs (rare in real Mendix versions).
158+
func parseIntOrZero(s string) int {
159+
n := 0
160+
for _, ch := range s {
161+
if ch < '0' || ch > '9' {
162+
return 0
163+
}
164+
n = n*10 + int(ch-'0')
165+
}
166+
return n
167+
}
168+
169+
// =============================================================================
170+
// Public resolution API (consumed by inspection / DESCRIBE-side commands)
171+
// =============================================================================
172+
//
173+
// Write-side routing in buildWidgetV3 stays in the switch — the existing
174+
// hand-coded builders already produce correct BSON for each keyword. The
175+
// dispatch table is consumed by:
176+
// - mxcli schema show <KEYWORD> (future inspection command)
177+
// - DESCRIBE-side BSON storage type → keyword resolution
178+
// - check --post-migration to flag legacy-stack widgets
179+
180+
// KeywordResolution describes how an MDL keyword resolves for a given
181+
// Mendix version. Stable enough for tests and inspection commands.
182+
type KeywordResolution struct {
183+
Keyword string
184+
Version string
185+
Kind string // "native" or "pluggable"
186+
WidgetID string // pluggable widget id, empty for native
187+
DeprecatedFrom string // version where the binding becomes deprecated, empty otherwise
188+
}
189+
190+
// ResolveKeyword returns how the given keyword resolves for the given
191+
// Mendix version, or (nil, false) when the keyword has no dispatch entry.
192+
func ResolveKeyword(keyword, version string) (*KeywordResolution, bool) {
193+
binding, ok := resolveKeywordBinding(keyword, version)
194+
if !ok {
195+
return nil, false
196+
}
197+
return &KeywordResolution{
198+
Keyword: strings.ToUpper(keyword),
199+
Version: version,
200+
Kind: string(binding.Kind),
201+
WidgetID: binding.WidgetID,
202+
DeprecatedFrom: binding.DeprecatedFrom,
203+
}, true
204+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import "testing"
6+
7+
func TestResolveKeyword(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
keyword string
11+
version string
12+
wantOK bool
13+
wantKind string
14+
wantWidget string
15+
wantDeprec string
16+
}{
17+
{
18+
name: "DATAGRID on 11.9 → pluggable",
19+
keyword: "DATAGRID",
20+
version: "11.9.0",
21+
wantOK: true,
22+
wantKind: "pluggable",
23+
wantWidget: "com.mendix.widget.web.datagrid.Datagrid",
24+
},
25+
{
26+
name: "DATAGRID on 10.18 → pluggable (Datagrid 2.x default)",
27+
keyword: "DATAGRID",
28+
version: "10.18.0",
29+
wantOK: true,
30+
wantKind: "pluggable",
31+
wantWidget: "com.mendix.widget.web.datagrid.Datagrid",
32+
},
33+
{
34+
name: "DATAGRID lowercase still resolves",
35+
keyword: "datagrid",
36+
version: "11.9.0",
37+
wantOK: true,
38+
wantKind: "pluggable",
39+
wantWidget: "com.mendix.widget.web.datagrid.Datagrid",
40+
},
41+
{
42+
name: "Unknown keyword returns false",
43+
keyword: "MYCUSTOM",
44+
version: "11.9.0",
45+
wantOK: false,
46+
},
47+
{
48+
name: "DATAGRID below 9.0 → no match (not in any binding range)",
49+
keyword: "DATAGRID",
50+
version: "8.18.0",
51+
wantOK: false,
52+
},
53+
}
54+
55+
for _, tc := range tests {
56+
t.Run(tc.name, func(t *testing.T) {
57+
res, ok := ResolveKeyword(tc.keyword, tc.version)
58+
if ok != tc.wantOK {
59+
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
60+
}
61+
if !ok {
62+
return
63+
}
64+
if res.Kind != tc.wantKind {
65+
t.Errorf("Kind = %q, want %q", res.Kind, tc.wantKind)
66+
}
67+
if res.WidgetID != tc.wantWidget {
68+
t.Errorf("WidgetID = %q, want %q", res.WidgetID, tc.wantWidget)
69+
}
70+
if res.DeprecatedFrom != tc.wantDeprec {
71+
t.Errorf("DeprecatedFrom = %q, want %q", res.DeprecatedFrom, tc.wantDeprec)
72+
}
73+
})
74+
}
75+
}
76+
77+
func TestCompareVersion(t *testing.T) {
78+
tests := []struct {
79+
a, b string
80+
want int
81+
}{
82+
{"11.0.0", "10.99.99", 1},
83+
{"10.18.0", "10.24.0", -1},
84+
{"11.9.0", "11.9.0", 0},
85+
{"11", "11.0.0", 0},
86+
{"11.9", "11.9.0", 0},
87+
{"9.0.0", "10.0.0", -1},
88+
{"abc", "1.0.0", -1}, // non-numeric → 0, less than 1
89+
}
90+
for _, tc := range tests {
91+
got := compareVersion(tc.a, tc.b)
92+
if got != tc.want {
93+
t.Errorf("compareVersion(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
94+
}
95+
}
96+
}
97+
98+
func TestVersionInRange(t *testing.T) {
99+
tests := []struct {
100+
version, min, max string
101+
want bool
102+
}{
103+
{"11.9.0", "11.0.0", "", true},
104+
{"11.9.0", "9.0.0", "10.99.99", false},
105+
{"10.24.0", "9.0.0", "10.99.99", true},
106+
{"10.99.99", "9.0.0", "10.99.99", true}, // exact upper bound is inclusive
107+
{"10.99.100", "9.0.0", "10.99.99", false}, // patch beyond bound excluded
108+
{"11.0.0", "11.0.0", "", true},
109+
{"11.0.0", "11.0.1", "", false},
110+
{"", "", "", true},
111+
{"", "9.0.0", "", false}, // empty version excluded when bounds set
112+
}
113+
for _, tc := range tests {
114+
got := versionInRange(tc.version, tc.min, tc.max)
115+
if got != tc.want {
116+
t.Errorf("versionInRange(%q, %q, %q) = %v, want %v",
117+
tc.version, tc.min, tc.max, got, tc.want)
118+
}
119+
}
120+
}

mdl/grammar/MDLLexer.g4

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ MARKER: M A R K E R; // Maps markers
262262
DYNAMICMARKER: D Y N A M I C M A R K E R; // Maps dynamicMarkers
263263
SERIES: S E R I E S; // AreaChart / chart series
264264

265+
// Dual-stack keywords (Phase 2 — issue #539). LEGACY* keywords route to the
266+
// dojo-based native widgets even on Mendix 11+ where the pluggable React
267+
// equivalent has become the default for the generic keyword.
268+
LEGACYDATAGRID: L E G A C Y D A T A G R I D;
269+
265270
// Button widgets
266271
ACTIONBUTTON: A C T I O N B U T T O N;
267272
LINKBUTTON: L I N K B U T T O N;

mdl/grammar/domains/MDLPage.g4

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ widgetTypeV3
254254
| MARKER
255255
| DYNAMICMARKER
256256
| SERIES
257+
// Dual-stack keyword (Phase 2 — #539). LEGACYDATAGRID always routes to
258+
// the dojo-based native Forms$DataGrid even on Mendix 11+; useful for
259+
// migrated projects that still have native datagrids on the page.
260+
| LEGACYDATAGRID
257261
;
258262

259263
// V3 Widget properties: (Prop: Value, Prop: Value)

mdl/grammar/domains/MDLSettings.g4

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,8 @@ keyword
534534
| TABCONTAINER | TABPAGE | WIDGET | WIDGETS
535535
// Object-list container keywords for pluggable widgets (#538)
536536
| GROUP | CUSTOMITEM | MARKER | DYNAMICMARKER | SERIES
537+
// Dual-stack keyword (#539)
538+
| LEGACYDATAGRID
537539

538540
// Widget properties
539541
| ATTR | ATTRIBUTES | ATTRIBUTE | AUTOFILL | BINDS | BUTTONSTYLE

0 commit comments

Comments
 (0)