Skip to content
Open
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
86 changes: 86 additions & 0 deletions cli/completer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package cli

import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -389,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))
Expand Down
218 changes: 218 additions & 0 deletions cli/completer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
package cli

import (
"reflect"
"strings"
"testing"

"github.com/apache/cloudstack-cloudmonkey/config"
Expand Down Expand Up @@ -221,3 +223,219 @@ 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)
}
}

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)
}
}