You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cloudstack.apache.org by ro...@apache.org on 2018/04/12 23:13:45 UTC

[cloudstack-cloudmonkey] branch master updated: cli: implement auto-completion for apis

This is an automated email from the ASF dual-hosted git repository.

rohit pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cloudstack-cloudmonkey.git


The following commit(s) were added to refs/heads/master by this push:
     new ff373cd  cli: implement auto-completion for apis
ff373cd is described below

commit ff373cd11187234c77f2ff2bfc68de2d82828433
Author: Rohit Yadav <ro...@apache.org>
AuthorDate: Fri Apr 13 04:43:09 2018 +0530

    cli: implement auto-completion for apis
    
    Signed-off-by: Rohit Yadav <ro...@apache.org>
---
 .gitignore       |   2 +-
 Makefile         |   2 +-
 cli/completer.go | 218 +++++++++++++++++++++++++++++++++++++++----------------
 cli/selector.go  |  47 ++++++++----
 cmd/api.go       |  29 ++++++++
 cmd/network.go   |   4 +-
 config/cache.go  |  69 ++++++++++++++----
 config/config.go |  43 +++++++++++
 config/util.go   |  67 -----------------
 9 files changed, 316 insertions(+), 165 deletions(-)

diff --git a/.gitignore b/.gitignore
index 0cbfde4..67cb950 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,5 +21,5 @@ dist
 *.exe
 *.test
 *.out
-.gopath~
+.gopath
 .idea
diff --git a/Makefile b/Makefile
index 41184d9..d32b6be 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ PACKAGE  = cloudmonkey
 DATE    ?= $(shell date +%FT%T%z)
 VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2> /dev/null || \
 			cat $(CURDIR)/.version 2> /dev/null || echo v0)
-GOPATH   = $(CURDIR)/.gopath~
+GOPATH   = $(CURDIR)/.gopath
 BIN      = $(GOPATH)/bin
 BASE     = $(GOPATH)/src/$(PACKAGE)
 PKGS     = $(or $(PKG),$(shell cd $(BASE) && env GOPATH=$(GOPATH) $(GO) list ./... | grep -v "^$(PACKAGE)/vendor/"))
