mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-09 00:48:20 +08:00
Add Shell Completion script generation
`dnsutils shell-completion <shell>` 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`.
This commit is contained in:
parent
cb3c020f45
commit
b91bcf373f
7 changed files with 413 additions and 1 deletions
|
@ -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
|
||||
|
||||
|
|
34
commands/completion-scripts/completion.bash.gotmpl
Normal file
34
commands/completion-scripts/completion.bash.gotmpl
Normal file
|
@ -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}}"
|
20
commands/completion-scripts/completion.zsh.gotmpl
Normal file
20
commands/completion-scripts/completion.zsh.gotmpl
Normal file
|
@ -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}}"
|
91
commands/completion.go
Normal file
91
commands/completion.go
Normal file
|
@ -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
|
||||
}
|
249
commands/completion_test.go
Normal file
249
commands/completion_test.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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/) <sup>[[archived](https://web.archive.org/web/20231015083946/https://thevaluable.dev/zsh-completion-guide-examples/)]</sup>.
|
||||
|
||||
### 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.
|
||||
|
|
1
go.mod
1
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
|
||||
|
|
Loading…
Reference in a new issue