Skip to content

Commit

Permalink
command: add extensive autocompletion support (peak#500)
Browse files Browse the repository at this point in the history
Adds extensive auto-completion support to s5cmd for zsh, bash and pwsh.
Including:
- command completion
- flag completion
- local file/directory completion
- bucket name completion
- s3 key completion


The user will be given the instructions and the completion script when
she called s5cmd with install-completion flag. The instructions and
script will be written according to value of SHELL environment variable

Fixes peak#207 
Fixes peak#372

Co-authored-by: Selman Kayrancioglu <selman@peak.com>
Co-authored-by: Aykut Farsak <aykutfarsak@gmail.com>
Co-authored-by: Selman Kayrancioglu <seruman@users.noreply.github.com>
  • Loading branch information
4 people authored Sep 16, 2022
1 parent 22592e5 commit 5e867c9
Show file tree
Hide file tree
Showing 39 changed files with 600 additions and 1,515 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
- Allow adjacent slashes to be used as keys when uploading to remote. ([#459](https://github.com/peak/s5cmd/pull/459))
- Debian packages are provided on [releases page](https://github.com/peak/s5cmd/releases) ([#380](https://github.com/peak/s5cmd/issues/380))
- Upgraded minimum required Go version to 1.17.

- Improve auto-completion support of s5cmd for `zsh` and `bash`, start supporting `pwsh` and stop the support for `fish`. Now s5cmd can complete bucket names, s3 keys in a bucket and the local files. However, `install-completion` flag no longer _installs_ the completion script to `*rc` files instead it merely gives instructions to install autocompletion and provides the autocompletion script ([#500](https://github.com/peak/s5cmd/pull/500)).

#### Bugfixes
- Fixed a bug where (`--stat`) prints unnecessarily when used with help and version commands ([#452](https://github.com/peak/s5cmd/issues/452))
Expand Down
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,28 @@ While executing the commands, `s5cmd` detects the region according to the follow
### Shell auto-completion
Shell completion is supported for bash, zsh and fish.
Shell completion is supported for bash, pwsh (PowerShell) and zsh.
To enable auto-completion, run:
Run `s5cmd --install-completion` to obtain the appropriate auto-completion script for your shell, note that `install-completion` does not install the auto-completion but merely gives the instructions to install. The name is kept as it is for backward compatibility.
s5cmd --install-completion
To actually enable auto-completion:
#### in bash and zsh:
you should add auto-completion script to `.bashrc` and `.zshrc` file.
#### in pwsh:
you should save the autocompletion script to a file named `s5cmd.ps1` and add the full path of "s5cmd.ps1" file to profile file (which you can locate with `$profile`)
This will add a few lines to your shell configuration file. After installation,
restart your shell to activate the changes.
Finally, restart your shell to activate the changes.
> **Note**
The environment variable `SHELL` must be accurate for the autocompletion to function properly. That is it should point to `bash` binary in bash, to `zsh` binary in zsh and to `pwsh` binary in PowerShell.
> **Note**
The autocompletion is tested with following versions of the shells: \
***zsh*** 5.8.1 (x86_64-apple-darwin21.0) \
GNU ***bash***, version 5.1.16(1)-release (x86_64-apple-darwin21.1.0) \
***PowerShell*** 7.2.6
### Google Cloud Storage support
Expand Down
20 changes: 6 additions & 14 deletions command/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"os"
"strings"

cmpinstall "github.com/posener/complete/cmd/install"
"github.com/urfave/cli/v2"

"github.com/peak/s5cmd/log"
Expand All @@ -23,8 +22,9 @@ const (
)

var app = &cli.App{
Name: appName,
Usage: "Blazing fast S3 and local filesystem execution tool",
Name: appName,
Usage: "Blazing fast S3 and local filesystem execution tool",
EnableBashCompletion: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Expand Down Expand Up @@ -60,7 +60,7 @@ var app = &cli.App{
},
&cli.BoolFlag{
Name: "install-completion",
Usage: "install completion for your shell",
Usage: "get completion installation instructions for your shell (only available for bash, pwsh, and zsh)",
},
&cli.BoolFlag{
Name: "dry-run",
Expand Down Expand Up @@ -154,13 +154,9 @@ var app = &cli.App{
},
Action: func(c *cli.Context) error {
if c.Bool("install-completion") {
if cmpinstall.IsInstalled(appName) {
return nil
}

return cmpinstall.Install(appName)
printAutocompletionInstructions(os.Getenv("SHELL"))
return nil
}

args := c.Args()
if args.Present() {
cli.ShowCommandHelp(c, args.First())
Expand Down Expand Up @@ -228,9 +224,5 @@ func AppCommand(name string) *cli.Command {
func Main(ctx context.Context, args []string) error {
app.Commands = Commands()

if maybeAutoComplete() {
return nil
}

return app.RunContext(ctx, args)
}
241 changes: 241 additions & 0 deletions command/auto_complete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package command

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/peak/s5cmd/storage"
"github.com/peak/s5cmd/storage/url"
"github.com/urfave/cli/v2"
)

const zsh = `autoload -Uz compinit
compinit
_s5cmd_cli_zsh_autocomplete() {
local -a opts
local cur
cur=${words[-1]}
opts=("${(@f)$(${words[@]:0:#words[@]-1} "${cur}" --generate-bash-completion)}")
if [[ "${opts[1]}" != "" ]]; then
_describe 'values' opts
else
_files
fi
}
compdef _s5cmd_cli_zsh_autocomplete s5cmd
`

const bash = `# prepare autocompletion suggestions for s5cmd and save them to COMPREPLY array
_s5cmd_cli_bash_autocomplete() {
if [[ "${COMP_WORDS[0]}" != "source" ]]; then
COMPREPLY=()
local opts cur cmd` +
// get current word (cur) and prepare command (cmd)
`
cur="${COMP_WORDS[COMP_CWORD]}"
cmd="${COMP_LINE:0:$COMP_POINT}"` +

// if we want to complete the second argument and we didn't start writing
// yet then we should pass an empty string as another argument. Otherwise
// the white spaces will be discarded and the program will make suggestions
// as if it is completing the first argument.
// Beware that we want to pass an empty string so we intentionally write
// as it is. Fixes of SC2089 and SC2090 are not what we want.
// see also https://www.shellcheck.net/wiki/SC2090
`
[ "${COMP_LINE:COMP_POINT-1:$COMP_POINT}" == " " ] \
&& cmd="${cmd} ''" ` +

// execute the command with '--generate-bash-completion' flag to obtain
// possible completion values for current word.
// ps. SC2090 is not wanted.
`
opts=$($cmd --generate-bash-completion)` +

// prepare completion array with possible values and filter those do not start with cur.
// if no completion is found then fallback to default completion of shell.
`
while IFS='' read -r line;
do
COMPREPLY+=("$line");
done \
< <(compgen -o bashdefault -o default -o nospace -W "${opts}" -- "${cur}")
return 0
fi
}
# call the _s5cmd_cli_bash_autocomplete to complete s5cmd command.
complete -o nospace -F _s5cmd_cli_bash_autocomplete s5cmd
`

const pwsh = `$fn = $($MyInvocation.MyCommand.Name)
$name = $fn -replace "(.*)\.ps1$", '$1'
Register-ArgumentCompleter -Native -CommandName $name -ScriptBlock {
param($commandName, $wordToComplete, $cursorPosition)
$other = "$wordToComplete --generate-bash-completion"
Invoke-Expression $other | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
`

func getBashCompleteFn(cmd *cli.Command, isOnlyRemote, isOnlyBucket bool) func(ctx *cli.Context) {
isOnlyRemote = isOnlyRemote || isOnlyBucket
return func(ctx *cli.Context) {
arg := parseArgumentToComplete(ctx)

if strings.HasPrefix(arg, "-") {
cli.DefaultCompleteWithFlags(cmd)(ctx)
return
}

if isOnlyRemote || strings.HasPrefix(arg, "s3://") {
u, err := url.New(arg)
if err != nil {
u = &url.URL{Type: 0, Scheme: "s3"}
}

c := ctx.Context
client, err := storage.NewRemoteClient(c, u, NewStorageOpts(ctx))
if err != nil {
return
}

shell := filepath.Base(os.Getenv("SHELL"))
printS3Suggestions(c, shell, client, u, arg, isOnlyBucket)
return
}
}
}

// constantCompleteWithDefault returns a complete function which prints the argument, itself, which is to be completed.
// If the argument is empty string it uses the defaultCompletions to make suggestions.
func constantCompleteWithDefault(shell, arg string, defaultCompletions ...string) {
if arg == "" {
for _, str := range defaultCompletions {
fmt.Println(formatSuggestionForShell(shell, str, arg))
}
} else {
fmt.Println(formatSuggestionForShell(shell, arg, arg))
}
}

func printS3Suggestions(c context.Context, shell string, client *storage.S3, u *url.URL, arg string, isOnlyBucket bool) {
if u.Bucket == "" || (u.IsBucket() && !strings.HasSuffix(arg, "/")) || isOnlyBucket {
printListBuckets(c, shell, client, u, arg)
} else {
printListNURLSuggestions(c, shell, client, u, 20, arg)
}
}

func printListBuckets(ctx context.Context, shell string, client *storage.S3, u *url.URL, argToBeCompleted string) {
buckets, err := client.ListBuckets(ctx, u.Bucket)
if err != nil {
return
}

for _, bucket := range buckets {
fmt.Println(formatSuggestionForShell(shell, "s3://"+bucket.Name+"/", argToBeCompleted))
}
}

func printListNURLSuggestions(ctx context.Context, shell string, client *storage.S3, u *url.URL, count int, argToBeCompleted string) {
if u.IsBucket() {
var err error
u, err = url.New(u.Absolute() + "/")
if err != nil {
return
}
}

i := 0
for obj := range (*client).List(ctx, u, false) {
if i > count {
break
}
if obj.Err != nil {
return
}
fmt.Println(formatSuggestionForShell(shell, obj.URL.Absolute(), argToBeCompleted))
i++
}
}

func printAutocompletionInstructions(shell string) {
var script string
baseShell := filepath.Base(shell)
instructions := `# To enable autocompletion you should add the following script to startup scripts of your shell.
# It is probably located at ~/.` + baseShell + "rc"

switch baseShell {
case "zsh":
script = zsh
case "bash":
script = bash
case "pwsh":
script = pwsh
instructions = `# To enable autocompletion you should save the following script to a file named "s5cmd.ps1" and execute it.
# To persist it you should add the path of "s5cmd.ps1" file to profile file (which you can locate with $profile) to automatically execute "s5cmd.ps1" on every shell start up.`
default:
instructions = `# We couldn't recognize your SHELL "` + baseShell + `".
# Shell completion is supported only for bash, pwsh and zsh.
# Make sure that your SHELL environment variable is set accurately.`
}

fmt.Println(instructions)
fmt.Println(script)
}

func formatSuggestionForShell(baseShell, suggestion, argToBeCompleted string) string {
switch baseShell {
case "bash":
var prefix string
suggestions := make([]string, 0, 2)
if i := strings.LastIndex(argToBeCompleted, ":"); i >= 0 && baseShell == "bash" {
// include the original suggestion in case that COMP_WORDBREAKS does not contain :
// or that the argToBeCompleted was quoted.
// Bash doesn't split on : when argument is quoted even if : is in COMP_WORDBREAKS
suggestions = append(suggestions, suggestion)
prefix = argToBeCompleted[0 : i+1]
}
suggestions = append(suggestions, strings.TrimPrefix(suggestion, prefix))
return strings.Join(suggestions, "\n")
case "zsh":
// replace every colon : with \: if shell is zsh
// colons are used as a seperator for the autocompletion script
// so "literal colons in completion must be quoted with a backslash"
// see also https://zsh.sourceforge.io/Doc/Release/Completion-System.html#:~:text=This%20is%20followed,as%20name1%3B
return strings.ReplaceAll(suggestion, ":", `\:`)
default:
return suggestion
}
}

func parseArgumentToComplete(ctx *cli.Context) string {
var arg string
args := ctx.Args()
l := args.Len()

if l > 0 {
arg = args.Get(l - 1)
}

// argument may start with a quotation mark, in this case we want to trim
// that before checking if it has prefix 's3://'.
// Beware that we only want to trim the first char, not all of the leading
// quotation marks, because those quotation marks may be actual characters.
if strings.HasPrefix(arg, "'") {
arg = strings.TrimPrefix(arg, "'")
} else {
arg = strings.TrimPrefix(arg, "\"")
}
return arg
}
42 changes: 0 additions & 42 deletions command/autocomplete.go

This file was deleted.

Loading

0 comments on commit 5e867c9

Please sign in to comment.