diff --git a/internal/mycli/client_side_statement_def.go b/internal/mycli/client_side_statement_def.go index f729d261..91039b5f 100644 --- a/internal/mycli/client_side_statement_def.go +++ b/internal/mycli/client_side_statement_def.go @@ -33,6 +33,37 @@ type clientSideStatementDescription struct { Note string } +// fuzzyCompletionType represents what kind of candidates to provide for argument completion. +type fuzzyCompletionType int + +const ( + fuzzyCompleteDatabase fuzzyCompletionType = iota + 1 + fuzzyCompleteVariable + fuzzyCompleteTable +) + +func (t fuzzyCompletionType) String() string { + switch t { + case fuzzyCompleteDatabase: + return "database" + case fuzzyCompleteVariable: + return "variable" + case fuzzyCompleteTable: + return "table" + default: + return fmt.Sprintf("unhandled fuzzyCompletionType: %d", t) + } +} + +// fuzzyArgCompletion defines how to detect and complete an argument for a client-side statement. +type fuzzyArgCompletion struct { + // PrefixPattern matches partial input and captures the argument being typed in group 1. + PrefixPattern *regexp.Regexp + + // CompletionType specifies what candidates to fetch. + CompletionType fuzzyCompletionType +} + type clientSideStatementDef struct { // Descriptions represents human-readable descriptions. // It can be multiple because some clientSideStatementDef represents multiple statements in single pattern. @@ -44,6 +75,10 @@ type clientSideStatementDef struct { // HandleSubmatch holds a handler which converts the result of (*regexp.Regexp).FindStringSubmatch() to Statement. HandleSubmatch func(matched []string) (Statement, error) + + // Completion defines optional fuzzy argument completion for this statement. + // When non-nil, each entry's PrefixPattern is tried against partial input to detect argument completion. + Completion []fuzzyArgCompletion } var schemaObjectsReStr = stringsiter.Join("|", hiter.Map(func(s string) string { @@ -81,6 +116,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ HandleSubmatch: func(matched []string) (Statement, error) { return &UseStatement{Database: unquoteIdentifier(matched[1]), Role: unquoteIdentifier(matched[2])}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*USE\s+(\S*)$`), + CompletionType: fuzzyCompleteDatabase, + }}, }, { Descriptions: []clientSideStatementDescription{ @@ -107,6 +146,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ HandleSubmatch: func(matched []string) (Statement, error) { return &DropDatabaseStatement{DatabaseId: unquoteIdentifier(matched[1])}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*DROP\s+DATABASE\s+(\S*)$`), + CompletionType: fuzzyCompleteDatabase, + }}, }, { Descriptions: []clientSideStatementDescription{ @@ -160,6 +203,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ schema, table := extractSchemaAndName(unquoteIdentifier(matched[1])) return &ShowColumnsStatement{Schema: schema, Table: table}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*SHOW\s+COLUMNS\s+FROM\s+(\S*)$`), + CompletionType: fuzzyCompleteTable, + }}, }, { Descriptions: []clientSideStatementDescription{ @@ -173,6 +220,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ schema, table := extractSchemaAndName(unquoteIdentifier(matched[1])) return &ShowIndexStatement{Schema: schema, Table: table}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*SHOW\s+(?:INDEX|INDEXES|KEYS)\s+FROM\s+(\S*)$`), + CompletionType: fuzzyCompleteTable, + }}, }, { Descriptions: []clientSideStatementDescription{ @@ -357,6 +408,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ schema, table := extractSchemaAndName(unquoteIdentifier(matched[1])) return &TruncateTableStatement{Schema: schema, Table: table}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*TRUNCATE\s+TABLE\s+(\S*)$`), + CompletionType: fuzzyCompleteTable, + }}, }, // EXPLAIN & EXPLAIN ANALYZE { @@ -741,6 +796,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ HandleSubmatch: func(matched []string) (Statement, error) { return &SetStatement{VarName: matched[1], Value: matched[2]}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*SET\s+([^\s=]*)$`), + CompletionType: fuzzyCompleteVariable, + }}, }, { Descriptions: []clientSideStatementDescription{ @@ -777,6 +836,10 @@ var clientSideStatementDefs = []*clientSideStatementDef{ HandleSubmatch: func(matched []string) (Statement, error) { return &ShowVariableStatement{VarName: matched[1]}, nil }, + Completion: []fuzzyArgCompletion{{ + PrefixPattern: regexp.MustCompile(`(?i)^\s*SHOW\s+VARIABLE\s+(\S*)$`), + CompletionType: fuzzyCompleteVariable, + }}, }, // Query Parameter { diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go index e01092f0..61245b3f 100644 --- a/internal/mycli/fuzzy_finder.go +++ b/internal/mycli/fuzzy_finder.go @@ -16,15 +16,25 @@ package mycli import ( "context" + "fmt" "log/slog" - "regexp" + "maps" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" + "cloud.google.com/go/spanner" "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" "github.com/hymkor/go-multiline-ny" "github.com/ktr0731/go-fuzzyfinder" "github.com/nyaosorg/go-readline-ny" + "google.golang.org/api/iterator" ) +const fuzzyFetchTimeout = 10 * time.Second + // fuzzyFinderCommand implements readline.Command for the fuzzy finder feature. // It detects the current input context and launches a fuzzy finder with // appropriate candidates. The selected value replaces the current argument @@ -44,17 +54,16 @@ func (f *fuzzyFinderCommand) SetEditor(e *multiline.Editor) { } func (f *fuzzyFinderCommand) Call(ctx context.Context, B *readline.Buffer) readline.Result { - // Use current line buffer, not the full multiline text. - // B is the buffer for the current line only, so argStartPos must be relative to it. - input := B.String() + // Use text up to cursor position, not the full line buffer. + // This ensures completion context depends on where the cursor is, + // not what follows it (e.g., cursor in middle of "USE db ROLE admin"). + input := B.SubString(0, B.Cursor) result := detectFuzzyContext(input) - if result.contextType == "" { - return readline.CONTINUE - } - candidates, err := f.fetchCandidates(ctx, result.contextType) + // Resolve candidates (may show loading indicator for network fetches). + candidates, err := f.resolveCandidates(ctx, result.completionType) if err != nil { - slog.Debug("fuzzy finder: failed to fetch candidates", "context", result.contextType, "err", err) + slog.Debug("fuzzy finder: failed to fetch candidates", "completionType", result.completionType, "err", err) return readline.CONTINUE } if len(candidates) == 0 { @@ -81,7 +90,14 @@ func (f *fuzzyFinderCommand) Call(ctx context.Context, B *readline.Buffer) readl return readline.CONTINUE } - selected := candidates[idx] + var selected string + if result.completionType != 0 { + // Argument completion: insert the candidate directly + selected = candidates[idx] + } else { + // Statement name completion: insert the fixed prefix text + selected = statementNameCandidates[idx].InsertText + } // Replace the argument portion: delete from argStartPos to end of buffer, // then insert the selected value. @@ -95,46 +111,160 @@ func (f *fuzzyFinderCommand) Call(ctx context.Context, B *readline.Buffer) readl return readline.CONTINUE } -// fuzzyContextType represents what kind of candidates to provide. -type fuzzyContextType = string - -const ( - fuzzyContextDatabase fuzzyContextType = "database" -) - // fuzzyContextResult holds the detected context, the argument prefix typed so far, // and the buffer position where the argument starts. type fuzzyContextResult struct { - contextType fuzzyContextType - argPrefix string // partial argument already typed (used as initial fzf query) - argStartPos int // position in the current line buffer where the argument starts (in runes) + completionType fuzzyCompletionType // 0 means statement name completion (fallback) + argPrefix string // partial argument already typed (used as initial fzf query) + argStartPos int // position in the current line buffer where the argument starts (in runes) } -// useContextRe matches "USE" followed by optional whitespace and captures any partial argument. -var useContextRe = regexp.MustCompile(`(?i)^\s*USE(\s+(\S*))?$`) - // detectFuzzyContext analyzes the current editor buffer to determine // what kind of fuzzy completion is appropriate. +// Priority: argument completion (if input matches a completable statement) > statement name completion. func detectFuzzyContext(input string) fuzzyContextResult { - if m := useContextRe.FindStringSubmatch(input); m != nil { - argPrefix := m[2] // may be empty - // argStartPos: position after "USE " in runes. - // Find where the argument starts by locating USE + whitespace. - argStart := len([]rune(input)) - len([]rune(argPrefix)) - return fuzzyContextResult{ - contextType: fuzzyContextDatabase, - argPrefix: argPrefix, - argStartPos: argStart, + // Try argument completion first: iterate all defs with Completion entries. + for _, def := range clientSideStatementDefs { + for _, comp := range def.Completion { + loc := comp.PrefixPattern.FindStringSubmatchIndex(input) + if loc == nil { + continue + } + argPrefix := input[loc[2]:loc[3]] + argStart := utf8.RuneCountInString(input[:loc[2]]) + return fuzzyContextResult{ + completionType: comp.CompletionType, + argPrefix: argPrefix, + argStartPos: argStart, + } + } + } + + // Fallback: statement name completion. + // argPrefix is the trimmed input; argStartPos is after leading spaces. + trimmed := strings.TrimLeftFunc(input, unicode.IsSpace) + leadingSpaces := len([]rune(input)) - len([]rune(trimmed)) + return fuzzyContextResult{ + completionType: 0, // statement name completion + argPrefix: trimmed, + argStartPos: leadingSpaces, + } +} + +// statementNameCandidate holds the display and insert text for statement name completion. +type statementNameCandidate struct { + DisplayText string // shown in fzf (e.g., "SHOW COLUMNS FROM ") + InsertText string // inserted into buffer (e.g., "SHOW COLUMNS FROM ") +} + +// statementNameCandidates is built at init time from clientSideStatementDefs. +var statementNameCandidates []statementNameCandidate + +func init() { + statementNameCandidates = buildStatementNameCandidates() +} + +// statementNameDisplayTexts returns the display texts for fzf. +func statementNameDisplayTexts() []string { + texts := make([]string, len(statementNameCandidates)) + for i, c := range statementNameCandidates { + texts[i] = c.DisplayText + } + return texts +} + +// buildStatementNameCandidates builds the candidate list from all client-side statement defs. +func buildStatementNameCandidates() []statementNameCandidate { + var candidates []statementNameCandidate + for _, def := range clientSideStatementDefs { + for _, desc := range def.Descriptions { + if desc.Syntax == "" { + continue + } + insertText := extractFixedPrefix(desc.Syntax) + candidates = append(candidates, statementNameCandidate{ + DisplayText: desc.Syntax, + InsertText: insertText, + }) + } + } + return candidates +} + +// extractFixedPrefix walks words in a syntax string until it hits a placeholder +// indicator (<, [, {, or ...), returning the keyword prefix. +// For no-arg statements, returns the full syntax (no trailing space). +// For statements with args, returns the keyword prefix with a trailing space. +func extractFixedPrefix(syntax string) string { + words := strings.Fields(syntax) + var fixed []string + for _, w := range words { + if len(w) > 0 && (w[0] == '<' || w[0] == '[' || w[0] == '{' || strings.HasPrefix(w, "...")) { + break } + fixed = append(fixed, w) + } + if len(fixed) == len(words) { + // No-arg statement: return full text without trailing space. + return strings.Join(fixed, " ") + } + // Has args: return prefix with trailing space. + return strings.Join(fixed, " ") + " " +} + +// requiresNetwork reports whether the completion type requires a network call. +func requiresNetwork(ct fuzzyCompletionType) bool { + switch ct { + case fuzzyCompleteDatabase, fuzzyCompleteTable: + return true + default: + return false + } +} + +// resolveCandidates returns candidates for the given completion type. +// For statement name completion (ct == 0), returns pre-built display texts. +// For network-dependent types, shows a loading indicator on the terminal and applies a timeout. +func (f *fuzzyFinderCommand) resolveCandidates(ctx context.Context, ct fuzzyCompletionType) ([]string, error) { + if ct == 0 { + return statementNameDisplayTexts(), nil + } + if !requiresNetwork(ct) { + return f.fetchCandidates(ctx, ct) + } + + // Show loading indicator below the editor. + rewind := f.editor.GotoEndLine() + out := f.editor.Out() + fmt.Fprint(out, "Loading...") + if err := out.Flush(); err != nil { + slog.Debug("fuzzy finder: flush loading indicator", "err", err) + } + + fetchCtx, cancel := context.WithTimeout(ctx, fuzzyFetchTimeout) + defer cancel() + + candidates, err := f.fetchCandidates(fetchCtx, ct) + + // Clear loading indicator and restore cursor. + fmt.Fprint(out, "\r\033[2K") + if err := out.Flush(); err != nil { + slog.Debug("fuzzy finder: flush clear loading", "err", err) } - return fuzzyContextResult{} + rewind() + + return candidates, err } -// fetchCandidates returns completion candidates for the given context type. -func (f *fuzzyFinderCommand) fetchCandidates(ctx context.Context, ctxType fuzzyContextType) ([]string, error) { - switch ctxType { - case fuzzyContextDatabase: +// fetchCandidates returns completion candidates for the given completion type. +func (f *fuzzyFinderCommand) fetchCandidates(ctx context.Context, ct fuzzyCompletionType) ([]string, error) { + switch ct { + case fuzzyCompleteDatabase: return f.fetchDatabaseCandidates(ctx) + case fuzzyCompleteVariable: + return f.fetchVariableCandidates(), nil + case fuzzyCompleteTable: + return f.fetchTableCandidates(ctx) default: return nil, nil } @@ -163,3 +293,52 @@ func (f *fuzzyFinderCommand) fetchDatabaseCandidates(ctx context.Context) ([]str } return databases, nil } + +// fetchVariableCandidates returns sorted system variable names from the registry. +func (f *fuzzyFinderCommand) fetchVariableCandidates() []string { + sv := f.cli.SystemVariables + if sv == nil { + return nil + } + names := slices.Sorted(maps.Keys(sv.ListVariables())) + return names +} + +// fetchTableCandidates lists table names from INFORMATION_SCHEMA.TABLES. +// Returns table names formatted as "schema.name" for non-default schemas, or just "name" for default schema. +func (f *fuzzyFinderCommand) fetchTableCandidates(ctx context.Context) ([]string, error) { + session := f.cli.SessionHandler.GetSession() + if session == nil || session.client == nil { + return nil, nil + } + + stmt := spanner.Statement{ + SQL: `SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_CATALOG = '' ORDER BY TABLE_SCHEMA, TABLE_NAME`, + } + + iter := session.client.Single().Query(ctx, stmt) + defer iter.Stop() + + var tables []string + for { + row, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("fetchTableCandidates: %w", err) + } + + var schema, name string + if err := row.Columns(&schema, &name); err != nil { + return nil, fmt.Errorf("fetchTableCandidates: %w", err) + } + + if schema == "" { + tables = append(tables, name) + } else { + tables = append(tables, schema+"."+name) + } + } + return tables, nil +} diff --git a/internal/mycli/fuzzy_finder_test.go b/internal/mycli/fuzzy_finder_test.go index 993dd541..9bacdecf 100644 --- a/internal/mycli/fuzzy_finder_test.go +++ b/internal/mycli/fuzzy_finder_test.go @@ -22,79 +22,341 @@ import ( func TestDetectFuzzyContext(t *testing.T) { tests := []struct { - name string - input string - wantContextType fuzzyContextType - wantArgPrefix string - wantArgStartPos int + name string + input string + wantCompletionType fuzzyCompletionType + wantArgPrefix string + wantArgStartPos int }{ + // Argument completion: USE → database { - name: "USE with trailing space", - input: "USE ", - wantContextType: fuzzyContextDatabase, - wantArgPrefix: "", - wantArgStartPos: 4, + name: "USE with trailing space", + input: "USE ", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "", + wantArgStartPos: 4, }, { - name: "USE without space", - input: "USE", - wantContextType: fuzzyContextDatabase, - wantArgPrefix: "", - wantArgStartPos: 3, + name: "USE with partial db name", + input: "USE apst", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "apst", + wantArgStartPos: 4, }, { - name: "USE with partial db name", - input: "USE apst", - wantContextType: fuzzyContextDatabase, - wantArgPrefix: "apst", - wantArgStartPos: 4, + name: "USE with full db name", + input: "USE my-database", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "my-database", + wantArgStartPos: 4, }, { - name: "USE with full db name", - input: "USE my-database", - wantContextType: fuzzyContextDatabase, - wantArgPrefix: "my-database", - wantArgStartPos: 4, + name: "use lowercase", + input: "use db", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "db", + wantArgStartPos: 4, }, { - name: "use lowercase", - input: "use db", - wantContextType: fuzzyContextDatabase, - wantArgPrefix: "db", - wantArgStartPos: 4, + name: "USE with leading space", + input: " USE ", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "", + wantArgStartPos: 6, }, + // Argument completion: SET → variable { - name: "USE with leading space", - input: " USE ", - wantContextType: fuzzyContextDatabase, - wantArgPrefix: "", - wantArgStartPos: 6, + name: "SET with partial name", + input: "SET CLI_", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "CLI_", + wantArgStartPos: 4, }, { - name: "SELECT query", - input: "SELECT 1", - wantContextType: "", + name: "SET with no name", + input: "SET ", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "", + wantArgStartPos: 4, }, { - name: "empty input", - input: "", - wantContextType: "", + name: "set lowercase", + input: "set cli_f", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "cli_f", + wantArgStartPos: 4, }, + // Argument completion: SHOW VARIABLE → variable { - name: "SHOW DATABASES", - input: "SHOW DATABASES", - wantContextType: "", + name: "SHOW VARIABLE with partial name", + input: "SHOW VARIABLE CLI_", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "CLI_", + wantArgStartPos: 14, + }, + { + name: "SHOW VARIABLE with no name", + input: "SHOW VARIABLE ", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "", + wantArgStartPos: 14, + }, + { + name: "show variable lowercase", + input: "show variable read", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "read", + wantArgStartPos: 14, + }, + // Argument completion: SHOW COLUMNS FROM → table + { + name: "SHOW COLUMNS FROM with trailing space", + input: "SHOW COLUMNS FROM ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 18, + }, + { + name: "SHOW COLUMNS FROM with partial table", + input: "SHOW COLUMNS FROM Sin", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "Sin", + wantArgStartPos: 18, + }, + // Argument completion: SHOW INDEX FROM → table + { + name: "SHOW INDEX FROM with trailing space", + input: "SHOW INDEX FROM ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 16, + }, + { + name: "SHOW INDEXES FROM with partial table", + input: "SHOW INDEXES FROM Al", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "Al", + wantArgStartPos: 18, + }, + { + name: "SHOW KEYS FROM", + input: "SHOW KEYS FROM ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 15, + }, + // Argument completion: TRUNCATE TABLE → table + { + name: "TRUNCATE TABLE with trailing space", + input: "TRUNCATE TABLE ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 15, + }, + { + name: "TRUNCATE TABLE with partial table", + input: "TRUNCATE TABLE Sin", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "Sin", + wantArgStartPos: 15, + }, + // Argument completion: DROP DATABASE → database + { + name: "DROP DATABASE with trailing space", + input: "DROP DATABASE ", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "", + wantArgStartPos: 14, + }, + { + name: "DROP DATABASE with partial name", + input: "DROP DATABASE my", + wantCompletionType: fuzzyCompleteDatabase, + wantArgPrefix: "my", + wantArgStartPos: 14, + }, + // SET with = should not match arg completion → falls through to statement name + { + name: "SET with = falls through to statement name", + input: "SET CLI_FORMAT = TABLE", + wantCompletionType: 0, + wantArgPrefix: "SET CLI_FORMAT = TABLE", + wantArgStartPos: 0, + }, + { + name: "SET with = no space falls through to statement name", + input: "SET CLI_FORMAT=TABLE", + wantCompletionType: 0, + wantArgPrefix: "SET CLI_FORMAT=TABLE", + wantArgStartPos: 0, + }, + // Statement name completion (fallback) + { + name: "USE without space falls through to statement name", + input: "USE", + wantCompletionType: 0, + wantArgPrefix: "USE", + wantArgStartPos: 0, + }, + { + name: "SET without space falls through to statement name", + input: "SET", + wantCompletionType: 0, + wantArgPrefix: "SET", + wantArgStartPos: 0, + }, + { + name: "partial statement name SHO", + input: "SHO", + wantCompletionType: 0, + wantArgPrefix: "SHO", + wantArgStartPos: 0, + }, + { + name: "empty input falls through to statement name", + input: "", + wantCompletionType: 0, + wantArgPrefix: "", + wantArgStartPos: 0, + }, + { + name: "SELECT query falls through to statement name", + input: "SELECT 1", + wantCompletionType: 0, + wantArgPrefix: "SELECT 1", + wantArgStartPos: 0, + }, + { + name: "SHOW DATABASES falls through to statement name", + input: "SHOW DATABASES", + wantCompletionType: 0, + wantArgPrefix: "SHOW DATABASES", + wantArgStartPos: 0, + }, + { + name: "SHOW VARIABLES falls through to statement name", + input: "SHOW VARIABLES", + wantCompletionType: 0, + wantArgPrefix: "SHOW VARIABLES", + wantArgStartPos: 0, + }, + { + name: "leading spaces preserved in statement name fallback", + input: " SHO", + wantCompletionType: 0, + wantArgPrefix: "SHO", + wantArgStartPos: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := detectFuzzyContext(tt.input) - assert.Equal(t, tt.wantContextType, got.contextType) - if tt.wantContextType != "" { - assert.Equal(t, tt.wantArgPrefix, got.argPrefix) - assert.Equal(t, tt.wantArgStartPos, got.argStartPos) - } + assert.Equal(t, tt.wantCompletionType, got.completionType) + assert.Equal(t, tt.wantArgPrefix, got.argPrefix) + assert.Equal(t, tt.wantArgStartPos, got.argStartPos) }) } } + +func TestExtractFixedPrefix(t *testing.T) { + tests := []struct { + name string + syntax string + want string + }{ + { + name: "no-arg statement", + syntax: "SHOW DATABASES", + want: "SHOW DATABASES", + }, + { + name: "single-arg with angle bracket", + syntax: "USE [ROLE ]", + want: "USE ", + }, + { + name: "multi-keyword prefix", + syntax: "SHOW COLUMNS FROM ", + want: "SHOW COLUMNS FROM ", + }, + { + name: "optional bracket", + syntax: "SHOW TABLES []", + want: "SHOW TABLES ", + }, + { + name: "curly brace alternatives", + syntax: "START BATCH {DDL|DML}", + want: "START BATCH ", + }, + { + name: "no-arg single word", + syntax: "HELP", + want: "HELP", + }, + { + name: "no-arg two words", + syntax: "EXIT", + want: "EXIT", + }, + { + name: "ellipsis in args", + syntax: "DUMP TABLES [, , ...]", + want: "DUMP TABLES ", + }, + { + name: "complex multi-keyword with angle bracket", + syntax: "SHOW INDEX FROM ", + want: "SHOW INDEX FROM ", + }, + { + name: "EXPLAIN with angle bracket", + syntax: "EXPLAIN [FORMAT=] [WIDTH=] ", + want: "EXPLAIN ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFixedPrefix(tt.syntax) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBuildStatementNameCandidates(t *testing.T) { + candidates := buildStatementNameCandidates() + + // Should have candidates (at least as many as defs with descriptions) + assert.Greater(t, len(candidates), 0) + + // Each candidate should have non-empty DisplayText and InsertText + for _, c := range candidates { + assert.NotEmpty(t, c.DisplayText, "DisplayText should not be empty") + assert.NotEmpty(t, c.InsertText, "InsertText should not be empty") + } + + // Verify specific well-known candidates exist + displayTexts := make(map[string]string) + for _, c := range candidates { + displayTexts[c.DisplayText] = c.InsertText + } + + // No-arg statement: full text, no trailing space + assert.Equal(t, "SHOW DATABASES", displayTexts["SHOW DATABASES"]) + + // Arg statement: keyword prefix with trailing space + assert.Equal(t, "USE ", displayTexts["USE [ROLE ]"]) + + // Multi-keyword prefix + assert.Equal(t, "SHOW COLUMNS FROM ", displayTexts["SHOW COLUMNS FROM "]) +} + +func TestStatementNameDisplayTexts(t *testing.T) { + texts := statementNameDisplayTexts() + assert.Equal(t, len(statementNameCandidates), len(texts)) + for i, c := range statementNameCandidates { + assert.Equal(t, c.DisplayText, texts[i]) + } +}