Skip to content

Commit 73f6683

Browse files
authored
Merge pull request #168 from engalar/fix/data-container-hints
feat: add data container context hints to DESCRIBE PAGE
2 parents a2515e9 + 271b9e1 commit 73f6683

6 files changed

Lines changed: 296 additions & 0 deletions

File tree

cmd/mxcli/lsp_completion.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ func (s *mdlServer) Completion(ctx context.Context, params *protocol.CompletionP
3131
}
3232
linePrefixUpper := strings.ToUpper(linePrefix)
3333

34+
// Check if typing a $ variable reference inside page/snippet context
35+
if strings.Contains(linePrefix, "$") {
36+
varItems := s.variableCompletionItems(text, linePrefix)
37+
if len(varItems) > 0 {
38+
return &protocol.CompletionList{
39+
IsIncomplete: false,
40+
Items: varItems,
41+
}, nil
42+
}
43+
}
44+
3445
// Check if context calls for catalog-based element completion
3546
if types := inferCompletionTypes(linePrefixUpper); types != nil {
3647
items := s.catalogCompletionItems(ctx, linePrefix, types)
@@ -360,3 +371,86 @@ func objectTypeToCompletionKind(objectType string) (protocol.CompletionItemKind,
360371
return protocol.CompletionItemKindValue, objectType
361372
}
362373
}
374+
375+
// variableCompletionItems returns completion items for $ variable references.
376+
// It suggests $currentObject (common in data containers) and any page parameters
377+
// found in the document's CREATE PAGE Params declaration.
378+
func (s *mdlServer) variableCompletionItems(docText string, linePrefix string) []protocol.CompletionItem {
379+
// Extract the partial after the last $ to filter suggestions
380+
lastDollar := strings.LastIndex(linePrefix, "$")
381+
partial := ""
382+
if lastDollar >= 0 && lastDollar < len(linePrefix)-1 {
383+
partial = strings.ToUpper(linePrefix[lastDollar+1:])
384+
}
385+
386+
var items []protocol.CompletionItem
387+
388+
// Always suggest $currentObject — it's the most common data container variable
389+
if partial == "" || strings.HasPrefix("CURRENTOBJECT", partial) {
390+
items = append(items, protocol.CompletionItem{
391+
Label: "$currentObject",
392+
Kind: protocol.CompletionItemKindVariable,
393+
Detail: "Current object from enclosing data container",
394+
})
395+
}
396+
397+
// Extract page parameter names from CREATE PAGE ... Params: { $Name: Type, ... }
398+
paramNames := extractPageParamNames(docText)
399+
for _, name := range paramNames {
400+
if partial == "" || strings.HasPrefix(strings.ToUpper(name), partial) {
401+
items = append(items, protocol.CompletionItem{
402+
Label: "$" + name,
403+
Kind: protocol.CompletionItemKindVariable,
404+
Detail: "Page parameter",
405+
})
406+
}
407+
}
408+
409+
return items
410+
}
411+
412+
// extractPageParamNames extracts parameter names from CREATE PAGE ... Params: { $Name: Type } declarations.
413+
func extractPageParamNames(text string) []string {
414+
var names []string
415+
for _, line := range strings.Split(text, "\n") {
416+
trimmed := strings.TrimSpace(line)
417+
// Look for $ParamName patterns in Params declarations
418+
// Format: Params: { $Name: Type } or $Name: Type on separate lines
419+
idx := 0
420+
for idx < len(trimmed) {
421+
dollar := strings.Index(trimmed[idx:], "$")
422+
if dollar < 0 {
423+
break
424+
}
425+
dollar += idx
426+
// Extract the name after $
427+
end := dollar + 1
428+
for end < len(trimmed) {
429+
c := trimmed[end]
430+
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
431+
end++
432+
} else {
433+
break
434+
}
435+
}
436+
if end > dollar+1 {
437+
name := trimmed[dollar+1 : end]
438+
// Skip if this looks like a variable declaration (DECLARE) rather than a param
439+
if !strings.HasPrefix(strings.ToUpper(trimmed), "DECLARE") {
440+
names = append(names, name)
441+
}
442+
}
443+
idx = end
444+
}
445+
}
446+
// Deduplicate
447+
seen := make(map[string]bool)
448+
var unique []string
449+
for _, n := range names {
450+
if !seen[n] {
451+
seen[n] = true
452+
unique = append(unique, n)
453+
}
454+
}
455+
return unique
456+
}

