diff options
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/command.go | 70 | ||||
| -rw-r--r-- | internal/cli/interface.go | 168 | ||||
| -rw-r--r-- | internal/cli/query.go | 295 |
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") +} |
