From 86a757d44ee81488dfea2d1e2abab379d20e1cc5 Mon Sep 17 00:00:00 2001 From: dheeraj12347 Date: Sun, 4 Oct 2026 04:04:56 +0000 Subject: [PATCH 1/2] Add map argument autocomplete --- cli/completer.go | 61 +++++++++++++++ cli/completer_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) diff --git a/cli/completer.go b/cli/completer.go index 9547f5a..1f450a7 100644 --- a/cli/completer.go +++ b/cli/completer.go @@ -19,6 +19,7 @@ package cli import ( "fmt" + "regexp" "sort" "strconv" "strings" @@ -224,6 +225,53 @@ func pluralizeNoun(noun string) string { } } +func mapFieldsFromDescription(description string) []string { + fields := []string{} + seen := make(map[string]bool) + + re := regexp.MustCompile(`([A-Za-z0-9_-]+\[\d+\]\.[A-Za-z0-9_.:-]+)=`) + + matches := re.FindAllStringSubmatch(description, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + + field := match[1] + "=" + if seen[field] { + continue + } + + seen[field] = true + fields = append(fields, field) + } + + return fields +} + +func mapFieldSuggestions(arg *config.APIArg) []string { + if arg == nil || arg.Type != "map" { + return nil + } + + fields := mapFieldsFromDescription(arg.Description) + if len(fields) > 0 { + return fields + } + + description := strings.ToLower(arg.Description) + if strings.Contains(description, "key/value") || + strings.Contains(description, "key-value") { + mapName := strings.TrimSuffix(arg.Name, "=") + return []string{ + mapName + "[0].key=", + mapName + "[0].value=", + } + } + + return nil +} + func findAutocompleteAPI(arg *config.APIArg, apiFound *config.API, apiMap map[string][]*config.API) *config.API { if arg.Type == "map" { return nil @@ -409,6 +457,19 @@ func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) return } + if arg.Type == "map" { + suggestions := mapFieldSuggestions(arg) + + for _, suggestion := range suggestions { + if strings.HasPrefix(suggestion, string(line)) { + options = append(options, []rune(suggestion[len(line):])) + } + } + + offset = len(line) + return + } + if arg.Type == config.FAKE && arg.Name == "filter=" { offset = 0 filterInputs := strings.Split(strings.Replace(argInput, ",", ",|", -1), "|") diff --git a/cli/completer_test.go b/cli/completer_test.go index 0aa73df..98b674e 100644 --- a/cli/completer_test.go +++ b/cli/completer_test.go @@ -18,6 +18,8 @@ package cli import ( + "reflect" + "strings" "testing" "github.com/apache/cloudstack-cloudmonkey/config" @@ -221,3 +223,169 @@ func TestFindAutocompleteAPIHeuristicWinsOverRelated(t *testing.T) { t.Fatalf("expected listProjects, got %s", result.Name) } } + +func TestMapFieldsFromDescription(t *testing.T) { + description := "Example: tags[0].key=value&tags[0].value=value" + + fields := mapFieldsFromDescription(description) + + expected := []string{ + "tags[0].key=", + "tags[0].value=", + } + + if len(fields) != len(expected) { + t.Fatalf("expected %d fields, got %d: %v", len(expected), len(fields), fields) + } + + for i := range expected { + if fields[i] != expected[i] { + t.Errorf("expected %q, got %q", expected[i], fields[i]) + } + } +} + +func TestMapFieldsFromDescriptionWithRealMap(t *testing.T) { + description := "VMware only: used to specify network mapping of a vApp VMware template registered \"as-is\". Example nicnetworklist[0].ip=Nic-101&nicnetworklist[0].network=uuid" + + fields := mapFieldsFromDescription(description) + + expected := []string{ + "nicnetworklist[0].ip=", + "nicnetworklist[0].network=", + } + + if len(fields) != len(expected) { + t.Fatalf("expected %d fields, got %d: %v", len(expected), len(fields), fields) + } + + for i := range expected { + if fields[i] != expected[i] { + t.Errorf("expected %q, got %q", expected[i], fields[i]) + } + } +} + +func TestMapFieldsFromDescriptionWithMultipleFields(t *testing.T) { + description := "Disk offering details. Example: datadisksdetails[0].diskofferingid=id&datadisksdetails[0].size=10&datadisksdetails[0].miniops=100&datadisksdetails[0].maxiops=200" + + fields := mapFieldsFromDescription(description) + + expected := []string{ + "datadisksdetails[0].diskofferingid=", + "datadisksdetails[0].size=", + "datadisksdetails[0].miniops=", + "datadisksdetails[0].maxiops=", + } + + if len(fields) != len(expected) { + t.Fatalf("expected %d fields, got %d: %v", len(expected), len(fields), fields) + } + + for i := range expected { + if fields[i] != expected[i] { + t.Errorf("expected %q, got %q", expected[i], fields[i]) + } + } +} + +func TestMapFieldSuggestions(t *testing.T) { + arg := &config.APIArg{ + Name: "tags=", + Type: "map", + Description: "Example: tags[0].key=value&tags[0].value=value", + } + + suggestions := mapFieldSuggestions(arg) + + expected := []string{ + "tags[0].key=", + "tags[0].value=", + } + + if len(suggestions) != len(expected) { + t.Fatalf("expected %d suggestions, got %d: %v", len(expected), len(suggestions), suggestions) + } + + for i := range expected { + if suggestions[i] != expected[i] { + t.Errorf("expected %q, got %q", expected[i], suggestions[i]) + } + } +} + +func TestMapFieldSuggestionsWithGenericMap(t *testing.T) { + arg := &config.APIArg{ + Name: "tags", + Type: "map", + Description: "List resources by tags (key/value pairs)", + } + + expected := []string{ + "tags[0].key=", + "tags[0].value=", + } + + got := mapFieldSuggestions(arg) + + if !reflect.DeepEqual(got, expected) { + t.Fatalf("expected %v, got %v", expected, got) + } +} + +func TestMapAutocompleteOptions(t *testing.T) { + arg := &config.APIArg{ + Name: "tags", + Type: "map", + Description: "List resources by tags (key/value pairs)", + } + + line := "tags" + + suggestions := mapFieldSuggestions(arg) + var options [][]rune + + for _, suggestion := range suggestions { + if strings.HasPrefix(suggestion, line) { + options = append(options, []rune(suggestion[len(line):])) + } + } + + expected := [][]rune{ + []rune("[0].key="), + []rune("[0].value="), + } + + if !reflect.DeepEqual(options, expected) { + t.Fatalf("expected %v, got %v", expected, options) + } +} + +func TestMapFieldsFromDescriptionWithMultipleIndexes(t *testing.T) { + description := "Example: details[0].key=accesskey&details[0].value=value&details[1].key=secretkey&details[1].value=value" + + expected := []string{ + "details[0].key=", + "details[0].value=", + "details[1].key=", + "details[1].value=", + } + + fields := mapFieldsFromDescription(description) + + if !reflect.DeepEqual(fields, expected) { + t.Fatalf("expected %v, got %v", expected, fields) + } +} + +func TestMapFieldSuggestionsWithNonKeyValueMap(t *testing.T) { + arg := &config.APIArg{ + Name: "storagecapabilities", + Type: "map", + Description: "the storage pool capabilities", + } + + if got := mapFieldSuggestions(arg); got != nil { + t.Fatalf("expected no suggestions, got %v", got) + } +} From c79972f6027b686221f6f2b9dc603d85e2edbf7a Mon Sep 17 00:00:00 2001 From: dheeraj12347 Date: Sat, 10 Oct 2026 14:09:01 +0000 Subject: [PATCH 2/2] Fix map field autocomplete dispatch --- cli/completer.go | 51 ++++++++++++++++++++++++++++++++----------- cli/completer_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/cli/completer.go b/cli/completer.go index 1f450a7..0add1b3 100644 --- a/cli/completer.go +++ b/cli/completer.go @@ -437,7 +437,45 @@ func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) // Auto-complete API arg splitLine := strings.Split(string(line), " ") line = trimSpaceLeft([]rune(splitLine[len(splitLine)-1])) + for _, arg := range apiFound.Args { + + if arg.Type == "map" { + suggestions := mapFieldSuggestions(arg) + input := string(line) + mapName := strings.TrimSuffix(arg.Name, "=") + + matchInput := input + matchOffset := len(line) + + // Replace the trailing '=' in tags= with the indexed field. + if strings.HasSuffix(input, "=") && + strings.TrimSuffix(input, "=") == mapName { + matchInput = mapName + matchOffset-- + } + + if matchInput == mapName || + strings.HasPrefix(matchInput, mapName+"[") { + var mapOptions [][]rune + + for _, suggestion := range suggestions { + if strings.HasPrefix(suggestion, matchInput) { + mapOptions = append( + mapOptions, + []rune(suggestion[len(matchInput):]), + ) + } + } + + if len(mapOptions) > 0 { + options = append(options, mapOptions...) + offset = matchOffset + return + } + } + } + search := arg.Name if !hasPrefix(line, []rune(search)) { sLine, sOffset := doInternal(line, pos, len(line), []rune(search)) @@ -457,19 +495,6 @@ func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) return } - if arg.Type == "map" { - suggestions := mapFieldSuggestions(arg) - - for _, suggestion := range suggestions { - if strings.HasPrefix(suggestion, string(line)) { - options = append(options, []rune(suggestion[len(line):])) - } - } - - offset = len(line) - return - } - if arg.Type == config.FAKE && arg.Name == "filter=" { offset = 0 filterInputs := strings.Split(strings.Replace(argInput, ",", ",|", -1), "|") diff --git a/cli/completer_test.go b/cli/completer_test.go index 98b674e..e23b5ee 100644 --- a/cli/completer_test.go +++ b/cli/completer_test.go @@ -389,3 +389,53 @@ func TestMapFieldSuggestionsWithNonKeyValueMap(t *testing.T) { t.Fatalf("expected no suggestions, got %v", got) } } + +func TestMapAutocompleteThroughDo(t *testing.T) { + cfg := &config.Config{} + + // Restore the normal API cache after this test. + defer config.LoadCache(cfg) + + cfg.UpdateCache(map[string]interface{}{ + "count": 1, + "api": []interface{}{ + map[string]interface{}{ + "name": "listVirtualMachines", + "isasync": false, + "description": "List virtual machines", + "params": []interface{}{ + map[string]interface{}{ + "name": "tags", + "type": "map", + "required": false, + "description": "List resources by tags (key/value pairs)", + }, + }, + "response": []interface{}{}, + }, + }, + }) + + completer := &autoCompleter{Config: cfg} + line := []rune("list virtualmachines tags=") + + options, offset := completer.Do(line, len(line)) + + var got []string + for _, option := range options { + got = append(got, string(option)) + } + + expected := []string{ + "[0].key=", + "[0].value=", + } + + if !reflect.DeepEqual(got, expected) { + t.Fatalf("expected suggestions %v, got %v", expected, got) + } + + if offset != len("tags=")-1 { + t.Fatalf("expected offset %d, got %d", len("tags=")-1, offset) + } +}