cmd/mxcli/lsp_completion_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package main
4+
5+
import (
6+
"testing"
7+
)
8+
9+
func TestExtractPageParamNames(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
text string
13+
expected []string
14+
}{
15+
{
16+
name: "single param",
17+
text: "CREATE PAGE Mod.Page (Params: { $Order: Mod.Order })",
18+
expected: []string{"Order"},
19+
},
20+
{
21+
name: "multiple params",
22+
text: "CREATE PAGE Mod.Page (\n Params: { $Customer: Mod.Customer, $Helper: Mod.Helper }\n)",
23+
expected: []string{"Customer", "Helper"},
24+
},
25+
{
26+
name: "no params",
27+
text: "CREATE PAGE Mod.Page (Title: 'Test')",
28+
expected: nil,
29+
},
30+
{
31+
name: "skip DECLARE variables",
32+
text: "DECLARE $Temp String = '';\n$Order: Mod.Order",
33+
expected: []string{"Order"},
34+
},
35+
}
36+
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
got := extractPageParamNames(tt.text)
40+
if len(got) != len(tt.expected) {
41+
t.Errorf("extractPageParamNames() got %v, want %v", got, tt.expected)
42+
return
43+
}
44+
for i, name := range got {
45+
if name != tt.expected[i] {
46+
t.Errorf("extractPageParamNames()[%d] = %q, want %q", i, name, tt.expected[i])
47+
}
48+
}
49+
})
50+
}
51+
}
52+
53+
func TestVariableCompletionItems(t *testing.T) {
54+
s := &mdlServer{}
55+
docText := "CREATE PAGE Mod.Page (\n Params: { $Customer: Mod.Customer }\n) {\n DATAVIEW dv1 (DataSource: $Customer) {\n"
56+
57+
items := s.variableCompletionItems(docText, "$")
58+
if len(items) == 0 {
59+
t.Fatal("expected completion items for $ prefix")
60+
}
61+
62+
// Should contain $currentObject
63+
foundCurrentObj := false
64+
foundCustomer := false
65+
for _, item := range items {
66+
if item.Label == "$currentObject" {
67+
foundCurrentObj = true
68+
}
69+
if item.Label == "$Customer" {
70+
foundCustomer = true
71+
}
72+
}
73+
if !foundCurrentObj {
74+
t.Error("expected $currentObject in completion items")
75+
}
76+
if !foundCustomer {
77+
t.Error("expected $Customer in completion items")
78+
}
79+
}

