diff --git a/pkg/cli/clisqlshell/BUILD.bazel b/pkg/cli/clisqlshell/BUILD.bazel index d6186e459dd3..6baad29004ea 100644 --- a/pkg/cli/clisqlshell/BUILD.bazel +++ b/pkg/cli/clisqlshell/BUILD.bazel @@ -7,6 +7,9 @@ go_library( "api.go", "context.go", "doc.go", + "editor.go", + "editor_bufio.go", + "editor_editline.go", "parser.go", "sql.go", "statement_diag.go", diff --git a/pkg/cli/clisqlshell/context.go b/pkg/cli/clisqlshell/context.go index 02a2da0f4701..e0061eae2dfc 100644 --- a/pkg/cli/clisqlshell/context.go +++ b/pkg/cli/clisqlshell/context.go @@ -91,10 +91,6 @@ type internalContext struct { // current database name, if known. This is maintained on a best-effort basis. dbName string - // displayPrompt indicates that the prompt should still be displayed, - // even when the line editor is disabled. - displayPrompt bool - // hook to run once, then clear, after running the next batch of statements. afterRun func() diff --git a/pkg/cli/clisqlshell/editor.go b/pkg/cli/clisqlshell/editor.go new file mode 100644 index 000000000000..6417c1f0871a --- /dev/null +++ b/pkg/cli/clisqlshell/editor.go @@ -0,0 +1,38 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package clisqlshell + +import "os" + +// editor is the interface between the shell and a line editor. +type editor interface { + init(win, wout, werr *os.File, sqlS sqlShell, maxHistEntries int, histFile string) (cleanupFn func(), err error) + errInterrupted() error + getOutputStream() *os.File + getLine() (string, error) + addHistory(line string) error + canPrompt() bool + setPrompt(prompt string) +} + +type sqlShell interface { + inCopy() bool + runShowCompletions(sql string, offset int) (rows [][]string, err error) + serverSideParse(sql string) (string, error) +} + +// getEditor instantiates an editor compatible with the current configuration. +func getEditor(useEditor bool, displayPrompt bool) editor { + if !useEditor { + return &bufioReader{displayPrompt: displayPrompt} + } + return &editlineReader{} +} diff --git a/pkg/cli/clisqlshell/editor_bufio.go b/pkg/cli/clisqlshell/editor_bufio.go new file mode 100644 index 000000000000..4ab2d8e51987 --- /dev/null +++ b/pkg/cli/clisqlshell/editor_bufio.go @@ -0,0 +1,82 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package clisqlshell + +import ( + "bufio" + "fmt" + "io" + "os" + + "github.com/cockroachdb/errors" +) + +// bufioReader implements the editor interface. +type bufioReader struct { + wout *os.File + displayPrompt bool + prompt string + buf *bufio.Reader +} + +var _ editor = (*bufioReader)(nil) + +func (b *bufioReader) init( + win, wout, werr *os.File, _ sqlShell, maxHistEntries int, histFile string, +) (cleanupFn func(), err error) { + b.wout = wout + b.buf = bufio.NewReader(win) + return func() {}, nil +} + +var errBufioInterrupted = errors.New("never happens") + +func (b *bufioReader) errInterrupted() error { + return errBufioInterrupted +} + +func (b *bufioReader) getOutputStream() *os.File { + return b.wout +} + +func (b *bufioReader) addHistory(line string) error { + return nil +} + +func (b *bufioReader) canPrompt() bool { + return b.displayPrompt +} + +func (b *bufioReader) setPrompt(prompt string) { + if b.displayPrompt { + b.prompt = prompt + } +} + +func (b *bufioReader) getLine() (string, error) { + fmt.Fprint(b.wout, b.prompt) + l, err := b.buf.ReadString('\n') + // bufio.ReadString() differs from readline.Readline in the handling of + // EOF. Readline only returns EOF when there is nothing left to read and + // there is no partial line while bufio.ReadString() returns EOF when the + // end of input has been reached but will return the non-empty partial line + // as well. We work around this by converting the bufioReader behavior to match + // the Readline behavior. + if err == io.EOF && len(l) != 0 { + err = nil + } else if err == nil { + // From the bufio.ReadString docs: ReadString returns err != nil if and + // only if the returned data does not end in delim. To match the behavior + // of readline.Readline, we strip off the trailing delimiter. + l = l[:len(l)-1] + } + return l, err +} diff --git a/pkg/cli/clisqlshell/editor_editline.go b/pkg/cli/clisqlshell/editor_editline.go new file mode 100644 index 000000000000..dfa863037a6a --- /dev/null +++ b/pkg/cli/clisqlshell/editor_editline.go @@ -0,0 +1,155 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package clisqlshell + +import ( + "fmt" + "os" + "strings" + + "github.com/cockroachdb/cockroach/pkg/cli/clierror" + "github.com/cockroachdb/errors" + readline "github.com/knz/go-libedit" +) + +// editlineReader implements the editor interface. +type editlineReader struct { + wout *os.File + sql sqlShell + prompt string + ins readline.EditLine +} + +var _ editor = (*editlineReader)(nil) + +func (b *editlineReader) init( + win, wout, werr *os.File, sqlS sqlShell, maxHistEntries int, histFile string, +) (cleanupFn func(), err error) { + cleanupFn = func() {} + + b.ins, err = readline.InitFiles("cockroach", + true /* wideChars */, win, wout, werr) + if errors.Is(err, readline.ErrWidecharNotSupported) { + fmt.Fprintln(werr, "warning: wide character support disabled") + b.ins, err = readline.InitFiles("cockroach", + false, win, wout, werr) + } + if err != nil { + return cleanupFn, err + } + cleanupFn = func() { b.ins.Close() } + b.wout = b.ins.Stdout() + b.sql = sqlS + b.ins.SetCompleter(b) + + // If the user has used bind -v or bind -l in their ~/.editrc, + // this will reset the standard bindings. However we really + // want in this shell that Ctrl+C, tab, Ctrl+Z and Ctrl+R + // always have the same meaning. So reload these bindings + // explicitly no matter what ~/.editrc may have changed. + b.ins.RebindControlKeys() + + if err := b.ins.UseHistory(maxHistEntries, true /*dedup*/); err != nil { + fmt.Fprintf(werr, "warning: cannot enable history: %v\n ", err) + } else if histFile != "" { + err = b.ins.LoadHistory(histFile) + if err != nil { + fmt.Fprintf(werr, "warning: cannot load the command-line history (file corrupted?): %v\n", err) + fmt.Fprintf(werr, "note: the history file will be cleared upon first entry\n") + } + // SetAutoSaveHistory() does two things: + // - it preserves the name of the history file, for use + // by the final SaveHistory() call. + // - it decides whether to save the history to file upon + // every new command. + // We disable the latter, since a history file can grow somewhat + // large and we don't want the excess I/O latency to be interleaved + // in-between every command. + b.ins.SetAutoSaveHistory(histFile, false) + prevCleanup := cleanupFn + cleanupFn = func() { + if err := b.ins.SaveHistory(); err != nil { + fmt.Fprintf(werr, "warning: cannot save command-line history: %v\n", err) + } + prevCleanup() + } + } + + return cleanupFn, nil +} + +func (b *editlineReader) errInterrupted() error { + return readline.ErrInterrupted +} + +func (b *editlineReader) getOutputStream() *os.File { + return b.wout +} + +func (b *editlineReader) addHistory(line string) error { + return b.ins.AddHistory(line) +} + +func (b *editlineReader) canPrompt() bool { + return true +} + +func (b *editlineReader) setPrompt(prompt string) { + b.prompt = prompt + b.ins.SetLeftPrompt(prompt) +} + +func (b *editlineReader) GetCompletions(word string) []string { + if b.sql.inCopy() { + return []string{word + "\t"} + } + sql, offset := b.ins.GetLineInfo() + if !strings.HasSuffix(sql, "??") { + rows, err := b.sql.runShowCompletions(sql, offset) + if err != nil { + clierror.OutputError(b.wout, err, true /*showSeverity*/, false /*verbose*/) + } + + var completions []string + for _, row := range rows { + completions = append(completions, row[0]) + } + + return completions + } + + helpText, err := b.sql.serverSideParse(sql) + if helpText != "" { + // We have a completion suggestion. Use that. + fmt.Fprintf(b.wout, "\nSuggestion:\n%s\n", helpText) + } else if err != nil { + // Some other error. Display it. + fmt.Fprintln(b.wout) + clierror.OutputError(b.wout, err, true /*showSeverity*/, false /*verbose*/) + } + + // After a suggestion or error, redisplay the prompt and current entry. + fmt.Fprint(b.wout, b.prompt, sql) + return nil +} + +func (b *editlineReader) getLine() (string, error) { + l, err := b.ins.GetLine() + if len(l) > 0 && l[len(l)-1] == '\n' { + // Strip the final newline. + l = l[:len(l)-1] + } else { + // There was no newline at the end of the input + // (e.g. Ctrl+C was entered). Force one. + fmt.Fprintln(b.wout) + } + return l, err +} diff --git a/pkg/cli/clisqlshell/sql.go b/pkg/cli/clisqlshell/sql.go index b3e3c1a9a9a8..ba6fae0c29f2 100644 --- a/pkg/cli/clisqlshell/sql.go +++ b/pkg/cli/clisqlshell/sql.go @@ -43,7 +43,6 @@ import ( "github.com/cockroachdb/cockroach/pkg/util/envutil" "github.com/cockroachdb/cockroach/pkg/util/sysutil" "github.com/cockroachdb/errors" - readline "github.com/knz/go-libedit" ) const ( @@ -137,10 +136,8 @@ type cliState struct { iCtx *internalContext conn clisqlclient.Conn - // ins is used to read lines if isInteractive is true. - ins readline.EditLine - // buf is used to read lines if isInteractive is false. - buf *bufio.Reader + // ins is used to read lines. + ins editor // singleStatement is set to true when this state level // is currently processing for runString(). In that mode: // - a missing semicolon at the end of input is ignored: @@ -269,21 +266,13 @@ func (c *cliState) printCliHelp() { fmt.Fprintln(c.iCtx.stdout) } -const noLineEditor readline.EditLine = -1 - -func (c *cliState) hasEditor() bool { - return c.ins != noLineEditor -} - // addHistory persists a line of input to the readline history file. func (c *cliState) addHistory(line string) { - if !c.hasEditor() || len(line) == 0 { + if len(line) == 0 { return } - // ins.AddHistory will push command into memory. err can - // be not nil only if it got a memory error. - if err := c.ins.AddHistory(line); err != nil { + if err := c.ins.addHistory(line); err != nil { fmt.Fprintf(c.iCtx.stderr, "warning: cannot add entry to history: %v\n", err) } } @@ -798,7 +787,7 @@ const unknownTxnStatus = " ?" // doRefreshPrompts refreshes the prompts of the client depending on the // status of the current transaction. func (c *cliState) doRefreshPrompts(nextState cliStateEnum) cliStateEnum { - if !c.iCtx.displayPrompt { + if !c.ins.canPrompt() { return nextState } @@ -812,9 +801,7 @@ func (c *cliState) doRefreshPrompts(nextState cliStateEnum) cliStateEnum { c.continuePrompt = strings.Repeat(" ", len(c.fullPrompt)-3) + "-> " } - if c.hasEditor() { - c.ins.SetLeftPrompt(c.continuePrompt) - } + c.ins.setPrompt(c.continuePrompt) return nextState } @@ -880,9 +867,7 @@ func (c *cliState) doRefreshPrompts(nextState cliStateEnum) cliStateEnum { c.currentPrompt = c.fullPrompt // Configure the editor to use the new prompt. - if c.hasEditor() { - c.ins.SetLeftPrompt(c.currentPrompt) - } + c.ins.setPrompt(c.currentPrompt) return nextState } @@ -953,49 +938,14 @@ func (c *cliState) refreshDatabaseName() string { var cmdHistFile = envutil.EnvOrDefaultString("COCKROACH_SQL_CLI_HISTORY", ".cockroachsql_history") -// GetCompletions implements the readline.CompletionGenerator interface. -func (c *cliState) GetCompletions(s string) []string { - sql, _ := c.ins.GetLineInfo() - - // In COPY mode, just add a tab character. - if c.inCopy() { - return []string{s + "\t"} - } - - if !strings.HasSuffix(sql, "??") { - query := fmt.Sprintf(`SHOW COMPLETIONS AT OFFSET %d FOR %s`, len(sql), lexbase.EscapeSQLString(sql)) - var rows [][]string +func (c *cliState) runShowCompletions(sql string, offset int) (rows [][]string, err error) { + query := fmt.Sprintf(`SHOW COMPLETIONS AT OFFSET %d FOR %s`, offset, lexbase.EscapeSQLString(sql)) + err = c.runWithInterruptableCtx(func(ctx context.Context) error { var err error - err = c.runWithInterruptableCtx(func(ctx context.Context) error { - _, rows, err = c.sqlExecCtx.RunQuery(ctx, c.conn, clisqlclient.MakeQuery(query), true /* showMoreChars */) - return err - }) - - if err != nil { - clierror.OutputError(c.iCtx.stdout, err, true /*showSeverity*/, false /*verbose*/) - } - - var completions []string - for _, row := range rows { - completions = append(completions, row[0]) - } - - return completions - } - - helpText, err := c.serverSideParse(sql) - if helpText != "" { - // We have a completion suggestion. Use that. - fmt.Fprintf(c.iCtx.stdout, "\nSuggestion:\n%s\n", helpText) - } else if err != nil { - // Some other error. Display it. - fmt.Fprintln(c.iCtx.stdout) - clierror.OutputError(c.iCtx.stdout, err, true /*showSeverity*/, false /*verbose*/) - } - - // After the suggestion or error, re-display the prompt and current entry. - fmt.Fprint(c.iCtx.stdout, c.currentPrompt, sql) - return nil + _, rows, err = c.sqlExecCtx.RunQuery(ctx, c.conn, clisqlclient.MakeQuery(query), true /* showMoreChars */) + return err + }) + return rows, err } func (c *cliState) doStart(nextState cliStateEnum) cliStateEnum { @@ -1040,42 +990,7 @@ func (c *cliState) doReadLine(nextState cliStateEnum) cliStateEnum { return nextState } - var l string - var err error - if c.buf == nil { - l, err = c.ins.GetLine() - if len(l) > 0 && l[len(l)-1] == '\n' { - // Strip the final newline. - l = l[:len(l)-1] - } else { - // There was no newline at the end of the input - // (e.g. Ctrl+C was entered). Force one. - fmt.Fprintln(c.iCtx.stdout) - } - } else { - if c.iCtx.displayPrompt { - prompt := c.currentPrompt - if c.useContinuePrompt { - prompt = c.continuePrompt - } - fmt.Fprint(c.iCtx.stdout, prompt) - } - l, err = c.buf.ReadString('\n') - // bufio.ReadString() differs from readline.Readline in the handling of - // EOF. Readline only returns EOF when there is nothing left to read and - // there is no partial line while bufio.ReadString() returns EOF when the - // end of input has been reached but will return the non-empty partial line - // as well. We workaround this by converting the bufio behavior to match - // the Readline behavior. - if err == io.EOF && len(l) != 0 { - err = nil - } else if err == nil { - // From the bufio.ReadString docs: ReadString returns err != nil if and - // only if the returned data does not end in delim. To match the behavior - // of readline.Readline, we strip off the trailing delimiter. - l = l[:len(l)-1] - } - } + l, err := c.ins.getLine() switch { case err == nil: @@ -1089,7 +1004,7 @@ func (c *cliState) doReadLine(nextState cliStateEnum) cliStateEnum { } // In any case, process one line. - case errors.Is(err, readline.ErrInterrupted): + case errors.Is(err, c.ins.errInterrupted()): if !c.cliCtx.IsInteractive { // Ctrl+C terminates non-interactive shells in all cases. c.exitErr = err @@ -1727,11 +1642,9 @@ func (c *cliState) runIncludeInternal( sqlExecCtx: c.sqlExecCtx, sqlCtx: c.sqlCtx, iCtx: c.iCtx, - + ins: &bufioReader{wout: c.iCtx.stdout, buf: input}, conn: c.conn, includeDir: filepath.Dir(filename), - ins: noLineEditor, - buf: input, levels: level, singleStatement: singleStatement, @@ -2144,40 +2057,37 @@ func (c *cliState) configurePreShellDefaults( // there is also a terminal on stdout. canUseEditor := c.cliCtx.IsInteractive && c.sqlExecCtx.TerminalOutput useEditor := canUseEditor && !c.sqlCtx.DisableLineEditor - c.iCtx.displayPrompt = canUseEditor + c.ins = getEditor(useEditor, canUseEditor) + + // maxHistEntries is the maximum number of entries to + // preserve. Note that libedit de-duplicates entries under the + // hood. We expect that folk entering SQL in a shell will often + // reuse the same queries over time, so we don't expect this limit + // to ever be reached in practice, or to be an annoyance to + // anyone. We do prefer a limit however (as opposed to no limit at + // all), to prevent abnormal situation where a history runs into + // megabytes and starts slowing down the shell. + const maxHistEntries = 10000 + var histFile string if useEditor { - // The readline initialization is not placed in - // the doStart() method because of the defer. - c.ins, c.exitErr = readline.InitFiles("cockroach", - true, /* wideChars */ - cmdIn, c.iCtx.stdout, c.iCtx.stderr) - if errors.Is(c.exitErr, readline.ErrWidecharNotSupported) { - fmt.Fprintln(c.iCtx.stderr, "warning: wide character support disabled") - c.ins, c.exitErr = readline.InitFiles("cockroach", - false, cmdIn, c.iCtx.stdout, c.iCtx.stderr) + homeDir, err := envutil.HomeDir() + if err != nil { + fmt.Fprintf(c.iCtx.stderr, "warning: cannot retrieve user information: %v\nwarning: history will not be saved\n", err) + } else { + histFile = filepath.Join(homeDir, cmdHistFile) } - if c.exitErr != nil { - return cleanupFn, c.exitErr - } - // The readline library may have a custom file descriptor for stdout. - // Use that for further output. - c.iCtx.stdout = c.ins.Stdout() - c.iCtx.queryOutputFile = c.ins.Stdout() - - // If the user has used bind -v or bind -l in their ~/.editrc, - // this will reset the standard bindings. However we really - // want in this shell that Ctrl+C, tab, Ctrl+Z and Ctrl+R - // always have the same meaning. So reload these bindings - // explicitly no matter what ~/.editrc may have changed. - c.ins.RebindControlKeys() - cleanupFn = func() { c.ins.Close() } - } else { - c.ins = noLineEditor - c.buf = bufio.NewReader(cmdIn) - cleanupFn = func() {} } - if c.iCtx.displayPrompt { + cleanupFn, c.exitErr = c.ins.init(cmdIn, c.iCtx.stdout, c.iCtx.stderr, c, maxHistEntries, histFile) + if c.exitErr != nil { + return cleanupFn, c.exitErr + } + // The readline library may have a custom file descriptor for stdout. + // Use that for further output. + c.iCtx.stdout = c.ins.getOutputStream() + c.iCtx.queryOutputFile = c.ins.getOutputStream() + + if canUseEditor { // Default prompt is part of the connection URL. eg: "marc@localhost:26257>". c.iCtx.customPromptPattern = defaultPromptPattern if c.sqlConnCtx.DebugMode { @@ -2185,56 +2095,6 @@ func (c *cliState) configurePreShellDefaults( } } - if c.hasEditor() { - // We only enable prompt and history management when the - // interactive input prompter is enabled. This saves on churn and - // memory when e.g. piping a large SQL script through the - // command-line client. - - // maxHistEntries is the maximum number of entries to - // preserve. Note that libedit de-duplicates entries under the - // hood. We expect that folk entering SQL in a shell will often - // reuse the same queries over time, so we don't expect this limit - // to ever be reached in practice, or to be an annoyance to - // anyone. We do prefer a limit however (as opposed to no limit at - // all), to prevent abnormal situation where a history runs into - // megabytes and starts slowing down the shell. - const maxHistEntries = 10000 - - c.ins.SetCompleter(c) - if err := c.ins.UseHistory(maxHistEntries, true /*dedup*/); err != nil { - fmt.Fprintf(c.iCtx.stderr, "warning: cannot enable history: %v\n ", err) - } else { - homeDir, err := envutil.HomeDir() - if err != nil { - fmt.Fprintf(c.iCtx.stderr, "warning: cannot retrieve user information: %v\nwarning: history will not be saved\n", err) - } else { - histFile := filepath.Join(homeDir, cmdHistFile) - err = c.ins.LoadHistory(histFile) - if err != nil { - fmt.Fprintf(c.iCtx.stderr, "warning: cannot load the command-line history (file corrupted?): %v\n", err) - fmt.Fprintf(c.iCtx.stderr, "note: the history file will be cleared upon first entry\n") - } - // SetAutoSaveHistory() does two things: - // - it preserves the name of the history file, for use - // by the final SaveHistory() call. - // - it decides whether to save the history to file upon - // every new command. - // We disable the latter, since a history file can grow somewhat - // large and we don't want the excess I/O latency to be interleaved - // in-between every command. - c.ins.SetAutoSaveHistory(histFile, false) - prevCleanup := cleanupFn - cleanupFn = func() { - if err := c.ins.SaveHistory(); err != nil { - fmt.Fprintf(c.iCtx.stderr, "warning: cannot save command-line history: %v\n", err) - } - prevCleanup() - } - } - } - } - // If any --set flags were set through the command line, // synthetize '-e set=xxx' statements for them at the beginning. c.iCtx.quitAfterExecStmts = len(c.sqlCtx.ExecStmts) > 0 @@ -2380,8 +2240,8 @@ func (c *cliState) maybeHandleInterrupt() func() { c.iCtx.mu.Unlock() if cancelFn == nil { // No query currently executing. - // Do we have a line editor? If so, do nothing. - if c.ins != noLineEditor { + // Are we doing interactive input? If so, do nothing. + if c.cliCtx.IsInteractive { continue } // Otherwise, ctrl+c interrupts the shell. We do this diff --git a/pkg/cli/clisqlshell/sql_internal_test.go b/pkg/cli/clisqlshell/sql_internal_test.go index 1ed172a87997..a3e5bf9e65c9 100644 --- a/pkg/cli/clisqlshell/sql_internal_test.go +++ b/pkg/cli/clisqlshell/sql_internal_test.go @@ -11,6 +11,8 @@ package clisqlshell import ( + "bufio" + "os" "testing" "github.com/cockroachdb/cockroach/pkg/cli/clicfg" @@ -155,7 +157,7 @@ func setupTestCliState() *cliState { } sqlCtx := &Context{} c := NewShell(cliCtx, sqlConnCtx, sqlExecCtx, sqlCtx, nil).(*cliState) - c.ins = noLineEditor + c.ins = &bufioReader{wout: os.Stdout, buf: bufio.NewReader(os.Stdin)} return c }