1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

Merge branch 'master' into tlim_newtxt

This commit is contained in:
Tom Limoncelli
2023-11-01 13:36:01 -04:00
14 changed files with 602 additions and 94 deletions

View File

@@ -88,6 +88,24 @@ func Run(v string) int {
sort.Sort(cli.CommandsByName(commands))
app.Commands = commands
app.EnableBashCompletion = true
app.BashComplete = func(cCtx *cli.Context) {
// ripped from cli.DefaultCompleteWithFlags
var lastArg string
if len(os.Args) > 2 {
lastArg = os.Args[len(os.Args)-2]
}
if lastArg != "" {
if strings.HasPrefix(lastArg, "-") {
if !islastFlagComplete(lastArg, app.Flags) {
dnscontrolPrintFlagSuggestions(lastArg, app.Flags, cCtx.App.Writer)
return
}
}
}
dnscontrolPrintCommandSuggestions(app.Commands, cCtx.App.Writer)
}
if err := app.Run(os.Args); err != nil {
return 1
}

View 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}}"

View 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}}"

170
commands/completion.go Normal file
View File

@@ -0,0 +1,170 @@
package commands
import (
"embed"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
"text/template"
"unicode/utf8"
"github.com/urfave/cli/v2"
)
//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
}
func dnscontrolPrintCommandSuggestions(commands []*cli.Command, writer io.Writer) {
for _, command := range commands {
if command.Hidden {
continue
}
if strings.HasSuffix(os.Getenv("SHELL"), "zsh") {
for _, name := range command.Names() {
_, _ = fmt.Fprintf(writer, "%s:%s\n", name, command.Usage)
}
} else {
for _, name := range command.Names() {
_, _ = fmt.Fprintf(writer, "%s\n", name)
}
}
}
}
func dnscontrolCliArgContains(flagName string) bool {
for _, name := range strings.Split(flagName, ",") {
name = strings.TrimSpace(name)
count := utf8.RuneCountInString(name)
if count > 2 {
count = 2
}
flag := fmt.Sprintf("%s%s", strings.Repeat("-", count), name)
for _, a := range os.Args {
if a == flag {
return true
}
}
}
return false
}
func dnscontrolPrintFlagSuggestions(lastArg string, flags []cli.Flag, writer io.Writer) {
cur := strings.TrimPrefix(lastArg, "-")
cur = strings.TrimPrefix(cur, "-")
for _, flag := range flags {
if bflag, ok := flag.(*cli.BoolFlag); ok && bflag.Hidden {
continue
}
for _, name := range flag.Names() {
name = strings.TrimSpace(name)
// this will get total count utf8 letters in flag name
count := utf8.RuneCountInString(name)
if count > 2 {
count = 2 // reuse this count to generate single - or -- in flag completion
}
// if flag name has more than one utf8 letter and last argument in cli has -- prefix then
// skip flag completion for short flags example -v or -x
if strings.HasPrefix(lastArg, "--") && count == 1 {
continue
}
// match if last argument matches this flag and it is not repeated
if strings.HasPrefix(name, cur) && cur != name && !dnscontrolCliArgContains(name) {
flagCompletion := fmt.Sprintf("%s%s", strings.Repeat("-", count), name)
_, _ = fmt.Fprintln(writer, flagCompletion)
}
}
}
}
func islastFlagComplete(lastArg string, flags []cli.Flag) bool {
cur := strings.TrimPrefix(lastArg, "-")
cur = strings.TrimPrefix(cur, "-")
for _, flag := range flags {
for _, name := range flag.Names() {
name = strings.TrimSpace(name)
if strings.HasPrefix(name, cur) && cur != name && !dnscontrolCliArgContains(name) {
return false
}
}
}
return true
}

249
commands/completion_test.go Normal file
View 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
}

View File

@@ -822,7 +822,7 @@ declare function DOMAIN_ELSEWHERE(name: string, registrar: string, nameserver_na
*
* @see https://docs.dnscontrol.org/language-reference/top-level-functions/domain_elsewhere_auto
*/
declare function DOMAIN_ELSEWHERE_AUTO(name: string, domain: string, registrar: string, dnsProvider: string): void;
declare function DOMAIN_ELSEWHERE_AUTO(name: string, registrar: string, dnsProvider: string): void;
/**
* DS adds a DS record to the domain.