From b91bcf373ffda8abaa322b1c04b1caaa2825bd19 Mon Sep 17 00:00:00 2001 From: Tom Whitwell Date: Tue, 20 Jun 2023 16:45:16 +0100 Subject: [PATCH] Add Shell Completion script generation `dnsutils shell-completion ` will generate a shell completion script for the specified shell (bash or zsh). If no shell is specified, the script will be generated for the current shell, using `$SHELL`. --- .editorconfig | 2 +- .../completion-scripts/completion.bash.gotmpl | 34 +++ .../completion-scripts/completion.zsh.gotmpl | 20 ++ commands/completion.go | 91 +++++++ commands/completion_test.go | 249 ++++++++++++++++++ documentation/getting-started.md | 17 ++ go.mod | 1 + 7 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 commands/completion-scripts/completion.bash.gotmpl create mode 100644 commands/completion-scripts/completion.zsh.gotmpl create mode 100644 commands/completion.go create mode 100644 commands/completion_test.go diff --git a/.editorconfig b/.editorconfig index fc14c62c6..3deaa8a71 100644 --- a/.editorconfig +++ b/.editorconfig @@ -109,7 +109,7 @@ indent_style = space # Shell # https://google.github.io/styleguide/shell.xml#Indentation -[*.{bash,sh,zsh}] +[*.{bash,sh,zsh,*sh.gotmpl}] indent_size = 2 indent_style = space diff --git a/commands/completion-scripts/completion.bash.gotmpl b/commands/completion-scripts/completion.bash.gotmpl new file mode 100644 index 000000000..1f04ec597 --- /dev/null +++ b/commands/completion-scripts/completion.bash.gotmpl @@ -0,0 +1,34 @@ +#!/bin/bash + +: "{{.App.Name}}" + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +_dnscontrol_init_completion() { + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +_dnscontrol() { + if [[ "${COMP_WORDS[0]}" != "source" ]]; then + local cur opts base words + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n "=:" || return + else + _dnscontrol_init_completion -n "=:" || return + fi + words=("${words[@]:0:$cword}") + if [[ "$cur" == "-"* ]]; then + requestComp="${words[*]} ${cur} --generate-bash-completion" + else + requestComp="${words[*]} --generate-bash-completion" + fi + opts=$(eval "${requestComp}" 2>/dev/null) + COMPREPLY=($(compgen -W "${opts}" -- ${cur})) + return 0 + fi +} + +complete -o bashdefault -o default -o nospace -F "_dnscontrol" "{{.App.Name}}" diff --git a/commands/completion-scripts/completion.zsh.gotmpl b/commands/completion-scripts/completion.zsh.gotmpl new file mode 100644 index 000000000..665a361ec --- /dev/null +++ b/commands/completion-scripts/completion.zsh.gotmpl @@ -0,0 +1,20 @@ +#compdef "{{.App.Name}}" + +_dnscontrol() { + local -a opts + local cur + cur=${words[-1]} + if [[ "$cur" == "-"* ]]; then + opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-bash-completion)}") + else + opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-bash-completion)}") + fi + + if [[ "${opts[1]}" != "" ]]; then + _describe 'values' opts + else + _files + fi +} + +compdef "_dnscontrol" "{{.App.Name}}" diff --git a/commands/completion.go b/commands/completion.go new file mode 100644 index 000000000..47d00cacf --- /dev/null +++ b/commands/completion.go @@ -0,0 +1,91 @@ +package commands + +import ( + "embed" + "errors" + "fmt" + "github.com/urfave/cli/v2" + "os" + "path" + "strings" + "text/template" +) + +//go:embed completion-scripts/completion.*.gotmpl +var completionScripts embed.FS + +func shellCompletionCommand() *cli.Command { + supportedShells, templates, err := getCompletionSupportedShells() + if err != nil { + panic(err) + } + return &cli.Command{ + Name: "shell-completion", + Usage: "generate shell completion scripts", + Hidden: true, + ArgsUsage: fmt.Sprintf("[ %s ]", strings.Join(supportedShells, " | ")), + Description: fmt.Sprintf("Generate shell completion script for [ %s ]", strings.Join(supportedShells, " | ")), + BashComplete: func(ctx *cli.Context) { + for _, shell := range supportedShells { + if strings.HasPrefix(shell, ctx.Args().First()) { + ctx.App.Writer.Write([]byte(shell + "\n")) + } + } + }, + Action: func(ctx *cli.Context) error { + var inputShell string + if inputShell = ctx.Args().First(); inputShell == "" { + if inputShell = os.Getenv("SHELL"); inputShell == "" { + return cli.Exit(errors.New("shell not specified"), 1) + } + } + shellName := path.Base(inputShell) // necessary if using $SHELL, noop otherwise + + template := templates[shellName] + if template == nil { + return cli.Exit(fmt.Errorf("unknown shell: %s", inputShell), 1) + } + + err = template.Execute(ctx.App.Writer, struct { + App *cli.App + }{ctx.App}) + if err != nil { + return cli.Exit(fmt.Errorf("failed to print completion script: %w", err), 1) + } + return nil + }, + } +} + +var _ = cmd(catUtils, shellCompletionCommand()) + +// getCompletionSupportedShells returns a list of shells with available completions. +// The list is generated from the embedded completion scripts. +func getCompletionSupportedShells() (shells []string, shellCompletionScripts map[string]*template.Template, err error) { + scripts, err := completionScripts.ReadDir("completion-scripts") + if err != nil { + return nil, nil, fmt.Errorf("failed to read completion scripts: %w", err) + } + + shellCompletionScripts = make(map[string]*template.Template) + + for _, f := range scripts { + fNameWithoutExtension := strings.TrimSuffix(f.Name(), ".gotmpl") + shellName := strings.TrimPrefix(path.Ext(fNameWithoutExtension), ".") + + content, err := completionScripts.ReadFile(path.Join("completion-scripts", f.Name())) + if err != nil { + return nil, nil, fmt.Errorf("failed to read completion script %s", f.Name()) + } + + t := template.New(shellName) + t, err = t.Parse(string(content)) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse template %s", f.Name()) + } + + shells = append(shells, shellName) + shellCompletionScripts[shellName] = t + } + return shells, shellCompletionScripts, nil +} diff --git a/commands/completion_test.go b/commands/completion_test.go new file mode 100644 index 000000000..d786ed4c9 --- /dev/null +++ b/commands/completion_test.go @@ -0,0 +1,249 @@ +package commands + +import ( + "bytes" + "fmt" + "github.com/google/go-cmp/cmp" + "strings" + "testing" + "text/template" + + "github.com/urfave/cli/v2" + "golang.org/x/exp/slices" +) + +type shellTestDataItem struct { + shellName string + shellPath string + completionScriptTemplate *template.Template +} + +// setupTestShellCompletionCommand resets the buffers used to capture output and errors from the app. +func setupTestShellCompletionCommand(t *testing.T, app *cli.App) func(t *testing.T) { + return func(t *testing.T) { + app.Writer.(*bytes.Buffer).Reset() + cli.ErrWriter.(*bytes.Buffer).Reset() + } +} + +func TestShellCompletionCommand(t *testing.T) { + app := cli.NewApp() + app.Name = "testing" + + var appWriterBuffer bytes.Buffer + app.Writer = &appWriterBuffer // capture output from app + + var appErrWriterBuffer bytes.Buffer + cli.ErrWriter = &appErrWriterBuffer // capture errors from app (apparently, HandleExitCoder doesn't use app.ErrWriter!?) + + cli.OsExiter = func(int) {} // disable os.Exit call + + app.Commands = []*cli.Command{ + shellCompletionCommand(), + } + + shellsAndCompletionScripts, err := testHelperGetShellsAndCompletionScripts() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(shellsAndCompletionScripts) == 0 { + t.Fatal("no shells found") + } + + invalidShellTestDataItem := shellTestDataItem{ + shellName: "invalid", + shellPath: "/bin/invalid", + } + for _, tt := range shellsAndCompletionScripts { + if tt.shellName == invalidShellTestDataItem.shellName { + t.Fatalf("invalidShellTestDataItem.shellName (%s) is actually a valid shell name", invalidShellTestDataItem.shellName) + } + } + + // Test shell argument + t.Run("shellArg", func(t *testing.T) { + for _, tt := range shellsAndCompletionScripts { + t.Run(tt.shellName, func(t *testing.T) { + tearDownTest := setupTestShellCompletionCommand(t, app) + defer tearDownTest(t) + + err := app.Run([]string{app.Name, "shell-completion", tt.shellName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := appWriterBuffer.String() + want, err := testHelperRenderTemplateFromApp(app, tt.completionScriptTemplate) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + + stderr := appErrWriterBuffer.String() + if stderr != "" { + t.Errorf("want no stderr, got %q", stderr) + } + }) + } + + t.Run(invalidShellTestDataItem.shellName, func(t *testing.T) { + tearDownTest := setupTestShellCompletionCommand(t, app) + defer tearDownTest(t) + + err := app.Run([]string{app.Name, "shell-completion", "invalid"}) + + if err == nil { + t.Fatal("expected error, but didn't get one") + } + + want := fmt.Sprintf("unknown shell: %s", invalidShellTestDataItem.shellName) + got := strings.TrimSpace(appErrWriterBuffer.String()) + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + + stdout := appWriterBuffer.String() + if stdout != "" { + t.Errorf("want no stdout, got %q", stdout) + } + }) + }) + + // Test $SHELL envar + t.Run("$SHELL", func(t *testing.T) { + for _, tt := range shellsAndCompletionScripts { + t.Run(tt.shellName, func(t *testing.T) { + tearDownTest := setupTestShellCompletionCommand(t, app) + defer tearDownTest(t) + + t.Setenv("SHELL", tt.shellPath) + + err := app.Run([]string{app.Name, "shell-completion"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := appWriterBuffer.String() + want, err := testHelperRenderTemplateFromApp(app, tt.completionScriptTemplate) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + + stderr := appErrWriterBuffer.String() + if stderr != "" { + t.Errorf("want no stderr, got %q", stderr) + } + }) + } + + t.Run(invalidShellTestDataItem.shellName, func(t *testing.T) { + tearDownTest := setupTestShellCompletionCommand(t, app) + defer tearDownTest(t) + + t.Setenv("SHELL", invalidShellTestDataItem.shellPath) + + err := app.Run([]string{app.Name, "shell-completion"}) + if err == nil { + t.Fatal("expected error, but didn't get one") + } + + want := fmt.Sprintf("unknown shell: %s", invalidShellTestDataItem.shellPath) + got := strings.TrimSpace(appErrWriterBuffer.String()) + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + + stdout := appWriterBuffer.String() + if stdout != "" { + t.Errorf("want no stdout, got %q", stdout) + } + }) + }) + + // Test shell argument completion (meta) + t.Run("shell-name-completion", func(t *testing.T) { + type testCase struct { + shellArg string + expected []string + } + testCases := []testCase{ + {shellArg: ""}, // empty 'shell' argument, returns all known shells (expected is filled later) + {shellArg: "invalid", expected: []string{""}}, // invalid shell, returns none + } + + for _, tt := range shellsAndCompletionScripts { + testCases[0].expected = append(testCases[0].expected, tt.shellName) + for i, _ := range tt.shellName { + testCases = append(testCases, testCase{ + shellArg: tt.shellName[:i+1], + expected: []string{tt.shellName}, + }) + } + } + + for _, tC := range testCases { + t.Run(tC.shellArg, func(t *testing.T) { + tearDownTest := setupTestShellCompletionCommand(t, app) + defer tearDownTest(t) + app.EnableBashCompletion = true + defer func() { + app.EnableBashCompletion = false + }() + + err := app.Run([]string{app.Name, "shell-completion", tC.shellArg, "--generate-bash-completion"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, line := range strings.Split(strings.TrimSpace(appWriterBuffer.String()), "\n") { + if !slices.Contains(tC.expected, line) { + t.Errorf("%q found, but not expected", line) + } + } + }) + } + }) +} + +// testHelperGetShellsAndCompletionScripts collects all supported shells and their completion scripts and returns them +// as a slice of shellTestDataItem. +// The completion scripts are sourced with getCompletionSupportedShells +func testHelperGetShellsAndCompletionScripts() ([]shellTestDataItem, error) { + shells, templates, err := getCompletionSupportedShells() + if err != nil { + return nil, err + } + + var shellsAndValues []shellTestDataItem + for shellName, t := range templates { + if !slices.Contains(shells, shellName) { + return nil, fmt.Errorf( + `"%s" is not present in slice of shells from getCompletionSupportedShells`, shellName) + } + shellsAndValues = append( + shellsAndValues, + shellTestDataItem{ + shellName: shellName, + shellPath: fmt.Sprintf("/bin/%s", shellName), + completionScriptTemplate: t, + }, + ) + } + return shellsAndValues, nil +} + +// testHelperRenderTemplateFromApp renders a given template with a given app. +// This is used to test the output of the CLI command against a 'known good' value. +func testHelperRenderTemplateFromApp(app *cli.App, scriptTemplate *template.Template) (string, error) { + var scriptBytes bytes.Buffer + err := scriptTemplate.Execute(&scriptBytes, struct { + App *cli.App + }{app}) + + return scriptBytes.String(), err +} diff --git a/documentation/getting-started.md b/documentation/getting-started.md index 9235b3c53..357e5826d 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -54,6 +54,23 @@ git clone https://github.com/StackExchange/dnscontrol If these don't work, more info is in [#805](https://github.com/StackExchange/dnscontrol/issues/805). +## 1.1. Shell Completion + +Shell completion is available for `zsh` and `bash`. + +### zsh + +Add `eval "$(dnscontrol shell-completion zsh)"` to your `~/.zshrc` file. + +This requires completion to be enabled in zsh. A good tutorial for this is available at +[The Valuable Dev](https://thevaluable.dev/zsh-completion-guide-examples/) [[archived](https://web.archive.org/web/20231015083946/https://thevaluable.dev/zsh-completion-guide-examples/)]. + +### bash + +Add `eval "$(dnscontrol shell-completion bash)"` to your `~/.bashrc` file. + +This requires the `bash-completion` package to be installed. See [scop/bash-completion](https://github.com/scop/bash-completion/) for instructions. + ## 2. Create a place for the config files Create a directory where you'll store your configuration files. diff --git a/go.mod b/go.mod index 8b4b32050..97404706b 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/G-Core/gcore-dns-sdk-go v0.2.6 github.com/fatih/color v1.15.0 github.com/fbiville/markdown-table-formatter v0.3.0 + github.com/google/go-cmp v0.5.9 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/kylelemons/godebug v1.1.0 github.com/mattn/go-isatty v0.0.19