mdl/executor/cmd_pages_describe.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,8 @@ type rawWidget struct {
535535
DesignProperties []rawDesignProp
536536
// Explicit widget properties (for generic PLUGGABLEWIDGET output)
537537
ExplicitProperties []rawExplicitProp
538+
// Data container context: entity qualified name provided by this container
539+
EntityContext string
538540
// Full widget ID (e.g. "com.mendix.widget.custom.switch.Switch")
539541
WidgetID string
540542
// Pluggable Image widget properties

mdl/executor/cmd_pages_describe_output.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@ func formatWidgetProps(w io.Writer, prefix string, header string, props []string
109109
fmt.Fprintf(w, "%s)%s", prefix, suffix)
110110
}
111111

112+
// outputDataContainerContext writes a comment showing available variables inside a data container.
113+
// isList indicates list containers (DataGrid2, ListView, Gallery) where a selection variable is available.
114+
func outputDataContainerContext(w io.Writer, prefix string, widgetName string, entityRef string, isList bool) {
115+
if entityRef == "" {
116+
return
117+
}
118+
parts := []string{fmt.Sprintf("$currentObject (%s)", entityRef)}
119+
if isList && widgetName != "" {
120+
parts = append(parts, fmt.Sprintf("$%s (selection)", widgetName))
121+
}
122+
fmt.Fprintf(w, "%s-- Context: %s\n", prefix, strings.Join(parts, ", "))
123+
}
124+
112125
// outputWidgetMDLV3 outputs a widget in MDL V3 syntax.
113126
// V3 syntax uses WIDGET Name (Props) { children } format.
114127
func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
@@ -255,6 +268,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
255268
}
256269
props = appendAppearanceProps(props, w)
257270
formatWidgetProps(e.output, prefix, header, props, " {\n")
271+
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, false)
258272
for _, child := range w.Children {
259273
e.outputWidgetMDLV3(child, indent+1)
260274
}
@@ -377,6 +391,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
377391
hasContent := len(w.ControlBar) > 0 || len(w.DataGridColumns) > 0
378392
if hasContent {
379393
formatWidgetProps(e.output, prefix, header, props, " {\n")
394+
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
380395
// Output CONTROLBAR section if control bar widgets present
381396
if len(w.ControlBar) > 0 {
382397
fmt.Fprintf(e.output, "%s CONTROLBAR controlBar1 {\n", prefix)
@@ -441,6 +456,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
441456
hasContent := len(w.Children) > 0 || len(w.FilterWidgets) > 0
442457
if hasContent {
443458
formatWidgetProps(e.output, prefix, header, props, " {\n")
459+
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
444460
// Output FILTER section if filter widgets present
445461
if len(w.FilterWidgets) > 0 {
446462
fmt.Fprintf(e.output, "%s FILTER filter1 {\n", prefix)
@@ -598,6 +614,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
598614
props = appendAppearanceProps(props, w)
599615
if len(w.Children) > 0 {
600616
formatWidgetProps(e.output, prefix, header, props, " {\n")
617+
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
601618
for _, child := range w.Children {
602619
e.outputWidgetMDLV3(child, indent+1)
603620
}
@@ -649,6 +666,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
649666
props = appendAppearanceProps(props, w)
650667
if len(w.Children) > 0 {
651668
formatWidgetProps(e.output, prefix, header, props, " {\n")
669+
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
652670
for _, child := range w.Children {
653671
e.outputWidgetMDLV3(child, indent+1)
654672
}

mdl/executor/cmd_pages_describe_parse.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
123123
case "Forms$DataView", "Pages$DataView":
124124
widget.Children = e.parseDataViewChildren(w)
125125
widget.DataSource = e.extractDataViewDataSource(w)
126+
if widget.DataSource != nil && widget.DataSource.Reference != "" {
127+
widget.EntityContext = widget.DataSource.Reference
128+
}
126129
return []rawWidget{widget}
127130

128131
case "Forms$TextBox", "Pages$TextBox":
@@ -180,6 +183,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
180183
widget.ShowPagingButtons = e.extractCustomWidgetPropertyString(w, "showPagingButtons")
181184
// showNumberOfRows: not yet fully supported in DataGrid2, skip to avoid CE0463
182185
widget.Selection = e.extractGallerySelection(w)
186+
if widget.DataSource != nil && widget.DataSource.Reference != "" {
187+
widget.EntityContext = widget.DataSource.Reference
188+
}
183189
}
184190
// For Gallery, extract datasource, content widgets, filter widgets, and selection mode
185191
if widget.RenderMode == "GALLERY" {
@@ -190,6 +196,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
190196
widget.DesktopColumns = e.extractCustomWidgetPropertyString(w, "desktopItems")
191197
widget.TabletColumns = e.extractCustomWidgetPropertyString(w, "tabletItems")
192198
widget.PhoneColumns = e.extractCustomWidgetPropertyString(w, "phoneItems")
199+
if widget.DataSource != nil && widget.DataSource.Reference != "" {
200+
widget.EntityContext = widget.DataSource.Reference
201+
}
193202
}
194203
// For filter widgets, extract filter attributes and expression
195204
if widget.RenderMode == "TEXTFILTER" || widget.RenderMode == "NUMBERFILTER" || widget.RenderMode == "DROPDOWNFILTER" || widget.RenderMode == "DATEFILTER" {
@@ -218,6 +227,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
218227
case "Forms$Gallery", "Pages$Gallery":
219228
widget.Children = e.parseGalleryContent(w)
220229
widget.DataSource = e.extractGalleryDataSource(w)
230+
if widget.DataSource != nil && widget.DataSource.Reference != "" {
231+
widget.EntityContext = widget.DataSource.Reference
232+
}
221233
return []rawWidget{widget}
222234

223235
case "Forms$SnippetCallWidget", "Pages$SnippetCallWidget":
@@ -227,6 +239,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
227239
case "Forms$ListView", "Pages$ListView":
228240
widget.Children = e.parseListViewContent(w)
229241
widget.DataSource = e.extractListViewDataSource(w)
242+
if widget.DataSource != nil && widget.DataSource.Reference != "" {
243+
widget.EntityContext = widget.DataSource.Reference
244+
}
230245
return []rawWidget{widget}
231246

232247
default:
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Tests for data container context hints (upstream issue #123).
4+
package executor
5+
6+
import (
7+
"bytes"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestOutputDataContainerContext_DataView(t *testing.T) {
13+
var buf bytes.Buffer
14+
outputDataContainerContext(&buf, " ", "dvOrder", "OrderManagement.PurchaseOrder", false)
15+
got := buf.String()
16+
expected := " -- Context: $currentObject (OrderManagement.PurchaseOrder)\n"
17+
if got != expected {
18+
t.Errorf("DataView context:\ngot: %q\nwant: %q", got, expected)
19+
}
20+
}
21+
22+
func TestOutputDataContainerContext_ListContainer(t *testing.T) {
23+
var buf bytes.Buffer
24+
outputDataContainerContext(&buf, " ", "dgOrders", "OrderManagement.PurchaseOrder", true)
25+
got := buf.String()
26+
expected := " -- Context: $currentObject (OrderManagement.PurchaseOrder), $dgOrders (selection)\n"
27+
if got != expected {
28+
t.Errorf("List container context:\ngot: %q\nwant: %q", got, expected)
29+
}
30+
}
31+
32+
func TestOutputDataContainerContext_EmptyEntity(t *testing.T) {
33+
var buf bytes.Buffer
34+
outputDataContainerContext(&buf, " ", "dv1", "", false)
35+
got := buf.String()
36+
if got != "" {
37+
t.Errorf("Expected no output for empty entity, got: %q", got)
38+
}
39+
}
40+
41+
func TestOutputDataContainerContext_ListNoName(t *testing.T) {
42+
var buf bytes.Buffer
43+
outputDataContainerContext(&buf, " ", "", "Module.Entity", true)
44+
got := buf.String()
45+
// Should only show $currentObject, no selection variable when widget has no name
46+
expected := " -- Context: $currentObject (Module.Entity)\n"
47+
if got != expected {
48+
t.Errorf("List container without name:\ngot: %q\nwant: %q", got, expected)
49+
}
50+
}
51+
52+
func TestOutputWidgetMDLV3_DataViewWithContext(t *testing.T) {
53+
buf := &bytes.Buffer{}
54+
e := New(buf)
55+
w := rawWidget{
56+
Type: "Forms$DataView",
57+
Name: "dvOrder",
58+
EntityContext: "OrderManagement.PurchaseOrder",
59+
DataSource: &rawDataSource{Type: "parameter", Reference: "Order"},
60+
Children: []rawWidget{
61+
{Type: "Forms$TextBox", Name: "txtName", Content: "Name"},
62+
},
63+
}
64+
e.outputWidgetMDLV3(w, 0)
65+
got := buf.String()
66+
if !strings.Contains(got, "-- Context: $currentObject (OrderManagement.PurchaseOrder)") {
67+
t.Errorf("DataView output should contain context comment, got:\n%s", got)
68+
}
69+
}
70+
71+
func TestOutputWidgetMDLV3_ListViewWithContext(t *testing.T) {
72+
buf := &bytes.Buffer{}
73+
e := New(buf)
74+
w := rawWidget{
75+
Type: "Forms$ListView",
76+
Name: "lvItems",
77+
EntityContext: "Module.Item",
78+
DataSource: &rawDataSource{Type: "database", Reference: "Module.Item"},
79+
Children: []rawWidget{
80+
{Type: "Forms$TextBox", Name: "txtDesc", Content: "Description"},
81+
},
82+
}
83+
e.outputWidgetMDLV3(w, 0)
84+
got := buf.String()
85+
if !strings.Contains(got, "-- Context: $currentObject (Module.Item), $lvItems (selection)") {
86+
t.Errorf("ListView output should contain context comment with selection, got:\n%s", got)
87+
}
88+
}

0 commit comments

Comments
 (0)