mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-05-11 05:55:12 +00: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:
@ -109,7 +109,7 @@ indent_style = space
|
|||||||
|
|
||||||
# Shell
|
# Shell
|
||||||
# https://google.github.io/styleguide/shell.xml#Indentation
|
# https://google.github.io/styleguide/shell.xml#Indentation
|
||||||
[*.{bash,sh,zsh}]
|
[*.{bash,sh,zsh,*sh.gotmpl}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
indent_style = space
|
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).
|
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
|
## 2. Create a place for the config files
|
||||||
|
|
||||||
Create a directory where you'll store your configuration 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/G-Core/gcore-dns-sdk-go v0.2.6
|
||||||
github.com/fatih/color v1.15.0
|
github.com/fatih/color v1.15.0
|
||||||
github.com/fbiville/markdown-table-formatter v0.3.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/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||||
github.com/kylelemons/godebug v1.1.0
|
github.com/kylelemons/godebug v1.1.0
|
||||||
github.com/mattn/go-isatty v0.0.19
|
github.com/mattn/go-isatty v0.0.19
|
||||||
|
Reference in New Issue
Block a user