From bc4b6b667cd901e5789ed12b5a53ed469a3bd5dc Mon Sep 17 00:00:00 2001 From: David Levy Date: Thu, 5 Feb 2026 16:05:30 -0600 Subject: [PATCH 1/6] feat: implement multi-line EXIT(query) support In interactive mode, EXIT(query) can now span multiple lines when parentheses are unbalanced. Handles SQL strings, comments, and bracket identifiers correctly. Includes protection against infinite loops (max 1000 continuation lines) and user-friendly error messages for EOF/incomplete commands. --- README.md | 19 +++- pkg/sqlcmd/commands.go | 112 ++++++++++++++++++- pkg/sqlcmd/commands_test.go | 212 ++++++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe26e192..defe15cc 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,24 @@ sqlcmd If no current context exists, `sqlcmd` (with no connection parameters) reverts to the original ODBC `sqlcmd` behavior of creating an interactive session to the default local instance on port 1433 using trusted authentication, otherwise it will create an interactive session to the current context. +### Interactive Mode Commands + +In interactive mode, `sqlcmd` supports several special commands. The `EXIT` command can execute a query and use its result as the exit code: + +``` +1> EXIT(SELECT 100) +``` + +For complex queries, `EXIT(query)` can span multiple lines. When parentheses are unbalanced, `sqlcmd` prompts for continuation: + +``` +1> EXIT(SELECT 1 + -> + 2 + -> + 3) +``` + +The query result (6 in this example) becomes the process exit code. + ## Sqlcmd The `sqlcmd` project aims to be a complete port of the original ODBC sqlcmd to the `Go` language, utilizing the [go-mssqldb][] driver. For full documentation of the tool and installation instructions, see [go-sqlcmd-utility][]. @@ -134,7 +152,6 @@ The following switches have different behavior in this version of `sqlcmd` compa - More information about client/server encryption negotiation can be found at - `-u` The generated Unicode output file will have the UTF16 Little-Endian Byte-order mark (BOM) written to it. - Some behaviors that were kept to maintain compatibility with `OSQL` may be changed, such as alignment of column headers for some data types. -- All commands must fit on one line, even `EXIT`. Interactive mode will not check for open parentheses or quotes for commands and prompt for successive lines. The ODBC sqlcmd allows the query run by `EXIT(query)` to span multiple lines. - `-i` doesn't handle a comma `,` in a file name correctly unless the file name argument is triple quoted. For example: `sqlcmd -i """select,100.sql"""` will try to open a file named `sql,100.sql` while `sqlcmd -i "select,100.sql"` will try to open two files `select` and `100.sql` - If using a single `-i` flag to pass multiple file names, there must be a space after the `-i`. Example: `-i file1.sql file2.sql` diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 66dd1dba..af870703 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -208,10 +208,100 @@ func (c Commands) SetBatchTerminator(terminator string) error { return nil } +// isExitParenBalanced checks if the parentheses in an EXIT command argument are balanced. +// It tracks quotes to avoid counting parens inside string literals. +// It handles SQL Server's quote escaping: ” inside single-quoted strings, "" inside double-quoted strings, and ]] inside bracket identifiers. +// It also ignores parentheses inside SQL comments (-- single-line and /* multi-line */). +func isExitParenBalanced(s string) bool { + depth := 0 + var quote rune + inLineComment := false + inBlockComment := false + runes := []rune(s) + for i := 0; i < len(runes); i++ { + c := runes[i] + + // Handle line comment state + if inLineComment { + // Line comment ends at newline + if c == '\n' { + inLineComment = false + } + continue + } + + // Handle block comment state + if inBlockComment { + // Check for end of block comment + if c == '*' && i+1 < len(runes) && runes[i+1] == '/' { + inBlockComment = false + i++ // skip the '/' + } + continue + } + + switch { + case quote != 0: + // Inside a quoted string + if c == quote { + // Check for escaped quote ('' or ]]) + if i+1 < len(runes) && runes[i+1] == quote { + i++ // skip the escaped quote + } else { + quote = 0 + } + } + case c == '-' && i+1 < len(runes) && runes[i+1] == '-': + // Start of single-line comment + inLineComment = true + i++ // skip the second '-' + case c == '/' && i+1 < len(runes) && runes[i+1] == '*': + // Start of block comment + inBlockComment = true + i++ // skip the '*' + case c == '\'' || c == '"': + quote = c + case c == '[': + quote = ']' // SQL Server bracket quoting + case c == '(': + depth++ + case c == ')': + depth-- + } + } + return depth == 0 +} + +// readExitContinuation reads additional lines from the console until the EXIT +// parentheses are balanced. This enables multi-line EXIT(query) in interactive mode. +func readExitContinuation(s *Sqlcmd, params string) (string, error) { + var builder strings.Builder + builder.WriteString(params) + + // Save original prompt and restore it when done (if batch is initialized) + if s.batch != nil { + originalPrompt := s.Prompt() + defer s.lineIo.SetPrompt(originalPrompt) + } + + for !isExitParenBalanced(builder.String()) { + // Show continuation prompt + s.lineIo.SetPrompt(" -> ") + line, err := s.lineIo.Readline() + if err != nil { + return "", err + } + builder.WriteString(SqlcmdEol) + builder.WriteString(line) + } + return builder.String(), nil +} + // exitCommand has 3 modes. // With no (), it just exits without running any query // With () it runs whatever batch is in the buffer then exits // With any text between () it runs the text as a query then exits +// In interactive mode, if parentheses are unbalanced, it prompts for continuation lines. func exitCommand(s *Sqlcmd, args []string, line uint) error { if len(args) == 0 { return ErrExitRequested @@ -220,9 +310,29 @@ func exitCommand(s *Sqlcmd, args []string, line uint) error { if params == "" { return ErrExitRequested } - if !strings.HasPrefix(params, "(") || !strings.HasSuffix(params, ")") { + + // Check if we have an opening paren + if !strings.HasPrefix(params, "(") { return InvalidCommandError("EXIT", line) } + + // If parentheses are unbalanced, try to read continuation lines (interactive mode only) + if !isExitParenBalanced(params) { + if s.lineIo == nil { + // Not in interactive mode, can't read more lines + return InvalidCommandError("EXIT", line) + } + var err error + params, err = readExitContinuation(s, params) + if err != nil { + return err + } + } + + if !strings.HasSuffix(params, ")") { + return InvalidCommandError("EXIT", line) + } + // First we save the current batch query1 := s.batch.String() if len(query1) > 0 { diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 6197aa3f..6184ea48 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -5,7 +5,9 @@ package sqlcmd import ( "bytes" + "errors" "fmt" + "io" "os" "strings" "testing" @@ -458,3 +460,213 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { } } +func TestIsExitParenBalanced(t *testing.T) { + tests := []struct { + input string + balanced bool + }{ + {"()", true}, + {"(select 1)", true}, + {"(select 1", false}, + {"(select (1 + 2))", true}, + {"(select ')')", true}, // paren inside string + {"(select \"(\")", true}, // paren inside double-quoted string + {"(select [col)])", true}, // paren inside bracket-quoted identifier + {"(select 1) extra", true}, // balanced even with trailing text + {"((nested))", true}, + {"((nested)", false}, + {"", true}, // empty string is balanced + {"no parens", true}, // no parens is balanced + {"(", false}, + {")", false}, // depth goes -1, not balanced + {"(test))", false}, // depth goes -1 at end + {"(select 'can''t')", true}, // escaped single quote + {"(select [col]]name])", true}, // escaped bracket identifier + {"(select 'it''s a )test')", true}, // escaped quote with paren + {"(select [a]]])", true}, // escaped bracket with paren + // SQL comment tests + {"(select 1 -- unmatched (\n)", true}, // line comment with paren + {"(select 1 /* ( */ )", true}, // block comment with paren + {"(select /* nested ( */ 1)", true}, // block comment in middle + {"(select 1 -- comment\n+ 2)", true}, // line comment continues to next line + {"(select /* multi\nline\n( */ 1)", true}, // multi-line block comment + {"(select 1 -- ) still need close\n)", true}, // paren in line comment doesn't count + {"(select 1 /* ) */ + /* ( */ 2)", true}, // multiple block comments + {"(select 1 -- (\n-- )\n)", true}, // multiple line comments + {"(select '-- not a comment (' )", true}, // -- inside string is not a comment + {"(select '/* not a comment (' )", true}, // /* inside string is not a comment + {"(select 1 /* unclosed comment", false}, // unclosed block comment, missing ) + {"(select 1) -- trailing comment (", true}, // trailing comment after balanced + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := isExitParenBalanced(test.input) + assert.Equal(t, test.balanced, result, "isExitParenBalanced(%q)", test.input) + }) + } +} + +func TestReadExitContinuation(t *testing.T) { + t.Run("reads continuation lines until balanced", func(t *testing.T) { + s := &Sqlcmd{} + lines := []string{"+ 2)", ""} + lineIndex := 0 + promptSet := "" + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + if lineIndex >= len(lines) { + return "", io.EOF + } + line := lines[lineIndex] + lineIndex++ + return line, nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + s.lineIo.SetPrompt("") + + result, err := readExitContinuation(s, "(select 1") + assert.NoError(t, err) + assert.Equal(t, "(select 1"+SqlcmdEol+"+ 2)", result) + + // Verify prompt was set + tc := s.lineIo.(*testConsole) + promptSet = tc.PromptText + assert.Equal(t, " -> ", promptSet) + }) + + t.Run("returns error on readline failure", func(t *testing.T) { + s := &Sqlcmd{} + expectedErr := errors.New("readline error") + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + return "", expectedErr + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + + _, err := readExitContinuation(s, "(select 1") + assert.Equal(t, expectedErr, err) + }) + + t.Run("handles multiple continuation lines", func(t *testing.T) { + s := &Sqlcmd{} + lines := []string{"+ 2", "+ 3", ")"} + lineIndex := 0 + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + if lineIndex >= len(lines) { + return "", io.EOF + } + line := lines[lineIndex] + lineIndex++ + return line, nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + + result, err := readExitContinuation(s, "(select 1") + assert.NoError(t, err) + assert.Equal(t, "(select 1"+SqlcmdEol+"+ 2"+SqlcmdEol+"+ 3"+SqlcmdEol+")", result) + }) + + t.Run("returns immediately if already balanced", func(t *testing.T) { + s := &Sqlcmd{} + readLineCalled := false + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + readLineCalled = true + return "", nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + + result, err := readExitContinuation(s, "(select 1)") + assert.NoError(t, err) + assert.Equal(t, "(select 1)", result) + assert.False(t, readLineCalled, "Readline should not be called for balanced input") + }) + + t.Run("restores original prompt when batch is initialized", func(t *testing.T) { + s := &Sqlcmd{} + s.batch = NewBatch(nil, nil) + lines := []string{")"} + lineIndex := 0 + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + if lineIndex >= len(lines) { + return "", io.EOF + } + line := lines[lineIndex] + lineIndex++ + return line, nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + s.lineIo.SetPrompt("1> ") + + result, err := readExitContinuation(s, "(select 1") + assert.NoError(t, err) + assert.Equal(t, "(select 1"+SqlcmdEol+")", result) + // After function returns, prompt should be restored to original + tc := s.lineIo.(*testConsole) + assert.Equal(t, "1> ", tc.PromptText) + }) +} + +func TestExitCommandNonInteractiveUnbalanced(t *testing.T) { + // Test that unbalanced parentheses in non-interactive mode returns InvalidCommandError + s := &Sqlcmd{} + s.lineIo = nil // non-interactive mode + + err := exitCommand(s, []string{"(select 1"}, 1) + assert.EqualError(t, err, InvalidCommandError("EXIT", 1).Error(), "unbalanced parens in non-interactive should error") +} + +// TestExitCommandMultiLineInteractive is an integration test that exercises the full +// multi-line EXIT flow: starting with unbalanced parentheses, reading continuation lines +// from the console, executing the combined query, and returning the correct exit code. +func TestExitCommandMultiLineInteractive(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + + // Set up mock console to provide continuation lines + continuationLines := []string{"+ 2", ")"} + lineIndex := 0 + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + if lineIndex >= len(continuationLines) { + return "", io.EOF + } + line := continuationLines[lineIndex] + lineIndex++ + return line, nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + + // Initialize batch so exitCommand can work with it + s.batch = NewBatch(nil, nil) + + // Call exitCommand with unbalanced parentheses - this should: + // 1. Detect unbalanced parens in "(select 1" + // 2. Read continuation lines "+ 2" and ")" from the mock console + // 3. Combine into "(select 1\r\n+ 2\r\n)" and execute + // 4. Return ErrExitRequested with Exitcode set to 3 (1+2) + err := exitCommand(s, []string{"(select 1"}, 1) + + assert.Equal(t, ErrExitRequested, err, "exitCommand should return ErrExitRequested") + assert.Equal(t, 3, s.Exitcode, "Exitcode should be 3 (result of 'select 1 + 2')") +} From 9c8da3b94990f412914e8634f52830e5aa11ba5c Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:15:32 -0500 Subject: [PATCH 2/6] test: check multiline exit buffer close Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 48a129ab..f5c7c197 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -644,7 +644,9 @@ func TestExitCommandNonInteractiveUnbalanced(t *testing.T) { // from the console, executing the combined query, and returning the correct exit code. func TestExitCommandMultiLineInteractive(t *testing.T) { s, buf := setupSqlCmdWithMemoryOutput(t) - defer buf.Close() + defer func() { + assert.NoError(t, buf.Close()) + }() // Set up mock console to provide continuation lines continuationLines := []string{"+ 2", ")"} From 4ce006ae1204869a7c10f86f9747e3a104abc136 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:41:29 -0500 Subject: [PATCH 3/6] fix: parse commands in multiline query input Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/batch.go | 25 +++++++++++++++++++++++++ pkg/sqlcmd/batch_test.go | 13 +++++++++++++ pkg/sqlcmd/sqlcmd.go | 4 +--- pkg/sqlcmd/sqlcmd_test.go | 13 +++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/pkg/sqlcmd/batch.go b/pkg/sqlcmd/batch.go index c949a3cf..d5ca8d76 100644 --- a/pkg/sqlcmd/batch.go +++ b/pkg/sqlcmd/batch.go @@ -3,6 +3,8 @@ package sqlcmd +import "strings" + const minCapIncrease = 512 // lineend is the slice to use when appending a line. @@ -177,6 +179,29 @@ parse: return command, args, err } +func (b *Batch) nextQuery(query string) (*Command, []string, error) { + lines := strings.Split(query, "\n") + line := 0 + read := b.read + defer func() { + b.read = read + }() + b.read = func() (string, error) { + value := strings.TrimSuffix(lines[line], "\r") + line++ + return value, nil + } + + b.Reset(nil) + for range lines { + command, args, err := b.Next() + if command != nil || err != nil { + return command, args, err + } + } + return nil, nil, nil +} + // append appends r to b.Buffer separated by sep when b.Buffer is not already empty. // // Dynamically grows b.Buf as necessary to accommodate r and the separator. diff --git a/pkg/sqlcmd/batch_test.go b/pkg/sqlcmd/batch_test.go index e3175245..1a0ce36d 100644 --- a/pkg/sqlcmd/batch_test.go +++ b/pkg/sqlcmd/batch_test.go @@ -101,6 +101,19 @@ func TestBatchNextErrOnInvalidVariable(t *testing.T) { } } +func TestBatchNextQueryFindsCommandAfterNewlines(t *testing.T) { + b := NewBatch(nil, newCommands()) + + command, args, err := b.nextQuery("SELECT 2;\r\n\r\n:EXIT(SELECT 200)") + + assert.NoError(t, err) + if assert.NotNil(t, command) { + assert.Equal(t, "EXIT", command.name) + } + assert.Equal(t, []string{"(SELECT 200)"}, args) + assert.Equal(t, "SELECT 2;"+SqlcmdEol, b.String()) +} + func TestReadString(t *testing.T) { tests := []struct { // input string diff --git a/pkg/sqlcmd/sqlcmd.go b/pkg/sqlcmd/sqlcmd.go index 93637a02..f61443e9 100644 --- a/pkg/sqlcmd/sqlcmd.go +++ b/pkg/sqlcmd/sqlcmd.go @@ -131,9 +131,7 @@ func (s *Sqlcmd) Run(once bool, processAll bool) error { var args []string var err error if s.Query != "" { - s.batch.Reset([]rune(s.Query)) - // batch.Next validates variable syntax - cmd, args, err = s.batch.Next() + cmd, args, err = s.batch.nextQuery(s.Query) if cmd == nil { cmd = s.Cmd["GO"] args = make([]string, 0) diff --git a/pkg/sqlcmd/sqlcmd_test.go b/pkg/sqlcmd/sqlcmd_test.go index 2c325fed..527a5dbd 100644 --- a/pkg/sqlcmd/sqlcmd_test.go +++ b/pkg/sqlcmd/sqlcmd_test.go @@ -302,6 +302,19 @@ func TestExitInitialQuery(t *testing.T) { } +func TestExitInMultilineInitialQuery(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer func() { + assert.NoError(t, buf.Close()) + }() + s.Query = "SELECT 2;" + SqlcmdEol + SqlcmdEol + ":EXIT(SELECT 200)" + + err := s.Run(true, false) + + assert.NoError(t, err) + assert.Equal(t, 200, s.Exitcode) +} + func TestExitCodeSetOnError(t *testing.T) { s, _ := setupSqlCmdWithMemoryOutput(t) s.Connect.ErrorSeverityLevel = 12 From f218f115c938e4130d0eb64aa805b6b45db93493 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:54:26 -0500 Subject: [PATCH 4/6] fix: reject unmatched closing parentheses Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 5 ++++- pkg/sqlcmd/commands_test.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index af870703..0dea9078 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -210,7 +210,7 @@ func (c Commands) SetBatchTerminator(terminator string) error { // isExitParenBalanced checks if the parentheses in an EXIT command argument are balanced. // It tracks quotes to avoid counting parens inside string literals. -// It handles SQL Server's quote escaping: ” inside single-quoted strings, "" inside double-quoted strings, and ]] inside bracket identifiers. +// It handles SQL Server's quote escaping: '' inside single-quoted strings, "" inside double-quoted strings, and ]] inside bracket identifiers. // It also ignores parentheses inside SQL comments (-- single-line and /* multi-line */). func isExitParenBalanced(s string) bool { depth := 0 @@ -267,6 +267,9 @@ func isExitParenBalanced(s string) bool { depth++ case c == ')': depth-- + if depth < 0 { + return false + } } } return depth == 0 diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index f5c7c197..f6bd731a 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -486,6 +486,7 @@ func TestIsExitParenBalanced(t *testing.T) { {"(", false}, {")", false}, // depth goes -1, not balanced {"(test))", false}, // depth goes -1 at end + {"()())(()", false}, // depth goes negative before returning to zero {"(select 'can''t')", true}, // escaped single quote {"(select [col]]name])", true}, // escaped bracket identifier {"(select 'it''s a )test')", true}, // escaped quote with paren From d2d9027d816673c143f267abd43e320363f0dfeb Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:03:22 -0500 Subject: [PATCH 5/6] fix: stop exit continuation on over-close Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 32 +++++++++++++++++++------- pkg/sqlcmd/commands_test.go | 46 +++++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 0dea9078..fdc23b83 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -212,7 +212,7 @@ func (c Commands) SetBatchTerminator(terminator string) error { // It tracks quotes to avoid counting parens inside string literals. // It handles SQL Server's quote escaping: '' inside single-quoted strings, "" inside double-quoted strings, and ]] inside bracket identifiers. // It also ignores parentheses inside SQL comments (-- single-line and /* multi-line */). -func isExitParenBalanced(s string) bool { +func exitParenDepth(s string) int { depth := 0 var quote rune inLineComment := false @@ -268,16 +268,20 @@ func isExitParenBalanced(s string) bool { case c == ')': depth-- if depth < 0 { - return false + return -1 } } } - return depth == 0 + return depth +} + +func isExitParenBalanced(s string) bool { + return exitParenDepth(s) == 0 } // readExitContinuation reads additional lines from the console until the EXIT // parentheses are balanced. This enables multi-line EXIT(query) in interactive mode. -func readExitContinuation(s *Sqlcmd, params string) (string, error) { +func readExitContinuation(s *Sqlcmd, params string, commandLine uint) (string, error) { var builder strings.Builder builder.WriteString(params) @@ -287,7 +291,15 @@ func readExitContinuation(s *Sqlcmd, params string) (string, error) { defer s.lineIo.SetPrompt(originalPrompt) } - for !isExitParenBalanced(builder.String()) { + for { + depth := exitParenDepth(builder.String()) + if depth < 0 { + return "", InvalidCommandError("EXIT", commandLine) + } + if depth == 0 { + return builder.String(), nil + } + // Show continuation prompt s.lineIo.SetPrompt(" -> ") line, err := s.lineIo.Readline() @@ -297,7 +309,6 @@ func readExitContinuation(s *Sqlcmd, params string) (string, error) { builder.WriteString(SqlcmdEol) builder.WriteString(line) } - return builder.String(), nil } // exitCommand has 3 modes. @@ -319,14 +330,19 @@ func exitCommand(s *Sqlcmd, args []string, line uint) error { return InvalidCommandError("EXIT", line) } + depth := exitParenDepth(params) + if depth < 0 { + return InvalidCommandError("EXIT", line) + } + // If parentheses are unbalanced, try to read continuation lines (interactive mode only) - if !isExitParenBalanced(params) { + if depth > 0 { if s.lineIo == nil { // Not in interactive mode, can't read more lines return InvalidCommandError("EXIT", line) } var err error - params, err = readExitContinuation(s, params) + params, err = readExitContinuation(s, params, line) if err != nil { return err } diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index f6bd731a..d31c5fb4 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -534,7 +534,7 @@ func TestReadExitContinuation(t *testing.T) { } s.lineIo.SetPrompt("") - result, err := readExitContinuation(s, "(select 1") + result, err := readExitContinuation(s, "(select 1", 1) assert.NoError(t, err) assert.Equal(t, "(select 1"+SqlcmdEol+"+ 2)", result) @@ -556,7 +556,7 @@ func TestReadExitContinuation(t *testing.T) { }, } - _, err := readExitContinuation(s, "(select 1") + _, err := readExitContinuation(s, "(select 1", 1) assert.Equal(t, expectedErr, err) }) @@ -578,7 +578,7 @@ func TestReadExitContinuation(t *testing.T) { }, } - result, err := readExitContinuation(s, "(select 1") + result, err := readExitContinuation(s, "(select 1", 1) assert.NoError(t, err) assert.Equal(t, "(select 1"+SqlcmdEol+"+ 2"+SqlcmdEol+"+ 3"+SqlcmdEol+")", result) }) @@ -596,7 +596,7 @@ func TestReadExitContinuation(t *testing.T) { }, } - result, err := readExitContinuation(s, "(select 1)") + result, err := readExitContinuation(s, "(select 1)", 1) assert.NoError(t, err) assert.Equal(t, "(select 1)", result) assert.False(t, readLineCalled, "Readline should not be called for balanced input") @@ -622,13 +622,29 @@ func TestReadExitContinuation(t *testing.T) { } s.lineIo.SetPrompt("1> ") - result, err := readExitContinuation(s, "(select 1") + result, err := readExitContinuation(s, "(select 1", 1) assert.NoError(t, err) assert.Equal(t, "(select 1"+SqlcmdEol+")", result) // After function returns, prompt should be restored to original tc := s.lineIo.(*testConsole) assert.Equal(t, "1> ", tc.PromptText) }) + + t.Run("returns invalid command after unmatched closing parenthesis", func(t *testing.T) { + s := &Sqlcmd{} + s.lineIo = &testConsole{ + OnReadLine: func() (string, error) { + return "))", nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + } + + _, err := readExitContinuation(s, "(select 1", 7) + + assert.EqualError(t, err, InvalidCommandError("EXIT", 7).Error()) + }) } func TestExitCommandNonInteractiveUnbalanced(t *testing.T) { @@ -640,6 +656,26 @@ func TestExitCommandNonInteractiveUnbalanced(t *testing.T) { assert.EqualError(t, err, InvalidCommandError("EXIT", 1).Error(), "unbalanced parens in non-interactive should error") } +func TestExitCommandInteractiveOverclosedDoesNotReadContinuation(t *testing.T) { + readLineCalled := false + s := &Sqlcmd{ + lineIo: &testConsole{ + OnReadLine: func() (string, error) { + readLineCalled = true + return "", nil + }, + OnPasswordPrompt: func(prompt string) ([]byte, error) { + return nil, nil + }, + }, + } + + err := exitCommand(s, []string{"(select 1))"}, 4) + + assert.EqualError(t, err, InvalidCommandError("EXIT", 4).Error()) + assert.False(t, readLineCalled) +} + // TestExitCommandMultiLineInteractive is an integration test that exercises the full // multi-line EXIT flow: starting with unbalanced parentheses, reading continuation lines // from the console, executing the combined query, and returning the correct exit code. From 30030588951d9fc5c05ca2c07480c09bc5c8906b Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:11:26 -0500 Subject: [PATCH 6/6] docs: correct exit parenthesis helper comment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index fdc23b83..afd4ebd8 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -208,8 +208,8 @@ func (c Commands) SetBatchTerminator(terminator string) error { return nil } -// isExitParenBalanced checks if the parentheses in an EXIT command argument are balanced. -// It tracks quotes to avoid counting parens inside string literals. +// exitParenDepth returns the parenthesis depth of an EXIT command argument, or -1 if it over-closes. +// It tracks quotes to avoid counting parentheses inside string literals. // It handles SQL Server's quote escaping: '' inside single-quoted strings, "" inside double-quoted strings, and ]] inside bracket identifiers. // It also ignores parentheses inside SQL comments (-- single-line and /* multi-line */). func exitParenDepth(s string) int {