From 1ab98f3b514dd1b0dfb1329476e220e5b703e401 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Tue, 3 Feb 2026 10:11:53 -0800 Subject: [PATCH] Add bash shell completion support Implement the Shell interface for bash, adding tab completion via COMP_WORDS/COMPREPLY with -o nosort -o nospace flags. Update docs, install script, and CLI flag values to include bash alongside fish and zsh. --- docs/cli-reference.md | 3 +- docs/getting-started.md | 3 ++ install.sh | 7 +++ internal/cli/app.go | 55 +++++++++++------------ internal/cli/cli_test.go | 15 +++++++ internal/complete/complete_test.go | 70 ++++++++++++++++++++++++++++++ internal/complete/shells.go | 32 ++++++++++++++ 7 files changed, 157 insertions(+), 28 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 95e473b..394d840 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -478,9 +478,10 @@ Update fetch binary in place. Use with `--dry-run` to check for updates without ### `--complete SHELL` -Output shell completion scripts. Values: `fish`, `zsh`. +Output shell completion scripts. Values: `bash`, `fish`, `zsh`. ```sh +echo 'eval "$(fetch --complete bash)"' >> ~/.bashrc fetch --complete zsh > ~/.zshrc.d/_fetch fetch --complete fish > ~/.config/fish/completions/fetch.fish ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 33543f5..bef76ce 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -126,6 +126,9 @@ auto-update = true Generate shell completion scripts: ```sh +# Bash +echo 'eval "$(fetch --complete bash)"' >> ~/.bashrc + # Zsh fetch --complete zsh > ~/.zshrc.d/fetch-completion.zsh diff --git a/install.sh b/install.sh index 1812167..5e63488 100755 --- a/install.sh +++ b/install.sh @@ -145,6 +145,13 @@ info "fetch successfully installed to '${DIM}${INSTALL_DIR}/fetch${RESET}'" # Optionally install completions. case "$SHELL" in + */bash) + COMPLETION_CMD='eval "$(fetch --complete=bash)"' + if ! grep -qF "$COMPLETION_CMD" "$HOME/.bashrc" 2>/dev/null; then + printf "\n# fetch completions\n${COMPLETION_CMD}\n" >> "$HOME/.bashrc" + info "completions appended to '${DIM}${HOME}/.bashrc${RESET}'" + fi + ;; */fish) mkdir -p "$HOME/.config/fish/completions" echo "fetch --complete=fish | source" > "$HOME/.config/fish/completions/fetch.fish" diff --git a/internal/cli/app.go b/internal/cli/app.go index c0ca452..25e251c 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -240,6 +240,20 @@ func (a *App) CLI() *CLI { return a.Cfg.ParseCert(value) }, }, + { + Short: "", + Long: "clobber", + Args: "", + Description: "Overwrite existing output file", + Default: "", + IsSet: func() bool { + return a.Clobber + }, + Fn: func(value string) error { + a.Clobber = true + return nil + }, + }, { Short: "", Long: "color", @@ -268,20 +282,6 @@ func (a *App) CLI() *CLI { return a.Cfg.ParseColor(value) }, }, - { - Short: "", - Long: "clobber", - Args: "", - Description: "Overwrite existing output file", - Default: "", - IsSet: func() bool { - return a.Clobber - }, - Fn: func(value string) error { - a.Clobber = true - return nil - }, - }, { Short: "", Long: "complete", @@ -289,6 +289,7 @@ func (a *App) CLI() *CLI { Description: "Output shell completion", Default: "", Values: []core.KeyVal[string]{ + {Key: "bash"}, {Key: "fish"}, {Key: "zsh"}, }, @@ -847,6 +848,19 @@ func (a *App) CLI() *CLI { return nil }, }, + { + Short: "S", + Long: "session", + Args: "NAME", + Description: "Use a named session for cookies", + Default: "", + IsSet: func() bool { + return a.Cfg.Session != nil + }, + Fn: func(value string) error { + return a.Cfg.ParseSession(value) + }, + }, { Short: "s", Long: "silent", @@ -862,19 +876,6 @@ func (a *App) CLI() *CLI { return nil }, }, - { - Short: "S", - Long: "session", - Args: "NAME", - Description: "Use a named session for cookies", - Default: "", - IsSet: func() bool { - return a.Cfg.Session != nil - }, - Fn: func(value string) error { - return a.Cfg.ParseSession(value) - }, - }, { Short: "t", Long: "timeout", diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ede3c78..94ba51c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -8,6 +8,21 @@ import ( "github.com/ryanfowler/fetch/internal/core" ) +func TestFlagsAlphabeticalOrder(t *testing.T) { + app, err := Parse(nil) + if err != nil { + t.Fatalf("unable to parse cli: %s", err.Error()) + } + cli := app.CLI() + for i := 1; i < len(cli.Flags); i++ { + prev := cli.Flags[i-1].Long + curr := cli.Flags[i].Long + if curr < prev { + t.Errorf("flags out of alphabetical order: %q should come before %q", curr, prev) + } + } +} + func TestCLI(t *testing.T) { app, err := Parse(nil) if err != nil { diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index fb37f31..2c8fbbd 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -7,6 +7,76 @@ import ( "github.com/ryanfowler/fetch/internal/cli" ) +func TestCompleteBash(t *testing.T) { + var app cli.App + flags := app.CLI().Flags + _, long := getFlagMaps(flags) + + tests := []struct { + name string + shell Shell + args []string + exp string + }{ + { + name: "should return nothing when no args", + shell: Bash{}, + args: nil, + exp: "", + }, + { + name: "should return nothing when only arg is command", + shell: Bash{}, + args: []string{"fetch"}, + exp: "", + }, + { + name: "should complete color flag", + shell: Bash{}, + args: []string{"fetch", "--col"}, + exp: "--color \n", + }, + { + name: "should complete color value", + shell: Bash{}, + args: []string{"fetch", "--color", ""}, + exp: func() string { + var sb strings.Builder + for _, kv := range long["color"].Values { + sb.WriteString(kv.Key) + sb.WriteString(" \n") + } + return sb.String() + }(), + }, + { + name: "should complete color value with prefix", + shell: Bash{}, + args: []string{"fetch", "--color", "o"}, + exp: func() string { + var sb strings.Builder + for _, kv := range long["color"].Values { + if !strings.HasPrefix(kv.Key, "o") { + continue + } + sb.WriteString(kv.Key) + sb.WriteString(" \n") + } + return sb.String() + }(), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res := Complete(test.shell, test.args) + if res != test.exp { + t.Fatalf("Unexpected result:\n%s", res) + } + }) + } +} + func TestCompleteFish(t *testing.T) { var app cli.App flags := app.CLI().Flags diff --git a/internal/complete/shells.go b/internal/complete/shells.go index 6b58b27..61d5017 100644 --- a/internal/complete/shells.go +++ b/internal/complete/shells.go @@ -17,6 +17,8 @@ type Shell interface { // nil is returned. func GetShell(name string) Shell { switch name { + case "bash": + return Bash{} case "fish": return Fish{} case "zsh": @@ -26,6 +28,36 @@ func GetShell(name string) Shell { } } +type Bash struct{} + +func (b Bash) Name() string { + return "bash" +} + +func (b Bash) Register() string { + return `_fetch_complete() { + local cur prev_tokens + cur="${COMP_WORDS[COMP_CWORD]}" + prev_tokens=("${COMP_WORDS[@]:0:COMP_CWORD}") + local IFS=$'\n' + COMPREPLY=($(fetch --complete=bash -- "${prev_tokens[@]}" "$cur")) + IFS=$' \t\n' +} +complete -o nosort -o nospace -F _fetch_complete fetch` +} + +func (b Bash) Complete(vals []core.KeyVal[string]) string { + var sb strings.Builder + for _, kv := range vals { + sb.WriteString(kv.Key) + if !strings.HasSuffix(kv.Key, "/") && !strings.HasSuffix(kv.Key, "=") { + sb.WriteByte(' ') + } + sb.WriteByte('\n') + } + return sb.String() +} + type Fish struct{} func (f Fish) Name() string {