diff --git a/cli/completer.go b/cli/completer.go index 9547f5a..0add1b3 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 @@ -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)) diff --git a/cli/completer_test.go b/cli/completer_test.go index 0aa73df..e23b5ee 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,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) + } +} diff --git a/vendor/github.com/ergochat/readline/complete.go b/vendor/github.com/ergochat/readline/complete.go index d8dce8c..1214820 100644 --- a/vendor/github.com/ergochat/readline/complete.go +++ b/vendor/github.com/ergochat/readline/complete.go @@ -47,13 +47,20 @@ func newOpCompleter(w *terminal, op *operation) *opCompleter { } } -func (o *opCompleter) truncateBufferAfterLastEqual(completion []rune) { +func (o *opCompleter) truncateBufferAfterLastEqual(completion []rune, replaceTrailingEqual bool) { bufRunes := o.op.buf.Runes() for i := len(bufRunes) - 1; i >= 0; i-- { if bufRunes[i] == '=' { - prefix := bufRunes[i+1:] // part after '=' in buffer - if len(prefix) > 0 && len(completion) >= len(prefix) && string(completion[:len(prefix)]) == string(prefix) { - o.op.buf.Set(bufRunes[:i+1]) // Keep content till '=' + if replaceTrailingEqual { + if i == len(bufRunes)-1 && len(completion) > 0 && completion[0] == '[' { + o.op.buf.Set(bufRunes[:i]) + } + } else { + prefix := bufRunes[i+1:] + if len(prefix) > 0 && len(completion) >= len(prefix) && + string(completion[:len(prefix)]) == string(prefix) { + o.op.buf.Set(bufRunes[:i+1]) + } } break } @@ -68,7 +75,7 @@ func (o *opCompleter) writeRunes(candidate []rune) { spaceFound = true } if spaceFound && r == '(' { - o.truncateBufferAfterLastEqual(candidate[idx+1:]) + o.truncateBufferAfterLastEqual(candidate[idx+1:], false) selected = candidate[:idx] break } @@ -262,6 +269,7 @@ func (o *opCompleter) OnComplete() (ringBell bool) { if !o.IsInCompleteMode() { if len(newLines) == 1 { // not yet in complete mode but only 1 candidate so complete it + o.truncateBufferAfterLastEqual(newLines[0], true) o.writeRunes(newLines[0]) o.ExitCompleteMode(false) return true @@ -270,6 +278,7 @@ func (o *opCompleter) OnComplete() (ringBell bool) { // check if all candidates have common prefix and return it and its size same, size := runes.Aggregate(newLines) if size > 0 { + o.truncateBufferAfterLastEqual(same, true) buf.WriteRunes(same) o.ExitCompleteMode(false) return false // partial completion so ring the bell diff --git a/vendor/github.com/ergochat/readline/complete_test.go b/vendor/github.com/ergochat/readline/complete_test.go new file mode 100644 index 0000000..08fc563 --- /dev/null +++ b/vendor/github.com/ergochat/readline/complete_test.go @@ -0,0 +1,48 @@ +package readline + +import ( + "testing" +) + +type mapCompletionTestCompleter struct{} + +func (mapCompletionTestCompleter) Do(_ []rune, _ int) ([][]rune, int) { + return [][]rune{ + []rune("[0].key="), + []rune("[0].value="), + }, 0 +} + +func TestMapCompletionReplacesTrailingEqual(t *testing.T) { + initial := "list virtualmachines tags=" + + cfg := &Config{ + AutoComplete: mapCompletionTestCompleter{}, + } + + term := &terminal{} + term.cfg.Store(cfg) + term.dimensions.Store(&termDimensions{ + width: 80, + height: 24, + }) + + op := &operation{ + t: term, + buf: &runeBuffer{ + buf: []rune(initial), + idx: len([]rune(initial)), + w: term, + }, + } + op.completer = newOpCompleter(term, op) + + op.completer.OnComplete() + + got := string(op.buf.Runes()) + want := "list virtualmachines tags[0]." + + if got != want { + t.Fatalf("after Tab, expected %q, got %q", want, got) + } +}