diff --git a/cli/completer.go b/cli/completer.go
index ba45391..1a4c0a2 100644
--- a/cli/completer.go
+++ b/cli/completer.go
@@ -35,7 +35,29 @@ type CliCompleter struct {
 
 var completer *CliCompleter
 
-func TrimSpaceLeft(in []rune) []rune {
+func buildApiCacheMap(apiMap map[string][]*config.Api) map[string][]*config.Api {
+	for _, cmd := range cmd.AllCommands() {
+		verb := cmd.Name
+		if cmd.SubCommands != nil && len(cmd.SubCommands) > 0 {
+			for _, scmd := range cmd.SubCommands {
+				dummyApi := &config.Api{
+					Name: scmd,
+					Verb: verb,
+				}
+				apiMap[verb] = append(apiMap[verb], dummyApi)
+			}
+		} else {
+			dummyApi := &config.Api{
+				Name: "",
+				Verb: verb,
+			}
+			apiMap[verb] = append(apiMap[verb], dummyApi)
+		}
+	}
+	return apiMap
+}
+
+func trimSpaceLeft(in []rune) []rune {
 	firstIndex := len(in)
 	for i, r := range in {
 		if unicode.IsSpace(r) == false {
@@ -65,36 +87,9 @@ func doInternal(line []rune, pos int, lineLen int, argName []rune) (newLine [][]
 	return
 }
 
-func (t *CliCompleter) Do(line []rune, pos int) (newLine [][]rune, offset int) {
-
-	line = TrimSpaceLeft(line[:pos])
-	lineLen := len(line)
+func (t *CliCompleter) Do(line []rune, pos int) (options [][]rune, offset int) {
 
-	apiCache := t.Config.GetCache()
-	apiMap := make(map[string][]*config.Api)
-	for api := range apiCache {
-		verb := apiCache[api].Verb
-		apiMap[verb] = append(apiMap[verb], apiCache[api])
-	}
-
-	for _, cmd := range cmd.AllCommands() {
-		verb := cmd.Name
-		if cmd.SubCommands != nil && len(cmd.SubCommands) > 0 {
-			for _, scmd := range cmd.SubCommands {
-				dummyApi := &config.Api{
-					Name: scmd,
-					Verb: verb,
-				}
-				apiMap[verb] = append(apiMap[verb], dummyApi)
-			}
-		} else {
-			dummyApi := &config.Api{
-				Name: "",
-				Verb: verb,
-			}
-			apiMap[verb] = append(apiMap[verb], dummyApi)
-		}
-	}
+	apiMap := buildApiCacheMap(t.Config.GetApiVerbMap())
 
 	var verbs []string
 	for verb := range apiMap {
@@ -105,56 +100,155 @@ func (t *CliCompleter) Do(line []rune, pos int) (newLine [][]rune, offset int) {
 	}
 	sort.Strings(verbs)
 
-	var verbsFound []string
+	line = trimSpaceLeft(line[:pos])
+
+	// Auto-complete verb
+	var verbFound string
 	for _, verb := range verbs {
 		search := verb + " "
 		if !runes.HasPrefix(line, []rune(search)) {
-			sLine, sOffset := doInternal(line, pos, lineLen, []rune(search))
-			newLine = append(newLine, sLine...)
+			sLine, sOffset := doInternal(line, pos, len(line), []rune(search))
+			options = append(options, sLine...)
 			offset = sOffset
 		} else {
-			verbsFound = append(verbsFound, verb)
+			verbFound = verb
+			break
 		}
 	}
+	if len(verbFound) == 0 {
+		return
+	}
 
-	apiArg := false
-	for _, verbFound := range verbsFound {
-		search := verbFound + " "
+	// Auto-complete noun
+	var nounFound string
+	line = trimSpaceLeft(line[len(verbFound):])
+	for _, api := range apiMap[verbFound] {
+		search := api.Noun + " "
+		if !runes.HasPrefix(line, []rune(search)) {
+			sLine, sOffset := doInternal(line, pos, len(line), []rune(search))
+			options = append(options, sLine...)
+			offset = sOffset
+		} else {
+			nounFound = api.Noun
+			break
+		}
+	}
+	if len(nounFound) == 0 {
+		return
+	}
 
-		nLine := TrimSpaceLeft(line[len(search):])
-		offset = lineLen - len(verbFound) - 1
+	// Find API
+	var apiFound *config.Api
+	for _, api := range apiMap[verbFound] {
+		if api.Noun == nounFound {
+			apiFound = api
+			break
+		}
+	}
+	if apiFound == nil {
+		return
+	}
 
-		for _, api := range apiMap[verbFound] {
-			resource := strings.TrimPrefix(strings.ToLower(api.Name), verbFound)
-			search = resource + " "
+	// Auto-complete api args
+	splitLine := strings.Split(string(line), " ")
+	line = trimSpaceLeft([]rune(splitLine[len(splitLine)-1]))
+	for _, arg := range apiFound.Args {
+		search := arg.Name + "="
+		if !runes.HasPrefix(line, []rune(search)) {
+			sLine, sOffset := doInternal(line, pos, len(line), []rune(search))
+			options = append(options, sLine...)
+			offset = sOffset
+		} else {
+			if arg.Type == "boolean" {
+				options = [][]rune{[]rune("true "), []rune("false ")}
+				offset = 0
+				return
+			}
+
+			var autocompleteApi *config.Api
+			var relatedNoun string
+			if arg.Name == "id" || arg.Name == "ids" {
+				relatedNoun = apiFound.Noun
+				if apiFound.Verb != "list" {
+					relatedNoun += "s"
+				}
+			} else if arg.Name == "account" {
+				relatedNoun = "accounts"
+			} else {
+				relatedNoun = strings.Replace(strings.Replace(arg.Name, "ids", "", -1), "id", "", -1) + "s"
+			}
+			for _, related := range apiMap["list"] {
+				if relatedNoun == related.Noun {
+					autocompleteApi = related
+					break
+				}
+			}
+
+			if autocompleteApi == nil {
+				return nil, 0
+			}
+
+			r := cmd.NewRequest(nil, config.NewConfig(), nil, nil)
+			autocompleteApiArgs := []string{"listall=true"}
+			if autocompleteApi.Noun == "templates" {
+				autocompleteApiArgs = append(autocompleteApiArgs, "templatefilter=all")
+			}
+			response, _ := cmd.NewAPIRequest(r, autocompleteApi.Name, autocompleteApiArgs)
 
-			if runes.HasPrefix(nLine, []rune(search)) {
-				// FIXME: handle params to API here with = stuff
-				for _, arg := range api.Args {
-					opt := arg.Name + "="
-					newLine = append(newLine, []rune(opt))
+			var autocompleteOptions []SelectOption
+			for _, v := range response {
+				switch obj := v.(type) {
+				case []interface{}:
+					if obj == nil {
+						break
+					}
+					for _, item := range obj {
+						resource, ok := item.(map[string]interface{})
+						if !ok {
+							continue
+						}
+						opt := SelectOption{}
+						if resource["id"] != nil {
+							opt.Id = resource["id"].(string)
+						}
+						if resource["name"] != nil {
+							opt.Name = resource["name"].(string)
+						} else if resource["username"] != nil {
+							opt.Name = resource["username"].(string)
+						}
+						if resource["displaytext"] != nil {
+							opt.Detail = resource["displaytext"].(string)
+						}
+
+						autocompleteOptions = append(autocompleteOptions, opt)
+					}
+					break
 				}
-				if string(nLine[len(nLine)-1]) == "=" {
-					apiArg = true
+			}
+
+			var selected string
+			if len(autocompleteOptions) > 1 {
+				sort.Slice(autocompleteOptions, func(i, j int) bool {
+					return autocompleteOptions[i].Name < autocompleteOptions[j].Name
+				})
+				fmt.Println()
+				selectedOption := ShowSelector(autocompleteOptions)
+				if arg.Name == "account" {
+					selected = selectedOption.Name
+				} else {
+					selected = selectedOption.Id
 				}
-				offset = lineLen - len(verbFound) - len(resource) - 1
 			} else {
-				sLine, _ := doInternal(nLine, pos, len(nLine), []rune(search))
-				newLine = append(newLine, sLine...)
+				if len(autocompleteOptions) == 1 {
+					selected = autocompleteOptions[0].Id
+				}
 			}
+			options = [][]rune{[]rune(selected + " ")}
+			offset = 0
 		}
 	}
 
-	// FIXME: pass selector uuid options
-	if apiArg {
-		fmt.Println()
-		option := ShowSelector()
-		// show only one option in autocompletion
-		newLine = [][]rune{[]rune(option)}
-		offset = 0
-	}
-
-	return newLine, offset
+	return options, offset
 }
 
 func NewCompleter(cfg *config.Config) *CliCompleter {
diff --git a/cli/selector.go b/cli/selector.go
index 5cf35af..bb1696e 100644
--- a/cli/selector.go
+++ b/cli/selector.go
@@ -25,23 +25,38 @@ import (
 	"github.com/rhtyd/readline"
 )
 
-type SelectOptions struct {
-	Name   string
+type SelectOption struct {
 	Id     string
+	Name   string
 	Detail string
 }
 
-func ShowSelector() string {
-	options := []SelectOptions{
-		{Name: "Option1", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option2", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option3", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option4", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option5", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option6", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option7", Id: "some-uuid", Detail: "Some Detail"},
-		{Name: "Option8", Id: "some-uuid", Detail: "Some Detail"},
+type Selector struct {
+	InUse bool
+}
+
+var selector Selector
+
+func init() {
+	selector = Selector{
+		InUse: false,
+	}
+}
+
+func (s Selector) lock() {
+	s.InUse = true
+}
+
+func (s Selector) unlock() {
+	s.InUse = false
+}
+
+func ShowSelector(options []SelectOption) SelectOption {
+	if selector.InUse {
+		return SelectOption{}
 	}
+	selector.lock()
+	defer selector.unlock()
 
 	templates := &promptui.SelectTemplates{
 		Label:    "{{ . }}?",
@@ -50,9 +65,9 @@ func ShowSelector() string {
 		Selected: "Selected: {{ .Name | cyan }} ({{ .Id | red }})",
 		Details: `
 --------- Current Selection ----------
-{{ "Name:" | faint }} {{ .Name }}
 {{ "Id:" | faint }}  {{ .Id }}
-{{ "Detail:" | faint }}  {{ .Detail }}`,
+{{ "Name:" | faint }} {{ .Name }}
+{{ "Description:" | faint }}  {{ .Detail }}`,
 	}
 
 	searcher := func(input string, index int) bool {
@@ -83,8 +98,8 @@ func ShowSelector() string {
 
 	if err != nil {
 		fmt.Printf("Prompt failed %v\n", err)
-		return ""
+		return SelectOption{}
 	}
 
-	return options[i].Id
+	return options[i]
 }
diff --git a/cmd/api.go b/cmd/api.go
index 4be24e3..ff9d0ce 100644
--- a/cmd/api.go
+++ b/cmd/api.go
@@ -51,6 +51,35 @@ func init() {
 				return errors.New("unknown or unauthorized API: " + apiName)
 			}
 
+			if strings.Contains(strings.Join(apiArgs, " "), "-h") {
+				fmt.Println("=== Help docs ===")
+				fmt.Println(api.Name, ":", api.Description)
+				fmt.Println("Async:", api.Async)
+				fmt.Println("Required params:", strings.Join(api.RequiredArgs, ", "))
+				for _, arg := range api.Args {
+					fmt.Println(arg.Name, "(", arg.Type, ")", arg.Description)
+				}
+				return nil
+			}
+
+			var missingArgs []string
+			for _, required := range api.RequiredArgs {
+				provided := false
+				for _, arg := range apiArgs {
+					if strings.HasPrefix(arg, required+"=") {
+						provided = true
+					}
+				}
+				if !provided {
+					missingArgs = append(missingArgs, required)
+				}
+			}
+
+			if len(missingArgs) > 0 {
+				fmt.Println("Missing required arguments: ", strings.Join(missingArgs, ", "))
+				return nil
+			}
+
 			b, _ := NewAPIRequest(r, api.Name, apiArgs)
 			response, _ := json.MarshalIndent(b, "", "  ")
 
diff --git a/cmd/network.go b/cmd/network.go
index 352afd9..6ff7d7c 100644
--- a/cmd/network.go
+++ b/cmd/network.go
@@ -57,8 +57,6 @@ func encodeRequestParams(params url.Values) string {
 }
 
 func NewAPIRequest(r *Request, api string, args []string) (map[string]interface{}, error) {
-	fmt.Println("[debug] Running api:", api, args)
-
 	params := make(url.Values)
 	params.Add("command", api)
 	for _, arg := range args {
@@ -85,7 +83,7 @@ func NewAPIRequest(r *Request, api string, args []string) (map[string]interface{
 
 	apiUrl := fmt.Sprintf("%s?%s", r.Config.ActiveProfile.Url, encodedParams)
 
-	fmt.Println("[debug] Requesting: ", apiUrl)
+	//fmt.Println("[debug] Requesting: ", apiUrl)
 	response, err := http.Get(apiUrl)
 	if err != nil {
 		fmt.Println("Error:", err)
diff --git a/config/cache.go b/config/cache.go
index 3814669..27b431f 100644
--- a/config/cache.go
+++ b/config/cache.go
@@ -21,31 +21,46 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"sort"
 	"strings"
 	"unicode"
 )
 
 type ApiArg struct {
 	Name        string
+	Type        string
+	Related     []string
 	Description string
 	Required    bool
 	Length      int
-	Type        string
-	Related     []string
 }
 
 type Api struct {
 	Name         string
-	ResponseName string
-	Description  string
-	Async        bool
-	Related      []string
-	Args         []*ApiArg
-	RequiredArgs []*ApiArg
 	Verb         string
+	Noun         string
+	Args         []*ApiArg
+	RequiredArgs []string
+	Related      []string
+	Async        bool
+	Description  string
+	ResponseName string
 }
 
 var apiCache map[string]*Api
+var apiVerbMap map[string][]*Api
+
+func (c *Config) GetApiVerbMap() map[string][]*Api {
+	if apiVerbMap != nil {
+		return apiVerbMap
+	}
+	apiSplitMap := make(map[string][]*Api)
+	for api := range apiCache {
+		verb := apiCache[api].Verb
+		apiSplitMap[verb] = append(apiSplitMap[verb], apiCache[api])
+	}
+	return apiSplitMap
+}
 
 func (c *Config) GetCache() map[string]*Api {
 	if apiCache == nil {
@@ -73,6 +88,7 @@ func (c *Config) SaveCache(response map[string]interface{}) {
 
 func (c *Config) UpdateCache(response map[string]interface{}) interface{} {
 	apiCache = make(map[string]*Api)
+	apiVerbMap = nil
 
 	count := response["count"]
 	apiList := response["api"].([]interface{})
@@ -85,6 +101,7 @@ func (c *Config) UpdateCache(response map[string]interface{}) interface{} {
 		}
 		apiName := api["name"].(string)
 		isAsync := api["isasync"].(bool)
+		description := api["description"].(string)
 
 		idx := 0
 		for _, chr := range apiName {
@@ -95,22 +112,44 @@ func (c *Config) UpdateCache(response map[string]interface{}) interface{} {
 			}
 		}
 		verb := apiName[:idx]
+		noun := strings.ToLower(apiName[idx:])
 
 		var apiArgs []*ApiArg
 		for _, argNode := range api["params"].([]interface{}) {
 			apiArg, _ := argNode.(map[string]interface{})
+			related := []string{}
+			if apiArg["related"] != nil {
+				related = strings.Split(apiArg["related"].(string), ",")
+				sort.Strings(related)
+			}
 			apiArgs = append(apiArgs, &ApiArg{
-				Name:     apiArg["name"].(string),
-				Type:     apiArg["type"].(string),
-				Required: apiArg["required"].(bool),
+				Name:        apiArg["name"].(string),
+				Type:        apiArg["type"].(string),
+				Required:    apiArg["required"].(bool),
+				Related:     related,
+				Description: apiArg["description"].(string),
 			})
 		}
 
+		sort.Slice(apiArgs, func(i, j int) bool {
+			return apiArgs[i].Name < apiArgs[j].Name
+		})
+
+		var requiredArgs []string
+		for _, arg := range apiArgs {
+			if arg.Required {
+				requiredArgs = append(requiredArgs, arg.Name)
+			}
+		}
+
 		apiCache[strings.ToLower(apiName)] = &Api{
-			Name:  apiName,
-			Async: isAsync,
-			Args:  apiArgs,
-			Verb:  verb,
+			Name:         apiName,
+			Verb:         verb,
+			Noun:         noun,
+			Args:         apiArgs,
+			RequiredArgs: requiredArgs,
+			Async:        isAsync,
+			Description:  description,
 		}
 	}
 	return count
diff --git a/config/config.go b/config/config.go
index 06c5393..0f2d71e 100644
--- a/config/config.go
+++ b/config/config.go
@@ -18,10 +18,24 @@
 package config
 
 import (
+	"fmt"
+	homedir "github.com/mitchellh/go-homedir"
 	"os"
 	"path"
 )
 
+var name = "cloudmonkey"
+var version = "6.0.0-alpha1"
+
+func getDefaultConfigDir() string {
+	home, err := homedir.Dir()
+	if err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+	return path.Join(home, ".cmk")
+}
+
 type OutputFormat string
 
 const (
@@ -98,3 +112,32 @@ func loadConfig() *Config {
 
 	return cfg
 }
+
+func (c *Config) Name() string {
+	return name
+}
+
+func (c *Config) Version() string {
+	return version
+}
+
+func (c *Config) PrintHeader() {
+	fmt.Printf("Apache CloudStack 🐵 cloudmonkey %s.\n", version)
+	fmt.Printf("Type \"help\" for details, \"sync\" to update API cache or press tab to list commands.\n\n")
+}
+
+func (c *Config) GetPrompt() string {
+	return fmt.Sprintf("(%s) \033[34m🐵\033[0m > ", c.ActiveProfile.Name)
+}
+
+func (c *Config) UpdateGlobalConfig(key string, value string) {
+	c.UpdateConfig("", key, value)
+}
+
+func (c *Config) UpdateConfig(namespace string, key string, value string) {
+	fmt.Println("Updating for key", key, ", value=", value, ", in ns=", namespace)
+	if key == "profile" {
+		//FIXME
+		c.ActiveProfile.Name = value
+	}
+}
diff --git a/config/util.go b/config/util.go
deleted file mode 100644
index 7421e5b..0000000
--- a/config/util.go
+++ /dev/null
@@ -1,67 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-package config
-
-import (
-	"fmt"
-	"os"
-	"path"
-
-	"github.com/mitchellh/go-homedir"
-)
-
-var name = "cloudmonkey"
-var version = "6.0.0-alpha1"
-
-func getDefaultConfigDir() string {
-	home, err := homedir.Dir()
-	if err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-	return path.Join(home, ".cmk")
-}
-
-func (c *Config) Name() string {
-	return name
-}
-
-func (c *Config) Version() string {
-	return version
-}
-
-func (c *Config) PrintHeader() {
-	fmt.Printf("Apache CloudStack 🐵 cloudmonkey %s.\n", version)
-	fmt.Printf("Type \"help\" for details, \"sync\" to update API cache or press tab to list commands.\n\n")
-}
-
-func (c *Config) GetPrompt() string {
-	return fmt.Sprintf("(%s) \033[34m🐵\033[0m > ", c.ActiveProfile.Name)
-}
-
-func (c *Config) UpdateGlobalConfig(key string, value string) {
-	c.UpdateConfig("", key, value)
-}
-
-func (c *Config) UpdateConfig(namespace string, key string, value string) {
-	fmt.Println("Updating for key", key, ", value=", value, ", in ns=", namespace)
-	if key == "profile" {
-		//FIXME
-		c.ActiveProfile.Name = value
-	}
-}

-- 
To stop receiving notification emails like this one, please contact
rohit@apache.org.