Skip to content

Commit

Permalink
fix(core): fix autocomplete edge cases (#811)
Browse files Browse the repository at this point in the history
* fix(core): fix autocomplete edge cases

Signed-off-by: Patrik Cyvoct <[email protected]>

Co-authored-by: Jerome Quere <[email protected]>
  • Loading branch information
Sh4d1 and jerome-quere authored Apr 23, 2020
1 parent 726385c commit 8686ed2
Show file tree
Hide file tree
Showing 8 changed files with 89 additions and 76 deletions.
9 changes: 6 additions & 3 deletions internal/core/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,9 @@ func (node *AutoCompleteNode) isLeafCommand() bool {
// BuildAutoCompleteTree builds the autocomplete tree from the commands, subcommands and arguments
func BuildAutoCompleteTree(commands *Commands) *AutoCompleteNode {
root := NewAutoCompleteCommandNode()
scwCommand := root.GetChildOrCreate("scw")
scwCommand.addGlobalFlags()
root.addGlobalFlags()
for _, cmd := range commands.commands {
node := scwCommand
node := root

// Creates nodes for namespaces, resources, verbs
for _, part := range []string{cmd.Namespace, cmd.Resource, cmd.Verb} {
Expand Down Expand Up @@ -255,6 +254,10 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
// nodeIndexInWords is the rightmost word index, before the cursor, that contains either a namespace or verb or resource or flag or flag value.
// see test 'scw test flower delete f'
nodeIndexInWords := 0

// We remove command binary name from the left words.
leftWords = leftWords[1:]

for i, word := range leftWords {
children, childrenExists := node.Children[word]
if !childrenExists {
Expand Down
1 change: 0 additions & 1 deletion internal/core/autocomplete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ func TestAutocomplete(t *testing.T) {
}
}

t.Run("scw", run(&testCase{Suggestions: AutocompleteSuggestions{"scw"}}))
t.Run("scw ", run(&testCase{Suggestions: AutocompleteSuggestions{"test"}}))
t.Run("scw te", run(&testCase{Suggestions: AutocompleteSuggestions{"test"}}))
t.Run("scw test", run(&testCase{Suggestions: AutocompleteSuggestions{"test"}}))
Expand Down
1 change: 1 addition & 0 deletions internal/core/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e
// Meta store globally available variables like SDK client.
// Meta is injected in a context object that will be passed to all commands.
meta := &meta{
BinaryName: config.Args[0],
BuildInfo: config.BuildInfo,
stdout: config.Stdout,
stderr: config.Stderr,
Expand Down
4 changes: 2 additions & 2 deletions internal/core/cobra_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func (b *cobraBuilder) build() *cobra.Command {
commandsIndex := map[string]*Command{}

rootCmd := &cobra.Command{
Use: "scw",
Use: b.meta.BinaryName,

// Do not display error with cobra, we handle it in bootstrap.
SilenceErrors: true,
Expand Down Expand Up @@ -104,7 +104,7 @@ func (b *cobraBuilder) hydrateCobra(cobraCmd *cobra.Command, cmd *Command) {
}

if cmd.Examples != nil {
cobraCmd.Annotations["Examples"] = buildExamples(cmd)
cobraCmd.Annotations["Examples"] = buildExamples(b.meta.BinaryName, cmd)
}

if cmd.SeeAlsos != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/core/cobra_usage_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func _buildArgShort(as *ArgSpec) string {

// buildExamples builds usage examples string.
// This string will be used by cobra usage template.
func buildExamples(cmd *Command) string {
func buildExamples(binaryName string, cmd *Command) string {
// Build the examples array.
var examples []string

Expand Down Expand Up @@ -110,7 +110,7 @@ func buildExamples(cmd *Command) string {

// Build command line example.
commandParts := []string{
"scw",
binaryName,
cmd.Namespace,
cmd.Resource,
cmd.Verb,
Expand Down
12 changes: 6 additions & 6 deletions internal/core/cobra_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func cobraRun(ctx context.Context, cmd *Command) func(*cobra.Command, []string)
cmdArgs := reflect.New(cmd.ArgsType).Interface()

// Handle positional argument by catching first argument `<value>` and rewrite it to `<arg-name>=<value>`.
if err = handlePositionalArg(cmd, rawArgs); err != nil {
if err = handlePositionalArg(meta.BinaryName, cmd, rawArgs); err != nil {
return err
}

Expand Down Expand Up @@ -113,7 +113,7 @@ func cobraRun(ctx context.Context, cmd *Command) func(*cobra.Command, []string)
// - no positional argument is found.
// - an unknown positional argument exists in the comand.
// - an argument duplicates a positional argument.
func handlePositionalArg(cmd *Command, rawArgs []string) error {
func handlePositionalArg(binaryName string, cmd *Command, rawArgs []string) error {
positionalArg := cmd.ArgSpecs.GetPositionalArg()

// Command does not have a positional argument.
Expand All @@ -131,7 +131,7 @@ func handlePositionalArg(cmd *Command, rawArgs []string) error {
otherArgs := append(rawArgs[:i], rawArgs[i+1:]...)
return &CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: positionalArgHint(cmd, argumentValue, otherArgs, positionalArgumentFound),
Hint: positionalArgHint(binaryName, cmd, argumentValue, otherArgs, positionalArgumentFound),
}
}
}
Expand All @@ -145,12 +145,12 @@ func handlePositionalArg(cmd *Command, rawArgs []string) error {
// No positional argument found.
return &CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: positionalArgHint(cmd, "<"+positionalArg.Name+">", rawArgs, false),
Hint: positionalArgHint(binaryName, cmd, "<"+positionalArg.Name+">", rawArgs, false),
}
}

// positionalArgHint formats the positional argument error hint.
func positionalArgHint(cmd *Command, hintValue string, otherArgs []string, positionalArgumentFound bool) string {
func positionalArgHint(binaryName string, cmd *Command, hintValue string, otherArgs []string, positionalArgumentFound bool) string {
suggestedArgs := []string{}

// If no positional argument exists, suggest one.
Expand All @@ -161,7 +161,7 @@ func positionalArgHint(cmd *Command, hintValue string, otherArgs []string, posit
// Suggest to use the other arguments.
suggestedArgs = append(suggestedArgs, otherArgs...)

suggestedCommand := append([]string{"scw", cmd.GetCommandLine()}, suggestedArgs...)
suggestedCommand := append([]string{binaryName, cmd.GetCommandLine()}, suggestedArgs...)
return "Try running: " + strings.Join(suggestedCommand, " ")
}

Expand Down
6 changes: 6 additions & 0 deletions internal/core/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (

// meta store globally available variables like sdk client or global Flags.
type meta struct {
BinaryName string

ProfileFlag string
DebugModeFlag bool
PrinterTypeFlag printer.Type
Expand Down Expand Up @@ -93,3 +95,7 @@ func ExtractEnv(ctx context.Context, envKey string) string {
func ExtractUserHomeDir(ctx context.Context) string {
return ExtractEnv(ctx, "HOME")
}

func ExtractBinaryName(ctx context.Context) string {
return extractMeta(ctx).BinaryName
}
128 changes: 66 additions & 62 deletions internal/namespaces/autocomplete/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,83 +38,85 @@ var homePath, _ = os.UserHomeDir()

// autocompleteScripts regroups the autocomplete scripts for the different shells
// The key is the path of the shell.
var autocompleteScripts = map[string]autocompleteScript{
"bash": {
// If `scw` is the first word on the command line,
// after hitting [tab] arguments are sent to `scw autocomplete complete bash`:
// - COMP_LINE: the complete command line
// - cword: the index of the word being completed (source COMP_CWORD)
// - words: the words composing the command line (source COMP_WORDS)
//
// Note that `=` signs are excluding from $COMP_WORDBREAKS. As a result, they are NOT be
// considered as breaking words and arguments like `image=` will not be split.
//
// Then `scw autocomplete complete bash` process the line, and tries to returns suggestions.
// These scw suggestions are put into `COMPREPLY` which is used by Bash to provides the shell suggestions.
CompleteFunc: `
_scw() {
func autocompleteScripts(binaryName string) map[string]autocompleteScript {
return map[string]autocompleteScript{
"bash": {
// If `scw` is the first word on the command line,
// after hitting [tab] arguments are sent to `scw autocomplete complete bash`:
// - COMP_LINE: the complete command line
// - cword: the index of the word being completed (source COMP_CWORD)
// - words: the words composing the command line (source COMP_WORDS)
//
// Note that `=` signs are excluding from $COMP_WORDBREAKS. As a result, they are NOT be
// considered as breaking words and arguments like `image=` will not be split.
//
// Then `scw autocomplete complete bash` process the line, and tries to returns suggestions.
// These scw suggestions are put into `COMPREPLY` which is used by Bash to provides the shell suggestions.
CompleteFunc: fmt.Sprintf(`
_%[1]s() {
_get_comp_words_by_ref -n = cword words
output=$(scw autocomplete complete bash -- "$COMP_LINE" "$cword" "${words[@]}")
output=$(%[1]s autocomplete complete bash -- "$COMP_LINE" "$cword" "${words[@]}")
COMPREPLY=($output)
# apply compopt option and ignore failure for older bash versions
[[ $COMPREPLY == *= ]] && compopt -o nospace 2> /dev/null || true
return
}
complete -F _scw scw
`,
CompleteScript: `eval "$(scw autocomplete script shell=bash)"`,
ShellConfigurationFile: map[string]string{
"darwin": path.Join(homePath, ".bash_profile"),
"linux": path.Join(homePath, ".bashrc"),
complete -F _%[1]s %[1]s
`, binaryName),
CompleteScript: fmt.Sprintf(`eval "$(%s autocomplete script shell=bash)"`, binaryName),
ShellConfigurationFile: map[string]string{
"darwin": path.Join(homePath, ".bash_profile"),
"linux": path.Join(homePath, ".bashrc"),
},
},
},
"fish": {
// (commandline) complete command line
// (commandline --cursor) position of the cursor, as number of chars in the command line
// (commandline --current-token) word to complete
// (commandline --tokenize --cut-at-cursor) tokenized selection up until the current cursor position
// formatted as one string-type token per line
//
// If files are shown although --no-files is set,
// it might be because you are using an alias for scw, such as :
// alias scw='go run "$HOME"/scaleway-cli/cmd/scw/main.go'
// You might want to run 'complete --erase --command go' during development.
//
// TODO: send rightWords
CompleteFunc: `
complete --erase --command scw;
complete --command scw --no-files;
complete --command scw --arguments '(scw autocomplete complete fish -- (commandline) (commandline --cursor) (commandline --current-token) (commandline --current-process --tokenize --cut-at-cursor))';
`,
CompleteScript: `eval (scw autocomplete script shell=fish)`,
ShellConfigurationFile: map[string]string{
"darwin": path.Join(homePath, ".config/fish/config.fish"),
"linux": path.Join(homePath, ".config/fish/config.fish"),
"fish": {
// (commandline) complete command line
// (commandline --cursor) position of the cursor, as number of chars in the command line
// (commandline --current-token) word to complete
// (commandline --tokenize --cut-at-cursor) tokenized selection up until the current cursor position
// formatted as one string-type token per line
//
// If files are shown although --no-files is set,
// it might be because you are using an alias for scw, such as :
// alias scw='go run "$HOME"/scaleway-cli/cmd/scw/main.go'
// You might want to run 'complete --erase --command go' during development.
//
// TODO: send rightWords
CompleteFunc: fmt.Sprintf(`
complete --erase --command %[1]s;
complete --command %[1]s --no-files;
complete --command %[1]s --arguments '(%[1]s autocomplete complete fish -- (commandline) (commandline --cursor) (commandline --current-token) (commandline --current-process --tokenize --cut-at-cursor))';
`, binaryName),
CompleteScript: fmt.Sprintf(`eval (%s autocomplete script shell=fish)`, binaryName),
ShellConfigurationFile: map[string]string{
"darwin": path.Join(homePath, ".config/fish/config.fish"),
"linux": path.Join(homePath, ".config/fish/config.fish"),
},
},
},
"zsh": {
// If you are using an alias for scw, such as :
// alias scw='go run "$HOME"/scaleway-cli/cmd/scw/main.go'
// you might want to run 'compdef _scw go' during development.
CompleteFunc: `
"zsh": {
// If you are using an alias for scw, such as :
// alias scw='go run "$HOME"/scaleway-cli/cmd/scw/main.go'
// you might want to run 'compdef _scw go' during development.
CompleteFunc: fmt.Sprintf(`
autoload -U compinit && compinit
_scw () {
output=($(scw autocomplete complete zsh -- ${CURRENT} ${words}))
_%[1]s () {
output=($(%[1]s autocomplete complete zsh -- ${CURRENT} ${words}))
opts=('-S' ' ')
if [[ $output == *= ]]; then
opts=('-S' '')
fi
compadd "${opts[@]}" -- "${output[@]}"
}
compdef _scw scw
`,
CompleteScript: `eval "$(scw autocomplete script shell=zsh)"`,
ShellConfigurationFile: map[string]string{
"darwin": path.Join(homePath, ".zshrc"),
"linux": path.Join(homePath, ".zshrc"),
compdef _%[1]s %[1]s
`, binaryName),
CompleteScript: fmt.Sprintf(`eval "$(%s autocomplete script shell=zsh)"`, binaryName),
ShellConfigurationFile: map[string]string{
"darwin": path.Join(homePath, ".zshrc"),
"linux": path.Join(homePath, ".zshrc"),
},
},
},
}
}

type InstallArgs struct {
Expand All @@ -141,6 +143,7 @@ func autocompleteInstallCommand() *core.Command {
func InstallCommandRun(ctx context.Context, argsI interface{}) (i interface{}, e error) {
// Warning
_, _ = interactive.Println("To enable autocomplete, scw needs to update your shell configuration.")
binaryName := core.ExtractBinaryName(ctx)

// If `shell=` is empty, ask for a value for `shell=`.
shellArg := argsI.(*InstallArgs).Shell
Expand All @@ -161,7 +164,7 @@ func InstallCommandRun(ctx context.Context, argsI interface{}) (i interface{}, e

shellName := filepath.Base(shellArg)

script, exists := autocompleteScripts[shellName]
script, exists := autocompleteScripts(binaryName)[shellName]
if !exists {
return nil, unsupportedShellError(shellName)
}
Expand Down Expand Up @@ -353,8 +356,9 @@ func autocompleteScriptCommand() *core.Command {
},
ArgsType: reflect.TypeOf(autocompleteShowArgs{}),
Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) {
binaryName := core.ExtractBinaryName(ctx)
shell := filepath.Base(argsI.(*autocompleteShowArgs).Shell)
script, exists := autocompleteScripts[shell]
script, exists := autocompleteScripts(binaryName)[shell]
if !exists {
return nil, unsupportedShellError(shell)
}
Expand Down

0 comments on commit 8686ed2

Please sign in to comment.