Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions cmd/mxcli/lsp_completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ func (s *mdlServer) Completion(ctx context.Context, params *protocol.CompletionP
}
linePrefixUpper := strings.ToUpper(linePrefix)

// Check if typing a $ variable reference inside page/snippet context
if strings.Contains(linePrefix, "$") {
varItems := s.variableCompletionItems(text, linePrefix)
if len(varItems) > 0 {
return &protocol.CompletionList{
IsIncomplete: false,
Items: varItems,
}, nil
}
}

// Check if context calls for catalog-based element completion
if types := inferCompletionTypes(linePrefixUpper); types != nil {
items := s.catalogCompletionItems(ctx, linePrefix, types)
Expand Down Expand Up @@ -360,3 +371,86 @@ func objectTypeToCompletionKind(objectType string) (protocol.CompletionItemKind,
return protocol.CompletionItemKindValue, objectType
}
}

// variableCompletionItems returns completion items for $ variable references.
// It suggests $currentObject (common in data containers) and any page parameters
// found in the document's CREATE PAGE Params declaration.
func (s *mdlServer) variableCompletionItems(docText string, linePrefix string) []protocol.CompletionItem {
// Extract the partial after the last $ to filter suggestions
lastDollar := strings.LastIndex(linePrefix, "$")
partial := ""
if lastDollar >= 0 && lastDollar < len(linePrefix)-1 {
partial = strings.ToUpper(linePrefix[lastDollar+1:])
}

var items []protocol.CompletionItem

// Always suggest $currentObject — it's the most common data container variable
if partial == "" || strings.HasPrefix("CURRENTOBJECT", partial) {
items = append(items, protocol.CompletionItem{
Label: "$currentObject",
Kind: protocol.CompletionItemKindVariable,
Detail: "Current object from enclosing data container",
})
}

// Extract page parameter names from CREATE PAGE ... Params: { $Name: Type, ... }
paramNames := extractPageParamNames(docText)
for _, name := range paramNames {
if partial == "" || strings.HasPrefix(strings.ToUpper(name), partial) {
items = append(items, protocol.CompletionItem{
Label: "$" + name,
Kind: protocol.CompletionItemKindVariable,
Detail: "Page parameter",
})
}
}

return items
}

// extractPageParamNames extracts parameter names from CREATE PAGE ... Params: { $Name: Type } declarations.
func extractPageParamNames(text string) []string {
var names []string
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
// Look for $ParamName patterns in Params declarations
// Format: Params: { $Name: Type } or $Name: Type on separate lines
idx := 0
for idx < len(trimmed) {
dollar := strings.Index(trimmed[idx:], "$")
if dollar < 0 {
break
}
dollar += idx
// Extract the name after $
end := dollar + 1
for end < len(trimmed) {
c := trimmed[end]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
end++
} else {
break
}
}
if end > dollar+1 {
name := trimmed[dollar+1 : end]
// Skip if this looks like a variable declaration (DECLARE) rather than a param
if !strings.HasPrefix(strings.ToUpper(trimmed), "DECLARE") {
names = append(names, name)
}
}
idx = end
}
}
// Deduplicate
seen := make(map[string]bool)
var unique []string
for _, n := range names {
if !seen[n] {
seen[n] = true
unique = append(unique, n)
}
}
return unique
}
79 changes: 79 additions & 0 deletions cmd/mxcli/lsp_completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"testing"
)

func TestExtractPageParamNames(t *testing.T) {
tests := []struct {
name string
text string
expected []string
}{
{
name: "single param",
text: "CREATE PAGE Mod.Page (Params: { $Order: Mod.Order })",
expected: []string{"Order"},
},
{
name: "multiple params",
text: "CREATE PAGE Mod.Page (\n Params: { $Customer: Mod.Customer, $Helper: Mod.Helper }\n)",
expected: []string{"Customer", "Helper"},
},
{
name: "no params",
text: "CREATE PAGE Mod.Page (Title: 'Test')",
expected: nil,
},
{
name: "skip DECLARE variables",
text: "DECLARE $Temp String = '';\n$Order: Mod.Order",
expected: []string{"Order"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPageParamNames(tt.text)
if len(got) != len(tt.expected) {
t.Errorf("extractPageParamNames() got %v, want %v", got, tt.expected)
return
}
for i, name := range got {
if name != tt.expected[i] {
t.Errorf("extractPageParamNames()[%d] = %q, want %q", i, name, tt.expected[i])
}
}
})
}
}

