From 36b903423828b2e35c9c1fde3fdc898fe2ed23ca Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Sun, 7 May 2017 22:46:38 +0200 Subject: refactor towards new cli.Interface --- pkg/cli/command.go | 63 +++++++++++++++++++++ pkg/cli/interface.go | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++ pkg/cli/query.go | 77 +++++++++++++++---------- pkg/cli/ui.go | 2 + 4 files changed, 269 insertions(+), 30 deletions(-) create mode 100644 pkg/cli/command.go create mode 100644 pkg/cli/interface.go (limited to 'pkg/cli') diff --git a/pkg/cli/command.go b/pkg/cli/command.go new file mode 100644 index 0000000..5b3ded5 --- /dev/null +++ b/pkg/cli/command.go @@ -0,0 +1,63 @@ +/******************************************************************************* +* +* Copyright 2017 Stefan Majewsky +* +* 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 . +* +*******************************************************************************/ + +package cli + +import ( + "fmt" + "io" + "os/exec" + "strings" +) + +//Command describes a command that can be run using the methods in the +//Interface 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(), + ) +} + +func (c Command) run(stdout, stderr io.Writer) error { + cmd := exec.Command(c.Program[0], c.Program[1:]...) + 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/pkg/cli/interface.go b/pkg/cli/interface.go new file mode 100644 index 0000000..7ebde9d --- /dev/null +++ b/pkg/cli/interface.go @@ -0,0 +1,157 @@ +/******************************************************************************* +* +* Copyright 2017 Stefan Majewsky +* +* 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 . +* +*******************************************************************************/ + +package cli + +import ( + "bufio" + "bytes" + "io" + "os" + "sort" + "strings" +) + +//NewInterface creates an Interface instance. +func NewInterface(stdin, stdout, stderr *os.File) (*Interface, error) { + //TODO: check terminal.IsTerminal(int(stdin.Fd())) and choose the TUI instance accordingly + return &Interface{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + stdinBuf: bufio.NewReader(stdin), + tui: &terminalTUI{}, + }, nil +} + +//Interface wraps access to the CLI, including input, output and subprocesses. +type Interface struct { + //TODO: flag isStdinTerminal that disables color output and swaps out the TUI instance + stdin io.Reader + stdout io.Writer + stderr io.Writer + stdinBuf *bufio.Reader + tui TUI + //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.Interface, 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) +} + +func (i *Interface) 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 *Interface) 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 *Interface) 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 *Interface) 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 *Interface) Run(c Command) error { + return c.run(i.safeStdout(), i.stderr) +} + +//CaptureStdout executes the given command on the same stderr and captures its stdout. +func (i *Interface) CaptureStdout(c Command) (string, error) { + var buf bytes.Buffer + err := c.run(&buf, i.stderr) + return string(buf.Bytes()), err +} + +//////////////////////////////////////////////////////////////////////////////// +// output + +//ShowResult displays the result of a computation on stdout. +func (i *Interface) 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 *Interface) ShowResultsSorted(strs []string) { + sort.Strings(strs) + for _, str := range strs { + i.ShowResult(str) + } +} + +//ShowProgress displays a progress message on stderr. +func (i *Interface) ShowProgress(str string) { + i.stderr.Write(StyledText{ + Styled(">> ", AnsiNormal, AnsiBold, AnsiCyan), + Styled(strings.TrimSpace(str)+"\n", AnsiCyan), + }.DisplayString(true)) +} + +//ShowWarning displays a warning message on stderr. +func (i *Interface) ShowWarning(str string) { + i.stderr.Write(StyledText{ + Styled("!! ", AnsiNormal, AnsiBold, AnsiYellow), + Styled(strings.TrimSpace(str)+"\n", AnsiYellow), + }.DisplayString(true)) +} + +//ShowError displays an error message on stderr. +func (i *Interface) ShowError(err string) { + i.stderr.Write(StyledText{ + Styled("!! ", AnsiNormal, AnsiBold, AnsiRed), + Styled(strings.TrimSpace(err)+"\n", AnsiRed), + }.DisplayString(true)) +} + +//ShowUsage displays a usage synopsis on stderr. +func (i *Interface) ShowUsage(str string) { + str = strings.TrimSpace(str) + "\n" + i.stderr.Write([]byte(str)) +} diff --git a/pkg/cli/query.go b/pkg/cli/query.go index 40baaec..1f5d16c 100644 --- a/pkg/cli/query.go +++ b/pkg/cli/query.go @@ -21,29 +21,47 @@ package cli import ( "fmt" "io" - "os" "regexp" "strings" terminal "golang.org/x/crypto/ssh/terminal" ) -//Confirm displays a yes/no question and returns whether the user answered "yes". -func Confirm(question string) bool { - os.Stdout.Write([]byte(strings.TrimSpace(question) + " [y/n] ")) +//cannot use `var errInterrupted = errors.New("Interrupted!")` because golint +//complains about the formatting of the error message +type errInterrupted struct{} - buf := buffer{Input: os.Stdin} +func (e errInterrupted) Error() string { + return "Interrupted!" +} + +type terminalTUI struct { + i *Interface +} + +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": - os.Stdout.Write([]byte("-> yes\n")) - return true + out.Write([]byte("-> yes\n")) + return true, nil case "n", "N": - os.Stdout.Write([]byte("-> no\n")) - return false + out.Write([]byte("-> no\n")) + return false, nil case "\x03": // Ctrl-C - fmt.Fprintln(os.Stderr, "\nInterrupted!") - os.Exit(255) + return false, errInterrupted{} } } } @@ -55,32 +73,32 @@ type Choice struct { Shortcut byte //The display string that describes this choice. Text string + //The string to return from Interface.Query(). + Return string } func (c Choice) hasShortcut() bool { return c.Shortcut != '\000' } -//Query displays a question and a set of answers and allows the user to select -//one of the answers. Returns the selected Choice instance, as well as its -//index in the original choices list (starting from 0). -func Query(prompt string, choices ...Choice) (Choice, int) { +func (t terminalTUI) Query(prompt string, choices ...Choice) (string, error) { if len(choices) == 0 { - return Choice{}, -1 + panic("no choices") } //disable line wrap; unexpected wrapping would confuse our cursor-moving code - os.Stdout.Write([]byte("\x1B[?7l")) - defer os.Stdout.Write([]byte("\x1B[?7h")) + out := t.i.safeStdout() + out.Write([]byte("\x1B[?7l")) + defer out.Write([]byte("\x1B[?7h")) //display question - os.Stdout.Write([]byte(strings.TrimSuffix(prompt, "\n") + "\n")) + out.Write([]byte(strings.TrimSuffix(prompt, "\n") + "\n")) selected := 0 - buf := buffer{Input: os.Stdin} + buf := buffer{Input: t.i.stdin} OUTER: for { - displayChoices(os.Stdout, choices, selected) + displayChoices(out, choices, selected) input := buf.getNextInput() if len(input) == 1 { @@ -107,29 +125,28 @@ OUTER: selected = len(choices) - 1 } case "\x03": // Ctrl-C - fmt.Fprintln(os.Stderr, "Interrupted!") - os.Exit(255) + return "", errInterrupted{} } //prepare to re-render choices - removeDisplayLines(len(choices)) + removeDisplayLines(out, len(choices)) } //clear query display - removeDisplayLines(len(choices) + 1) + removeDisplayLines(out, len(choices)+1) //display question + chosen answer - fmt.Fprintf(os.Stdout, "%s -> %s\n", + fmt.Fprintf(out, "%s -> %s\n", strings.TrimSuffix(prompt, "\n"), strings.TrimSpace(choices[selected].Text), ) - return choices[selected], selected + return choices[selected].Return, nil } -func removeDisplayLines(n int) { +func removeDisplayLines(stdout io.Writer, n int) { for idx := 0; idx < n; idx++ { - os.Stdout.Write([]byte("\x1B[A\x1B[2K")) + stdout.Write([]byte("\x1B[A\x1B[2K")) } } @@ -193,7 +210,7 @@ func (b *buffer) getNextInput() []byte { defer terminal.Restore(0, oldState) //fill buffer some more - n, err := os.Stdin.Read(b.buf[b.fill:]) + n, err := b.Input.Read(b.buf[b.fill:]) if err != nil { panic(err) } diff --git a/pkg/cli/ui.go b/pkg/cli/ui.go index c4eff69..60ae553 100644 --- a/pkg/cli/ui.go +++ b/pkg/cli/ui.go @@ -29,6 +29,8 @@ type AnsiStyle string const ( //AnsiNormal reverts all styles to default. AnsiNormal AnsiStyle = "0" + //AnsiBold makes the text bold. + AnsiBold AnsiStyle = "1" //AnsiInverse swaps foreground and background color. AnsiInverse AnsiStyle = "7" //AnsiBlack is a foreground color. -- cgit v1.3.1