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 ro...@apache.org.