summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2020-03-28 13:53:10 +0100
committerStefan Majewsky <majewsky@gmx.net>2020-03-28 13:53:10 +0100
commited2b9374599cc00467b1c27121318748cc0d3f49 (patch)
tree65e3200a0b69817b24ae772223d1d852a52458af /internal/cli
parent1ff86053ddef8de3247909434e0bd7000c060f2a (diff)
downloadgofu-ed2b9374599cc00467b1c27121318748cc0d3f49.tar.gz
rename pkg/ to internal/
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/command.go70
-rw-r--r--internal/cli/interface.go168
-rw-r--r--internal/cli/query.go295
3 files changed, 533 insertions, 0 deletions
diff --git a/internal/cli/command.go b/internal/cli/command.go
new file mode 100644
index 0000000..a9938d1
--- /dev/null
+++ b/internal/cli/command.go
@@ -0,0 +1,70 @@
+/*******************************************************************************
+*
+* Copyright 2017 Stefan Majewsky <majewsky@gmx.net>
+*
+* This program is free software: you can redistribute it and/or modify it under
+* the terms of the GNU General Public License as published by the Free Software
+* Foundation, either version 3 of the License, or (at your option) any later
+* version.
+*
+* This program is distributed in the hope that it will be useful, but WITHOUT ANY
+* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License along with
+* this program. If not, see <http://www.gnu.org/licenses/>.
+*
+*******************************************************************************/
+
+package cli
+
+import (
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+)
+
+//Command describes a command that can be run using the methods in the
+//Implementation interface.
+type Command struct {
+ Program []string
+ WorkDir string
+}
+
+type commandError struct {
+ Cmd Command
+ Err error
+}
+
+func (e commandError) Error() string {
+ cmdline := strings.Join(e.Cmd.Program, " ")
+ if e.Cmd.WorkDir == "" {
+ return fmt.Sprintf("exec `%s`: %s",
+ cmdline, e.Err.Error(),
+ )
+ }
+ return fmt.Sprintf("exec `%s` in %s: %s",
+ cmdline, e.Cmd.WorkDir, e.Err.Error(),
+ )
+}
+
+//CommandRunner is a function that can execute commands given to it.
+//This interface is only useful for unit tests; the default CommandRunner
+//suffices for all regular operation.
+type CommandRunner func(c Command, stdin io.Reader, stdout, stderr io.Writer) error
+
+//DefaultCommandRunner is a CommandRunner that actually executes the command.
+func DefaultCommandRunner(c Command, stdin io.Reader, stdout, stderr io.Writer) error {
+ cmd := exec.Command(c.Program[0], c.Program[1:]...)
+ cmd.Stdin = stdin
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ cmd.Dir = c.WorkDir
+
+ err := cmd.Run()
+ if err != nil {
+ err = commandError{c, err}
+ }
+ return err
+}
diff --git a/internal/cli/interface.go b/internal/cli/interface.go
new file mode 100644
index 0000000..a88212b
--- /dev/null
+++ b/internal/cli/interface.go
@@ -0,0 +1,168 @@
+/*******************************************************************************
+*
+* Copyright 2017 Stefan Majewsky <majewsky@gmx.net>
+*
+* This program is free software: you can redistribute it and/or modify it under
+* the terms of the GNU General Public License as published by the Free Software
+* Foundation, either version 3 of the License, or (at your option) any later
+* version.
+*
+* This program is distributed in the hope that it will be useful, but WITHOUT ANY
+* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License along with
+* this program. If not, see <http://www.gnu.org/licenses/>.
+*
+*******************************************************************************/
+
+package cli
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "sort"
+ "strings"
+
+ "golang.org/x/crypto/ssh/terminal"
+)
+
+//Interface wraps access to the CLI, including input, output and subprocesses.
+var Interface *Implementation
+
+func init() {
+ SetupInterface(os.Stdin, os.Stdout, os.Stderr, DefaultCommandRunner)
+}
+
+//SetupInterface prepares the Interface instance with nonstandard file streams
+//or a nonstandard CommandRunner. This is only required for unit tests.
+func SetupInterface(stdin io.Reader, stdout, stderr io.Writer, commandRunner CommandRunner) {
+ Interface = &Implementation{
+ stdin: stdin,
+ stdout: stdout,
+ stderr: stderr,
+ stdinBuf: bufio.NewReader(stdin),
+ commandRunner: commandRunner,
+ }
+
+ if stdinFile, ok := stdin.(*os.File); ok && terminal.IsTerminal(int(stdinFile.Fd())) {
+ Interface.tui = &terminalTUI{Interface}
+ } else {
+ Interface.tui = &pipeTUI{Interface}
+ }
+}
+
+//Implementation wraps access to the CLI, including input, output and subprocesses.
+type Implementation struct {
+ stdin io.Reader
+ stdout io.Writer
+ stderr io.Writer
+ stdinBuf *bufio.Reader
+ tui TUI
+ commandRunner CommandRunner
+ //If this flag is set, only ShowResult() will write into stdout; everything
+ //else that usually goes to stdout goes to stderr instead.
+ //
+ //This is useful when gofu is expected to output a certain value to stdout
+ //which is used by the next program in the pipe, and additional output from
+ //subprocesses could confuse the stdout handler.
+ StdoutProtected bool
+}
+
+//TUI provides the interactive parts of the cli.Implementation, so that these can be
+//easily swapped out for mock implementations in unit tests.
+type TUI interface {
+ //ReadLine reads a line from stdin (if tty: uses canonical mode).
+ ReadLine(prompt string) (string, error)
+ //Confirm displays a yes/no question and returns whether the user answered "yes".
+ Confirm(question string) (bool, error)
+ //Query displays a question and a set of answers and allows the user to select
+ //one of the answers. Returns the Return attribute of the selected Choice.
+ Query(prompt string, choices ...Choice) (string, error)
+ //Print writes the given string (potentially including ANSI escape codes) to
+ //the given writer. At this point, it can be decided whether to strip out the
+ //ANSI escape codes.
+ Print(w io.Writer, msg string)
+}
+
+func (i *Implementation) safeStdout() io.Writer {
+ if i.StdoutProtected {
+ return i.stderr
+ }
+ return i.stdout
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// input
+
+//ReadLine reads a line from stdin (if tty: uses canonical mode).
+func (i *Implementation) ReadLine(prompt string) (string, error) {
+ return i.tui.ReadLine(prompt)
+}
+
+//Confirm displays a yes/no question and returns whether the user answered "yes".
+func (i *Implementation) Confirm(question string) (bool, error) {
+ return i.tui.Confirm(question)
+}
+
+//Query displays a question and a set of answers and allows the user to select
+//one of the answers. Returns the Return attribute of the selected Choice.
+func (i *Implementation) Query(prompt string, choices ...Choice) (string, error) {
+ return i.tui.Query(prompt, choices...)
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// subprocesses
+
+//Run executes the given command on the same stdout and stderr.
+func (i *Implementation) Run(c Command) error {
+ return i.commandRunner(c, nil, i.safeStdout(), i.stderr)
+}
+
+//CaptureStdout executes the given command on the same stderr and captures its stdout.
+func (i *Implementation) CaptureStdout(c Command) (string, error) {
+ var buf bytes.Buffer
+ err := i.commandRunner(c, nil, &buf, i.stderr)
+ return string(buf.Bytes()), err
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// output
+
+//ShowResult displays the result of a computation on stdout.
+func (i *Implementation) ShowResult(str string) {
+ str = strings.TrimSpace(str) + "\n"
+ i.stdout.Write([]byte(str))
+}
+
+//ShowResultsSorted calls ShowResult() on each of the results after sorting them.
+func (i *Implementation) ShowResultsSorted(strs []string) {
+ sort.Strings(strs)
+ for _, str := range strs {
+ i.ShowResult(str)
+ }
+}
+
+//ShowProgress displays a progress message on stderr.
+func (i *Implementation) ShowProgress(str string) {
+ i.tui.Print(i.stderr, fmt.Sprintf("\x1B[0;1;36m>>\x1B[0;36m %s\x1B[0m\n", strings.TrimSpace(str)))
+}
+
+//ShowWarning displays a warning message on stderr.
+func (i *Implementation) ShowWarning(str string) {
+ i.tui.Print(i.stderr, fmt.Sprintf("\x1B[0;1;33m!!\x1B[0;33m %s\x1B[0m\n", strings.TrimSpace(str)))
+}
+
+//ShowError displays an error message on stderr.
+func (i *Implementation) ShowError(str string) {
+ i.tui.Print(i.stderr, fmt.Sprintf("\x1B[0;1;31m!!\x1B[0;31m %s\x1B[0m\n", strings.TrimSpace(str)))
+}
+
+//ShowUsage displays a usage synopsis on stderr.
+func (i *Implementation) ShowUsage(str string) {
+ str = strings.TrimSpace(str) + "\n"
+ i.stderr.Write([]byte(str))
+}
diff --git a/internal/cli/query.go b/internal/cli/query.go
new file mode 100644
index 0000000..add170c
--- /dev/null
+++ b/internal/cli/query.go
@@ -0,0 +1,295 @@
+/*******************************************************************************
+*
+* Copyright 2017 Stefan Majewsky <majewsky@gmx.net>
+*
+* This program is free software: you can redistribute it and/or modify it under
+* the terms of the GNU General Public License as published by the Free Software
+* Foundation, either version 3 of the License, or (at your option) any later
+* version.
+*
+* This program is distributed in the hope that it will be useful, but WITHOUT ANY
+* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License along with
+* this program. If not, see <http://www.gnu.org/licenses/>.
+*
+*******************************************************************************/
+
+package cli
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+
+ terminal "golang.org/x/crypto/ssh/terminal"
+)
+
+//cannot use `var errInterrupted = errors.New("Interrupted!")` because golint
+//complains about the formatting of the error message
+type errInterrupted struct{}
+
+func (e errInterrupted) Error() string {
+ return "Interrupted!"
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// TUI implementation for when stdin is a terminal
+
+type terminalTUI struct {
+ i *Implementation
+}
+
+func (t terminalTUI) Print(w io.Writer, msg string) {
+ w.Write([]byte(msg))
+}
+
+func (t terminalTUI) ReadLine(prompt string) (string, error) {
+ if prompt != "" {
+ t.i.safeStdout().Write([]byte(strings.TrimSpace(prompt) + " "))
+ }
+ str, err := t.i.stdinBuf.ReadString('\n')
+ return strings.TrimSpace(str), err
+}
+
+func (t terminalTUI) Confirm(question string) (bool, error) {
+ out := t.i.safeStdout()
+ out.Write([]byte(strings.TrimSpace(question) + " [y/n] "))
+
+ buf := buffer{Input: t.i.stdin}
+ for {
+ switch string(buf.getNextInput()) {
+ case "y", "Y":
+ out.Write([]byte("-> yes\n"))
+ return true, nil
+ case "n", "N":
+ out.Write([]byte("-> no\n"))
+ return false, nil
+ case "\x03": // Ctrl-C
+ return false, errInterrupted{}
+ }
+ }
+}
+
+//Choice is a thing that the user can choose during Query().
+type Choice struct {
+ //If given, the choice can be selected by pressing the key that
+ //produces this character.
+ Shortcut byte
+ //The display string that describes this choice.
+ Text string
+ //The string to return from Implementation.Query().
+ Return string
+}
+
+func (c Choice) hasShortcut() bool {
+ return c.Shortcut != '\000'
+}
+
+func (t terminalTUI) Query(prompt string, choices ...Choice) (string, error) {
+ if len(choices) == 0 {
+ panic("no choices")
+ }
+
+ //disable line wrap; unexpected wrapping would confuse our cursor-moving code
+ out := t.i.safeStdout()
+ out.Write([]byte("\x1B[?7l"))
+ defer out.Write([]byte("\x1B[?7h"))
+
+ //display question
+ out.Write([]byte(strings.TrimSuffix(prompt, "\n") + "\n"))
+ selected := 0
+
+ buf := buffer{Input: t.i.stdin}
+OUTER:
+ for {
+ displayChoices(out, choices, selected)
+
+ input := buf.getNextInput()
+ if len(input) == 1 {
+ //single character entered - check if a shortcut matches
+ for idx, choice := range choices {
+ if choice.Shortcut == input[0] {
+ selected = idx
+ break OUTER
+ }
+ }
+ }
+
+ switch string(input) {
+ case "\r", "\n":
+ break OUTER
+ case "\x1B[A": // Up arrow key
+ selected--
+ if selected < 0 {
+ selected = 0
+ }
+ case "\x1B[B": // Down arrow key
+ selected++
+ if selected >= len(choices) {
+ selected = len(choices) - 1
+ }
+ case "\x03": // Ctrl-C
+ return "", errInterrupted{}
+ }
+
+ //prepare to re-render choices
+ removeDisplayLines(out, len(choices))
+ }
+
+ //clear query display
+ removeDisplayLines(out, len(choices)+1)
+
+ //display question + chosen answer
+ fmt.Fprintf(out, "%s -> %s\n",
+ strings.TrimSuffix(prompt, "\n"),
+ strings.TrimSpace(choices[selected].Text),
+ )
+
+ return choices[selected].Return, nil
+}
+
+func removeDisplayLines(stdout io.Writer, n int) {
+ for idx := 0; idx < n; idx++ {
+ stdout.Write([]byte("\x1B[A\x1B[2K"))
+ }
+}
+
+func displayChoices(out io.Writer, choices []Choice, selectedIndex int) {
+ hasShortcuts := false
+ for _, choice := range choices {
+ if choice.hasShortcut() {
+ hasShortcuts = true
+ break
+ }
+ }
+
+ for idx, choice := range choices {
+ text := " " + strings.TrimSpace(choice.Text) + " \n"
+ if hasShortcuts {
+ shortcut := choice.Shortcut
+ if !choice.hasShortcut() {
+ shortcut = ' '
+ }
+ text = fmt.Sprintf(" [%c]%s", shortcut, text)
+ }
+
+ if idx == selectedIndex {
+ fmt.Fprintf(out, "\x1B[0;7m%s\x1B[0m", text)
+ } else {
+ out.Write([]byte(text))
+ }
+ }
+}
+
+var ansiEscapeRx = regexp.MustCompile(`^\x1B\[[\x20-\x3F]*[\x40-\x7E]`)
+
+type buffer struct {
+ Input io.Reader
+ buf [128]byte
+ fill int
+}
+
+func (b *buffer) getNextInput() []byte {
+ //do we have a simple input character?
+ if b.fill > 0 && b.buf[0] != '\x1B' {
+ result := append([]byte(nil), b.buf[0])
+ copy(b.buf[0:], b.buf[1:])
+ b.fill--
+ return result
+ }
+
+ //do we have a full ANSI escape sequence?
+ match := ansiEscapeRx.Find(b.buf[0:b.fill])
+ if match != nil {
+ result := append([]byte(nil), match...)
+ copy(b.buf[0:], b.buf[len(match):])
+ b.fill -= len(match)
+ return result
+ }
+
+ oldState, err := terminal.MakeRaw(0)
+ if err != nil {
+ panic(err)
+ }
+ defer terminal.Restore(0, oldState)
+
+ //fill buffer some more
+ n, err := b.Input.Read(b.buf[b.fill:])
+ if err != nil {
+ panic(err)
+ }
+ b.fill += n
+
+ return b.getNextInput() //restart
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// TUI implementation for when stdin is a pipe (also used in unit tests)
+
+type pipeTUI struct {
+ i *Implementation
+}
+
+//AnsiColorCodeRx is a regexp that matches ANSI escape sequences of the type SGR.
+var AnsiColorCodeRx = regexp.MustCompile("\x1B" + `\[[0-9;]*m`)
+
+func (t *pipeTUI) Print(w io.Writer, msg string) {
+ w.Write([]byte(AnsiColorCodeRx.ReplaceAllString(msg, "")))
+}
+
+func (t *pipeTUI) ReadLine(prompt string) (string, error) {
+ str, err := t.i.stdinBuf.ReadString('\n')
+ str = strings.TrimSpace(str)
+ if err == nil {
+ fmt.Fprintf(t.i.stderr, "%s %s\n", strings.TrimSpace(prompt), str)
+ }
+ return str, err
+}
+
+func (t *pipeTUI) Confirm(question string) (bool, error) {
+ question = strings.TrimSpace(question)
+ str, err := t.ReadLine(question)
+ if err != nil {
+ return false, err
+ }
+ //recognize /[01tTfF]|true|false/
+ ok, err := strconv.ParseBool(str)
+ if err != nil {
+ //recognize /[yY]|yes|no/
+ ok = strings.HasPrefix(question, "y") || strings.HasPrefix(question, "Y")
+ }
+ fmt.Fprintf(t.i.stderr, "%s -> %v (%s)\n", question, ok, str)
+ return ok, nil
+}
+
+//Query for the pipe TUI is limited to exact matches of the choice text, or
+//matching by choice shortcut.
+func (t *pipeTUI) Query(prompt string, choices ...Choice) (string, error) {
+ str, err := t.i.stdinBuf.ReadString('\n')
+ if err != nil {
+ return str, err
+ }
+ str = strings.TrimSpace(str)
+
+ //prefer exact match on choice.Text
+ for _, choice := range choices {
+ if choice.Text == str {
+ fmt.Fprintf(t.i.stderr, "%s -> %s\n", prompt, choice.Text)
+ return choice.Return, nil
+ }
+ }
+ //allow match on choice.Shortcut
+ for _, choice := range choices {
+ if string(choice.Shortcut) == str {
+ fmt.Fprintf(t.i.stderr, "%s -> %s\n", prompt, choice.Text)
+ return choice.Return, nil
+ }
+ }
+ fmt.Fprintf(t.i.stderr, "%s -> [%s]\n", prompt, str)
+ return "", errors.New("cannot match input with available choices")
+}