Skip to content
74 changes: 53 additions & 21 deletions ishell.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type Shell struct {
specialTerminators []string
backSlashCmds []string
quitKeywords []string
isComplete func(accumulated, delimiter string) bool
contextValues
Actions
}
Expand All @@ -80,6 +81,10 @@ type UninterpretedConfig struct {
BackSlashCmds []string
// Quit keywords to exit the shell if discovered
QuitKeywords []string
// IsComplete, when set, replaces the LineTerminator suffix check: it is
// called with the cumulative buffer and current delimiter and returns true
// when accumulation should stop.
IsComplete func(accumulated, delimiter string) bool
}

// New creates a new shell with default settings. Uses standard output and default prompt ">> ".
Expand Down Expand Up @@ -109,6 +114,7 @@ func NewUninterpreted(conf *UninterpretedConfig) *Shell {
shell.specialTerminators = conf.SpecialTerminators
shell.backSlashCmds = conf.BackSlashCmds
shell.quitKeywords = conf.QuitKeywords
shell.isComplete = conf.IsComplete

return shell
}
Expand Down Expand Up @@ -366,7 +372,7 @@ func (s *Shell) readUninterpreted() (string, error) {

if s.lineTerminator != "" {
firstLine := true
lines, err = s.readMultiLinesFunc(func(line string) (keepReading bool) {
lines, err = s.readMultiLinesAccumFunc(func(line, accumulated string) (keepReading bool) {
if firstLine {
firstLine = false
if matches := delimiterRegex.FindStringSubmatch(line); len(matches) == 2 {
Expand All @@ -382,22 +388,7 @@ func (s *Shell) readUninterpreted() (string, error) {
}
}
}

if strings.HasSuffix(strings.TrimSpace(line), s.lineTerminator) {
return false
}
for _, sc := range s.specialTerminators {
if strings.HasSuffix(strings.TrimSpace(line), sc) {
return false
}
}
for _, sc := range s.backSlashCmds {
if strings.HasPrefix(strings.TrimSpace(line), sc) {
return false
}
}

return true
return !s.lineIsComplete(line, accumulated)
})

if err != nil {
Expand Down Expand Up @@ -437,6 +428,27 @@ func (s *Shell) readUninterpreted() (string, error) {
return lines, nil
}

// lineIsComplete reports whether reading should stop: special terminators and
// backslash commands always end input, otherwise it defers to IsComplete or the
// line-terminator suffix check.
func (s *Shell) lineIsComplete(line, accumulated string) bool {
trimmed := strings.TrimSpace(line)
for _, sc := range s.specialTerminators {
if strings.HasSuffix(trimmed, sc) {
return true
}
}
for _, sc := range s.backSlashCmds {
if strings.HasPrefix(trimmed, sc) {
return true
}
}
if s.isComplete != nil {
return s.isComplete(accumulated, s.lineTerminator)
}
return strings.HasSuffix(trimmed, s.lineTerminator)
}

func (s *Shell) read() ([]string, error) {
s.rawArgs = nil
eof := ""
Expand Down Expand Up @@ -483,18 +495,38 @@ func (s *Shell) read() ([]string, error) {
}

func (s *Shell) readMultiLinesFunc(f func(string) (keepReading bool)) (string, error) {
return s.readMultiLinesAccumFunc(func(line, _ string) bool {
return f(line)
})
}

// readMultiLinesAccumFunc accumulates physical lines until the predicate
// returns false. The predicate receives the latest line and the cumulative
// buffer (lines joined by "\n").
func (s *Shell) readMultiLinesAccumFunc(f func(line, accumulated string) (keepReading bool)) (string, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a new function is necessary. the original readMultiLinesFunc and calls to it can just be modified.

return readMultiLinesAccumFromReader(s.readLine, s.reader.setMultiMode, f)
}

// readMultiLinesAccumFromReader is the inner accumulation loop, parameterized
// on the line reader and multi-mode toggle so tests can drive it without a real
// readline instance.
func readMultiLinesAccumFromReader(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this need to be its own function when it's never directly called and only ever called from s.readMultiLinesAccumFunc?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@angelamayxie I added readMultiLinesAccumFromReader only so I could unit-test the accumulation loop without a real readline/TTY. But that loop is unchanged pre-existing behavior; the only new thing in this PR is passing the accumulated buffer to the predicate, and that path is already covered end-to-end by the BATS tests in the Dolt PR. So, I can put everything back into readMultiLinesFunc change its predicate to func(line, accumulated string) bool, update the other callers (i.e., read() and the heredoc path) to ignore the second arg, and drop the standalone loop tests. Does that sound ok given the tests in the Dolt PR?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That works -- it doesn't need to be unit tested here since it gets tested anyways in the Dolt BATS tests.

readLine func() (string, error),
setMulti func(bool),
f func(line, accumulated string) (keepReading bool),
) (string, error) {
var lines bytes.Buffer
currentLine := 0
var err error
for {
if currentLine == 1 {
// from second line, enable next line prompt.
s.reader.setMultiMode(true)
setMulti(true)
}
var line string
line, err = s.readLine()
line, err = readLine()
fmt.Fprint(&lines, line)
if !f(line) || err != nil {
if !f(line, lines.String()) || err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repeatedly calling lines.String() to get the accumulated string does not seem performant to me. Also, looking at how it's integrated with Dolt, it seems like everything is getting scanned twice, first via the iShell and then again with the sql scanner. And if it's multiple lines, then the first line gets scanned n times, second line n-1 times, and so on? Ideally, the input should just get scanned once.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way we can avoid repeatedly calling lines.String() on the accumulated string? I would really really like to avoid doing that.

@codeaucafe codeaucafe Aug 22, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry about that @angelamayxie I completely scanned over and missed your original comment and focused on the twice scanning rather than the also inefficient lines.String() 🤕 I'll get this fixed

break
}
fmt.Fprintln(&lines)
Expand All @@ -503,7 +535,7 @@ func (s *Shell) readMultiLinesFunc(f func(string) (keepReading bool)) (string, e
if currentLine > 0 {
// if more than one line is read
// revert to standard prompt.
s.reader.setMultiMode(false)
setMulti(false)
}
return lines.String(), err
}
Expand Down
74 changes: 74 additions & 0 deletions uninterpreted_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package ishell

import (
"io"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

// linesReader returns a function that yields the supplied lines one at a
// time, then returns io.EOF on subsequent calls.
func linesReader(lines []string) func() (string, error) {
i := 0
return func() (string, error) {
if i >= len(lines) {
return "", io.EOF
}
l := lines[i]
i++
return l, nil
}
}

func TestReadMultiLinesAccumFromReaderTerminator(t *testing.T) {
// Predicate stops accumulation once the buffer contains "GO", regardless of
// per-line semicolon terminators.
read := linesReader([]string{"select 1;", "select 2;", "GO"})
multiCalls := []bool{}
pred := func(line, accumulated string) bool {
return !strings.Contains(accumulated, "GO")
}
out, err := readMultiLinesAccumFromReader(read, func(b bool) { multiCalls = append(multiCalls, b) }, pred)
assert.NoError(t, err)
assert.Equal(t, "select 1;\nselect 2;\nGO", out)
// setMulti(true) once at currentLine==1, setMulti(false) once at end.
assert.Equal(t, []bool{true, false}, multiCalls)
}

func TestReadMultiLinesAccumFromReaderSingleLine(t *testing.T) {
// First line ends accumulation; setMulti is never called.
read := linesReader([]string{"select 1;"})
multiCalls := []bool{}
pred := func(line, accumulated string) bool { return false }
out, err := readMultiLinesAccumFromReader(read, func(b bool) { multiCalls = append(multiCalls, b) }, pred)
assert.NoError(t, err)
assert.Equal(t, "select 1;", out)
assert.Empty(t, multiCalls)
}

func TestReadMultiLinesAccumFromReaderEmpty(t *testing.T) {
// First line is empty and predicate returns false: shell never enters
// multi-mode; output is empty. This mirrors the empty-Enter path.
read := linesReader([]string{""})
multiCalls := []bool{}
pred := func(line, accumulated string) bool { return false }
out, err := readMultiLinesAccumFromReader(read, func(b bool) { multiCalls = append(multiCalls, b) }, pred)
assert.NoError(t, err)
assert.Equal(t, "", out)
assert.Empty(t, multiCalls)
}

func TestReadMultiLinesAccumFromReaderEOFMidStream(t *testing.T) {
// EOF arriving after the predicate has chosen to keep reading surfaces
// the EOF error and returns the buffer collected so far. The trailing
// "\n" is the inter-line separator written by fmt.Fprintln before the
// next readLine call returned EOF.
read := linesReader([]string{"select 1;"})
pred := func(line, accumulated string) bool { return true }
out, err := readMultiLinesAccumFromReader(read, func(b bool) {}, pred)
assert.Equal(t, io.EOF, err)
assert.Equal(t, "select 1;\n", out)
}