Skip to content

Commit

Permalink
clisqlshell: hide the libedit dep behind a go interface
Browse files Browse the repository at this point in the history
In #86457 and related work we will want to offer two editors
side-by-side in a transition period, so folk can compare or fall back
on something known in case they are not happy with the new stuff.

To enable this transition period, this commit hides the editor behind
a go interface.

This also makes the shell code overall easier to read and understand.

Release note: None
  • Loading branch information
knz committed Sep 24, 2022
1 parent 4f3bb99 commit d5449c8
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 191 deletions.
3 changes: 3 additions & 0 deletions pkg/cli/clisqlshell/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions pkg/cli/clisqlshell/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
38 changes: 38 additions & 0 deletions pkg/cli/clisqlshell/editor.go
Original file line number Diff line number Diff line change
@@ -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{}
}
80 changes: 80 additions & 0 deletions pkg/cli/clisqlshell/editor_bufio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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
}

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
}
153 changes: 153 additions & 0 deletions pkg/cli/clisqlshell/editor_editline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// 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
}

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
}
Loading

0 comments on commit d5449c8

Please sign in to comment.