diff --git a/cli/console/console.go b/cli/console/console.go new file mode 100644 index 000000000..be59a9d88 --- /dev/null +++ b/cli/console/console.go @@ -0,0 +1,258 @@ +package console + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + "syscall" + "unicode" + + "github.com/apex/log" + "golang.org/x/term" + + "github.com/tarantool/go-prompt" +) + +const ( + maxLivePrefixIndent = 15 + // see https://github.com/tarantool/tarantool/blob/b53cb2aeceedc39f356ceca30bd0087ee8de7c16/src/box/lua/console.c#L265 + tarantoolWordSeparators = "\t\r\n !\"#$%&'()*+,-/;<=>?@[\\]^`{|}~" +) + +var ( + controlLeftBytes = []byte{0x1b, 0x62} + controlRightBytes = []byte{0x1b, 0x66} +) + +// ConsoleOpts collection console options to create new console. +type ConsoleOpts struct { + // Handler is the implementation of command processor. + Handler Handler + + // History if specified than save input commands with it. + History History + + // Format options set how to formatting result. + Format Format +} + +// Console implementation of active console handler. +type Console struct { + impl ConsoleOpts + internal Handler // internal Handler execute console's additional backslash commands. + input string + quit bool + prefix string + livePrefixEnabled bool + livePrefix string + delimiter string + prompt *prompt.Prompt +} + +// NewConsole creates a new console connected to the tarantool instance. +func NewConsole(opts ConsoleOpts) (Console, error) { + if opts.Handler == nil { + return Console{quit: true}, errors.New("no handler for commands has been set") + } + c := Console{ + impl: opts, + quit: false, + } + c.setPrefix() + return c, nil +} + +func (c *Console) runOnPipe() { + log.Debugf("Processing piped input") + pipe := bufio.NewScanner(os.Stdin) + for pipe.Scan() { + line := pipe.Text() + c.execute(line) + } +} + +// Run starts console. +func (c *Console) Run() error { + if c.quit { + return errors.New("can't run on stopped console") + } + if !term.IsTerminal(syscall.Stdin) { + c.runOnPipe() + return nil + } + + log.Infof("Connected to %s\n", c.title()) + c.prompt = prompt.New( + c.execute, + c.complete, + c.getPromptOptions()..., + ) + c.prompt.Run() + + return nil +} + +// Close frees up resources used by the console. +func (c *Console) Close() { + c.impl.Handler.Stop() + if c.impl.History != nil { + c.impl.History.Stop() + } +} + +// executeEmbeddedCommand try process additional backslash commands. +func (c *Console) executeEmbeddedCommand(in string) bool { + if c.input == "" && c.internal != nil { + if c.internal.Execute(in) != nil { + if c.quit { + c.Close() + log.Infof("Quit from the console") + os.Exit(0) + } + return true + } + } + return false +} + +// cleanupDelimiter checks if the input statement ends with the string `c.delimiter`. +// If yes, it removes it. Returns true if the delimiter has been removed. +func (c *Console) cleanupDelimiter() bool { + if c.delimiter == "" { + return true + } + no_space := strings.TrimRightFunc(c.input, func(r rune) bool { + return unicode.IsSpace(r) + }) + no_delim := strings.TrimSuffix(no_space, c.delimiter) + if len(no_space) > len(no_delim) { + c.input = no_delim + return true + } + return false +} + +// addStmt adds a new part of the statement. +// It returns true if the statement is already completed. +func (c *Console) addStmt(part string) bool { + if c.input == "" { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + c.input = part + } + } else { + c.input += "\n" + part + } + + has_delim := c.cleanupDelimiter() + c.livePrefixEnabled = !(has_delim && c.impl.Handler.Validate(c.input)) + return !c.livePrefixEnabled +} + +// execute called from prompt to process input. +func (c *Console) execute(in string) { + if c.executeEmbeddedCommand(in) || !c.addStmt(in) { + return + } + + trimmed := strings.TrimSpace(c.input) + if c.impl.History != nil { + c.impl.History.AppendCommand(trimmed) + } + + if c.prompt != nil { + if err := c.prompt.PushToHistory(trimmed); err != nil { + log.Debug(err.Error()) + } + } + + results := c.impl.Handler.Execute(c.input) + if results == nil { + c.Close() + log.Infof("Connection closed") + os.Exit(0) + } + if err := c.impl.Format.print(results); err != nil { + log.Errorf("Unable to format output: %s", err) + log.Infof("Source results:\n%v", results) + } + + c.input = "" + c.livePrefixEnabled = false +} + +// title return console's title. +func (c *Console) title() string { + return c.impl.Handler.Title() +} + +// complete provide prompt suggestions. +func (c *Console) complete(input prompt.Document) []prompt.Suggest { + if c.input == "" && c.internal != nil { + return c.internal.Complete(input) + } + return c.impl.Handler.Complete(input) +} + +// setPrefix adjust console prefix string. +func (c *Console) setPrefix() { + c.prefix = fmt.Sprintf("%s> ", c.title()) + + livePrefixIndent := len(c.title()) + if livePrefixIndent > maxLivePrefixIndent { + livePrefixIndent = maxLivePrefixIndent + } + + c.livePrefix = fmt.Sprintf("%s> ", strings.Repeat(" ", livePrefixIndent)) +} + +// getPromptOptions prepare option for prompt. +func (c *Console) getPromptOptions() []prompt.Option { + options := []prompt.Option{ + prompt.OptionTitle(c.title()), + prompt.OptionPrefix(c.prefix), + prompt.OptionLivePrefix(func() (string, bool) { + return c.livePrefix, c.livePrefixEnabled + }), + + prompt.OptionSuggestionBGColor(prompt.DarkGray), + prompt.OptionPreviewSuggestionTextColor(prompt.DefaultColor), + + prompt.OptionCompletionWordSeparator(tarantoolWordSeparators), + + prompt.OptionAddASCIICodeBind( + // Move to one word left. + prompt.ASCIICodeBind{ + ASCIICode: controlLeftBytes, + Fn: prompt.GoLeftWord, + }, + // Move to one word right. + prompt.ASCIICodeBind{ + ASCIICode: controlRightBytes, + Fn: prompt.GoRightWord, + }, + ), + // Interrupt current unfinished expression. + prompt.OptionAddKeyBind( + prompt.KeyBind{ + Key: prompt.ControlC, + Fn: func(buf *prompt.Buffer) { + c.input = "" + c.livePrefixEnabled = false + fmt.Println("^C") + }, + }, + ), + + prompt.OptionDisableAutoHistory(), + prompt.OptionReverseSearch(), + } + + if c.impl.History != nil { + options = append(options, prompt.OptionHistory(c.impl.History.Command())) + } + + return options +} diff --git a/cli/console/format.go b/cli/console/format.go new file mode 100644 index 000000000..5579d6b09 --- /dev/null +++ b/cli/console/format.go @@ -0,0 +1,26 @@ +package console + +import "github.com/tarantool/tt/cli/formatter" + +type Format struct { + // Mode specify how to formatting result. + Mode formatter.Format + // Opts options for Format. + Opts formatter.Opts +} + +func (f Format) print(HandlerResult) error { + // TODO: implement formatting and print results. + return nil +} + +func DefaultConsoleFormat() Format { + return Format{ + Mode: formatter.TableFormat, + Opts: formatter.Opts{ + Graphics: true, + ColumnWidthMax: 0, + TableDialect: formatter.DefaultTableDialect, + }, + } +} diff --git a/cli/console/handler.go b/cli/console/handler.go new file mode 100644 index 000000000..0ee3cdaa9 --- /dev/null +++ b/cli/console/handler.go @@ -0,0 +1,28 @@ +package console + +import "github.com/tarantool/go-prompt" + +// HandlerResult structure of data records. +// Map keys is names of columns. And map value is content of column. +// TODO: Solve what need return to make easy apply Formatter. +// Possible: can we make it return an interface with methods to be handled in Formatter? +type HandlerResult map[string]any + +// Handler is a auxiliary abstraction to isolate the console from +// the implementation of a particular instruction processor. +type Handler interface { + // Title return name of instruction processor instance. + Title() string + + // Validate the input string. + Validate(input string) bool + + // Complete checks the input and return available variants to continue typing. + Complete(input prompt.Document) []prompt.Suggest + + // Execute accept input to perform actions defined by client implementation. + Execute(input string) HandlerResult + + // Stop notify handler to terminate execution and close any opened streams. + Stop() // Q: А нужно ли иметь такой метод? +} diff --git a/cli/console/history.go b/cli/console/history.go new file mode 100644 index 000000000..c1cc36ea4 --- /dev/null +++ b/cli/console/history.go @@ -0,0 +1,13 @@ +package console + +const ( + DefaultHistoryFileName = ".tarantool_history" + DefaultHistoryLines = 10000 +) + +type History interface { + Open(fileName string, maxCommands int) error + AppendCommand(input string) + Command() []string + Stop() // Q: А нужно ли иметь такой метод? +}