func TestVariableCompletionItems(t *testing.T) {
s := &mdlServer{}
docText := "CREATE PAGE Mod.Page (\n Params: { $Customer: Mod.Customer }\n) {\n DATAVIEW dv1 (DataSource: $Customer) {\n"

items := s.variableCompletionItems(docText, "$")
if len(items) == 0 {
t.Fatal("expected completion items for $ prefix")
}

// Should contain $currentObject
foundCurrentObj := false
foundCustomer := false
for _, item := range items {
if item.Label == "$currentObject" {
foundCurrentObj = true
}
if item.Label == "$Customer" {
foundCustomer = true
}
}
if !foundCurrentObj {
t.Error("expected $currentObject in completion items")
}
if !foundCustomer {
t.Error("expected $Customer in completion items")
}
}
2 changes: 2 additions & 0 deletions mdl/executor/cmd_pages_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,8 @@ type rawWidget struct {
DesignProperties []rawDesignProp
// Explicit widget properties (for generic PLUGGABLEWIDGET output)
ExplicitProperties []rawExplicitProp
// Data container context: entity qualified name provided by this container
EntityContext string
// Full widget ID (e.g. "com.mendix.widget.custom.switch.Switch")
WidgetID string
// Pluggable Image widget properties
Expand Down
18 changes: 18 additions & 0 deletions mdl/executor/cmd_pages_describe_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ func formatWidgetProps(w io.Writer, prefix string, header string, props []string
fmt.Fprintf(w, "%s)%s", prefix, suffix)
}

// outputDataContainerContext writes a comment showing available variables inside a data container.
// isList indicates list containers (DataGrid2, ListView, Gallery) where a selection variable is available.
func outputDataContainerContext(w io.Writer, prefix string, widgetName string, entityRef string, isList bool) {
if entityRef == "" {
return
}
parts := []string{fmt.Sprintf("$currentObject (%s)", entityRef)}
if isList && widgetName != "" {
parts = append(parts, fmt.Sprintf("$%s (selection)", widgetName))
}
fmt.Fprintf(w, "%s-- Context: %s\n", prefix, strings.Join(parts, ", "))
}

// outputWidgetMDLV3 outputs a widget in MDL V3 syntax.
// V3 syntax uses WIDGET Name (Props) { children } format.
func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
Expand Down Expand Up @@ -255,6 +268,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
}
props = appendAppearanceProps(props, w)
formatWidgetProps(e.output, prefix, header, props, " {\n")
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, false)
for _, child := range w.Children {
e.outputWidgetMDLV3(child, indent+1)
}
Expand Down Expand Up @@ -377,6 +391,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
hasContent := len(w.ControlBar) > 0 || len(w.DataGridColumns) > 0
if hasContent {
formatWidgetProps(e.output, prefix, header, props, " {\n")
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
// Output CONTROLBAR section if control bar widgets present
if len(w.ControlBar) > 0 {
fmt.Fprintf(e.output, "%s CONTROLBAR controlBar1 {\n", prefix)
Expand Down Expand Up @@ -441,6 +456,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
hasContent := len(w.Children) > 0 || len(w.FilterWidgets) > 0
if hasContent {
formatWidgetProps(e.output, prefix, header, props, " {\n")
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
// Output FILTER section if filter widgets present
if len(w.FilterWidgets) > 0 {
fmt.Fprintf(e.output, "%s FILTER filter1 {\n", prefix)
Expand Down Expand Up @@ -598,6 +614,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
props = appendAppearanceProps(props, w)
if len(w.Children) > 0 {
formatWidgetProps(e.output, prefix, header, props, " {\n")
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
for _, child := range w.Children {
e.outputWidgetMDLV3(child, indent+1)
}
Expand Down Expand Up @@ -649,6 +666,7 @@ func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) {
props = appendAppearanceProps(props, w)
if len(w.Children) > 0 {
formatWidgetProps(e.output, prefix, header, props, " {\n")
outputDataContainerContext(e.output, prefix+" ", w.Name, w.EntityContext, true)
for _, child := range w.Children {
e.outputWidgetMDLV3(child, indent+1)
}
Expand Down
15 changes: 15 additions & 0 deletions mdl/executor/cmd_pages_describe_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
case "Forms$DataView", "Pages$DataView":
widget.Children = e.parseDataViewChildren(w)
widget.DataSource = e.extractDataViewDataSource(w)
if widget.DataSource != nil && widget.DataSource.Reference != "" {
widget.EntityContext = widget.DataSource.Reference
}
return []rawWidget{widget}

case "Forms$TextBox", "Pages$TextBox":
Expand Down Expand Up @@ -180,6 +183,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
widget.ShowPagingButtons = e.extractCustomWidgetPropertyString(w, "showPagingButtons")
// showNumberOfRows: not yet fully supported in DataGrid2, skip to avoid CE0463
widget.Selection = e.extractGallerySelection(w)
if widget.DataSource != nil && widget.DataSource.Reference != "" {
widget.EntityContext = widget.DataSource.Reference
}
}
// For Gallery, extract datasource, content widgets, filter widgets, and selection mode
if widget.RenderMode == "GALLERY" {
Expand All @@ -190,6 +196,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
widget.DesktopColumns = e.extractCustomWidgetPropertyString(w, "desktopItems")
widget.TabletColumns = e.extractCustomWidgetPropertyString(w, "tabletItems")
widget.PhoneColumns = e.extractCustomWidgetPropertyString(w, "phoneItems")
if widget.DataSource != nil && widget.DataSource.Reference != "" {
widget.EntityContext = widget.DataSource.Reference
}
}
// For filter widgets, extract filter attributes and expression
if widget.RenderMode == "TEXTFILTER" || widget.RenderMode == "NUMBERFILTER" || widget.RenderMode == "DROPDOWNFILTER" || widget.RenderMode == "DATEFILTER" {
Expand Down Expand Up @@ -218,6 +227,9 @@ func (e *Executor) parseRawWidget(w map[string]any) []rawWidget {
case "Forms$Gallery", "Pages$Gallery":
widget.Children = e.parseGalleryContent(w)
widget.DataSource = e.extractGalleryDataSource(w)
if widget.DataSource != nil && widget.DataSource.Reference != "" {
widget.EntityContext = widget.DataSource.Reference
}
return []rawWidget{widget}

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

default:
Expand Down
88 changes: 88 additions & 0 deletions mdl/executor/data_container_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0

// Tests for data container context hints (upstream issue #123).
package executor

import (
"bytes"
"strings"
"testing"
)

func TestOutputDataContainerContext_DataView(t *testing.T) {
var buf bytes.Buffer
outputDataContainerContext(&buf, " ", "dvOrder", "OrderManagement.PurchaseOrder", false)
got := buf.String()
expected := " -- Context: $currentObject (OrderManagement.PurchaseOrder)\n"
if got != expected {
t.Errorf("DataView context:\ngot: %q\nwant: %q", got, expected)
}
}

func TestOutputDataContainerContext_ListContainer(t *testing.T) {
var buf bytes.Buffer
outputDataContainerContext(&buf, " ", "dgOrders", "OrderManagement.PurchaseOrder", true)
got := buf.String()
expected := " -- Context: $currentObject (OrderManagement.PurchaseOrder), $dgOrders (selection)\n"
if got != expected {
t.Errorf("List container context:\ngot: %q\nwant: %q", got, expected)
}
}

func TestOutputDataContainerContext_EmptyEntity(t *testing.T) {
var buf bytes.Buffer
outputDataContainerContext(&buf, " ", "dv1", "", false)
got := buf.String()
if got != "" {
t.Errorf("Expected no output for empty entity, got: %q", got)
}
}

func TestOutputDataContainerContext_ListNoName(t *testing.T) {
var buf bytes.Buffer
outputDataContainerContext(&buf, " ", "", "Module.Entity", true)
got := buf.String()
// Should only show $currentObject, no selection variable when widget has no name
expected := " -- Context: $currentObject (Module.Entity)\n"
if got != expected {
t.Errorf("List container without name:\ngot: %q\nwant: %q", got, expected)
}
}

func TestOutputWidgetMDLV3_DataViewWithContext(t *testing.T) {
buf := &bytes.Buffer{}
e := New(buf)
w := rawWidget{
Type: "Forms$DataView",
Name: "dvOrder",
EntityContext: "OrderManagement.PurchaseOrder",
DataSource: &rawDataSource{Type: "parameter", Reference: "Order"},
Children: []rawWidget{
{Type: "Forms$TextBox", Name: "txtName", Content: "Name"},
},
}
e.outputWidgetMDLV3(w, 0)
got := buf.String()
if !strings.Contains(got, "-- Context: $currentObject (OrderManagement.PurchaseOrder)") {
t.Errorf("DataView output should contain context comment, got:\n%s", got)
}
}

func TestOutputWidgetMDLV3_ListViewWithContext(t *testing.T) {
buf := &bytes.Buffer{}
e := New(buf)
w := rawWidget{
Type: "Forms$ListView",
Name: "lvItems",
EntityContext: "Module.Item",
DataSource: &rawDataSource{Type: "database", Reference: "Module.Item"},
Children: []rawWidget{
{Type: "Forms$TextBox", Name: "txtDesc", Content: "Description"},
},
}
e.outputWidgetMDLV3(w, 0)
got := buf.String()
if !strings.Contains(got, "-- Context: $currentObject (Module.Item), $lvItems (selection)") {
t.Errorf("ListView output should contain context comment with selection, got:\n%s", got)
}
}
Loading