Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(core): fix autocomplete edge cases #811

Merged
merged 6 commits into from
Apr 23, 2020
Merged
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
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